Photo Upload
Two-section photo capture component for field reporting apps. Each section has a configurable slot grid with live camera overlay, view mode for reviewing captures, replace/remove per slot, and per-slot progress badges. Fully themeable via StyleConfig.
- 1. Go to the Components tab (right side panel, next to Screens)
- 2. Click New component — this creates a blank component
- 3. Click outside the new component to deselect it
- 4. Paste the YAML with Ctrl+V / ⌘V
Preview
All image types · Max 5 MB per photo
All image types · Max 5 MB per photo
Tap an empty slot to open the camera overlay · Tap a filled slot to view/manage
Installation
This component requires Modern controls to be enabled. Settings → Updates → Preview → Modern controls and themes
# In Power Apps Studio:
1. Components tab → New component → Import from code
2. Paste the full YAML definition
3. Component appears as "cmpPhotoUpload" in library
# Host screen OnVisible — required to initialize collections:
ClearCollect(_cmpTopPhotos, {SlotIndex: 0, Photo: Blank(), Name: ""});
Clear(_cmpTopPhotos);
ClearCollect(_cmpBottomPhotos, {SlotIndex: 0, Photo: Blank(), Name: ""});
Clear(_cmpBottomPhotos);
Set(_cmpShowOverlay, false);
Set(_cmpOverlaySection, "top");
Set(_cmpOverlayMode, "capture");
Set(_cmpViewSlot, 0)Usage
Basic setup
// Set on the component instance
cmpPhotoUpload1.Width = Parent.Width
cmpPhotoUpload1.MaxTopPhotos = 3
cmpPhotoUpload1.MaxBottomPhotos = 3
cmpPhotoUpload1.MaxColumns = 4
cmpPhotoUpload1.TopSectionLabel = "Header Photos"
cmpPhotoUpload1.BottomSectionLabel = "Footer Photos"
cmpPhotoUpload1.ShowActions = false // host screen handles Save
// Read output collections
cmpPhotoUpload1.OnSave =
EMS_UploadPhotos.Run(
Text(gSelectedProject.ID),
JSON(_cmpTopPhotos, JSONFormat.IncludeBinaryData),
JSON(_cmpBottomPhotos, JSONFormat.IncludeBinaryData)
)Send photos to Power Automate
// Flow trigger: PowerApps V2
// Inputs: ProjectID (text), TopPhotos (text), BottomPhotos (text)
//
// In your Save button OnSelect:
EMS_UploadPhotos.Run(
Text(gSelectedProject.ID),
JSON(_cmpTopPhotos, JSONFormat.IncludeBinaryData),
JSON(_cmpBottomPhotos, JSONFormat.IncludeBinaryData)
)
// Flow: Parse JSON both arrays, then Create File (SharePoint)
// Schema: [{ SlotIndex: Number, Photo: string, Name: string }]
// File content: base64ToBinary(item()?['Photo'])Custom theme (dark mode)
cmpPhotoUpload1.StyleConfig = {
colors: {
cardBg: ColorValue("#16223A"),
cardBorder: ColorValue("#2A3855"),
cardBorderSlot: ColorValue("#374A73"),
divider: ColorValue("#2A3855"),
camPillBg: ColorValue("#23304B"),
camPillIcon: ColorValue("#588CDC"),
titleText: ColorValue("#DCE4F5"),
slotEmptyBg: ColorValue("#1C263C"),
slotIconBg: ColorValue("#263452"),
slotIconColor: ColorValue("#5878AF"),
helperText: ColorValue("#506C9B"),
cancelBorder: ColorValue("#374869"),
imageBorder: ColorValue("#4A6A8A"),
overlayBg: ColorValue("#0A1020"),
overlayHeader: ColorValue("#111E35")
},
radius: { card: 16, slot: 12, pill: 8 }
}Properties
Input
| Property | Type | Default |
|---|---|---|
TopSectionLabelHeader label for the top photo section. | Text | "Top Photos — Report Header" |
BottomSectionLabelHeader label for the bottom photo section. | Text | "Bottom Photos — Report Footer" |
MaxTopPhotosTotal photo slots in the top section — wraps at MaxColumns per row. | Number | 3 |
MaxBottomPhotosTotal photo slots in the bottom section. | Number | 3 |
MaxColumnsSlots per row before wrapping. Default 4 gives 3 items comfortable width on mobile. | Number | 4 |
MaxFileSizeMBInformational max file size shown in helper text. Actual enforcement in the flow. | Number | 5 |
ShowActionsShow or hide Cancel / Save buttons. Set false when host screen handles actions. | Boolean | true |
StyleConfigColor and radius tokens as a Record. Override any field to theme the component. | Record | See defaults |
Events
| Event | Description |
|---|---|
OnSave | Fires when the Save Photos button is tapped. Read _cmpTopPhotos and _cmpBottomPhotos to get photo data. |
Output Collections
| Collection | Schema | Description |
|---|---|---|
_cmpTopPhotos | { SlotIndex: Number, Photo: Image, Name: Text } | All captured top section photos. SlotIndex is 1-based. |
_cmpBottomPhotos | { SlotIndex: Number, Photo: Image, Name: Text } | All captured bottom section photos. |
StyleConfig Tokens
Override any color or radius without redefining the whole record. Unspecified fields fall back to defaults.
cmpPhotoUpload1.StyleConfig = {
colors: {
cardBg: ColorValue("#FFFFFF"), // Section card background
cardBorder: ColorValue("#DCE4F2"), // Section card border
cardBorderSlot: ColorValue("#B0C0D8"), // Empty slot dashed border
divider: ColorValue("#E8EDF5"), // Horizontal divider lines
camPillBg: ColorValue("#EEF3FB"), // Camera icon pill background
camPillIcon: ColorValue("#3A6BC4"), // Camera icon color
titleText: ColorValue("#1E3A6B"), // Section title text
slotEmptyBg: ColorValue("#F7F9FC"), // Empty slot fill
slotIconBg: ColorValue("#DCE4F2"), // Camera icon circle fill
slotIconColor: ColorValue("#6482B4"), // Camera icon + "Add Photo" text
helperText: ColorValue("#9BACC4"), // "All image types · Max X MB" text
cancelBorder: ColorValue("#D1DAEB"), // Cancel button border
imageBorder: ColorValue("#A8C5EA"), // Filled slot image border
overlayBg: ColorValue("#0F1826"), // Camera overlay background
overlayHeader: ColorValue("#1A2740") // Camera overlay header bar
},
radius: {
card: 16, // Section card corner radius
slot: 12, // Individual slot corner radius
pill: 8 // Camera icon pill corner radius
}
}Examples
Field report — water damage (EMS)
// scrNewReport — Step 4: Photos
cmpPhotoUpload1.Width = Parent.Width
cmpPhotoUpload1.TopSectionLabel = "Header Photos"
cmpPhotoUpload1.BottomSectionLabel = "Footer Photos"
cmpPhotoUpload1.MaxTopPhotos = 3
cmpPhotoUpload1.MaxBottomPhotos = 3
cmpPhotoUpload1.MaxColumns = 3
cmpPhotoUpload1.ShowActions = false
// btnNextStep.OnSelect
If(
CountRows(_cmpTopPhotos) = 0 And
CountRows(_cmpBottomPhotos) = 0,
Notify("Add at least one photo", NotificationType.Warning),
Navigate(scrExportPreview)
)Profile photo capture — single slot
// Single slot, top section only
// Hide bottom section by setting MaxBottomPhotos = 0
// is not supported — instead set the label and use
// CountRows to gate on just the top collection
cmpPhotoUpload1.TopSectionLabel = "Profile Photo"
cmpPhotoUpload1.BottomSectionLabel = "Profile Photo"
cmpPhotoUpload1.MaxTopPhotos = 1
cmpPhotoUpload1.MaxBottomPhotos = 0
cmpPhotoUpload1.MaxColumns = 1
cmpPhotoUpload1.ShowActions = false
// Read the single captured photo
First(_cmpTopPhotos).PhotoDark theme with custom accent
cmpPhotoUpload1.MaxTopPhotos = 4
cmpPhotoUpload1.MaxColumns = 4
cmpPhotoUpload1.TopSectionLabel = "Site Photos"
cmpPhotoUpload1.StyleConfig = {
colors: {
cardBg: ColorValue("#16223A"),
cardBorder: ColorValue("#2A3855"),
cardBorderSlot: ColorValue("#374A73"),
divider: ColorValue("#2A3855"),
camPillBg: ColorValue("#23304B"),
camPillIcon: ColorValue("#588CDC"),
titleText: ColorValue("#DCE4F5"),
slotEmptyBg: ColorValue("#1C263C"),
slotIconBg: ColorValue("#263452"),
slotIconColor: ColorValue("#5878AF"),
helperText: ColorValue("#506C9B"),
imageBorder: ColorValue("#4A6A8A"),
overlayBg: ColorValue("#0A1020"),
overlayHeader: ColorValue("#111E35")
},
radius: { card: 16, slot: 10, pill: 8 }
}Power Automate upload flow
// btnSaveDraft.OnSelect — trigger the upload flow
Set(varIsSaving, true);
IfError(
EMS_UploadPhotos.Run(
Text(gSelectedProject.ID),
JSON(_cmpTopPhotos, JSONFormat.IncludeBinaryData),
JSON(_cmpBottomPhotos, JSONFormat.IncludeBinaryData)
),
Notify("Upload failed: " & FirstError.Message, NotificationType.Error);
Set(varIsSaving, false);
false
);
Clear(_cmpTopPhotos);
Clear(_cmpBottomPhotos);
Set(varIsSaving, false);
Navigate(scrProjects, ScreenTransition.None)
// ─────────────────────────────────────────────────
// Power Automate flow structure:
// Trigger: PowerApps V2
// Inputs: ProjectID (text), TopPhotos (text), BottomPhotos (text)
//
// Parse JSON — TopPhotos
// Schema: array of { SlotIndex: number, Photo: string, Name: string }
//
// Apply to each (TopPhotos)
// Create file (SharePoint)
// Site: your SharePoint site
// Folder: /EMS/Projects/@{triggerBody()?['ProjectID']}/Header
// File Name: @{item()?['Name']}
// File Content: @{base64ToBinary(item()?['Photo'])}
//
// (repeat Parse JSON + Apply to each for BottomPhotos → /Footer)Implementation Details
Camera overlay pattern
The overlay is a ManualLayout GroupContainer at X=0, Y=0 inside the component, Height=App.Height. The host screen's container clips it to the remaining screen space. This means no screen-level controls or coordinate math — the component is fully self-contained.
Slot index tracking
Rather than tracking which slot is active in a variable, capture mode uses Min(Filter(Sequence(maxSlots), !(Value in col.SlotIndex)), Value) to find the first genuinely empty slot. This correctly handles gaps after removal — if the user removes slot 2 and captures again, the photo fills slot 2 not slot 4.
View mode
Tapping a filled slot sets _cmpOverlayMode = "view" and _cmpViewSlot to that slot's index. The camera panel is hidden and replaced by imgViewFull. Tapping any filled thumbnail in the right strip switches the large view. The Replace button removes the current slot's photo and flips back to capture mode — the first-empty-slot logic then fills that same index.
Auto-close on full
After every capture, camOverlay.OnSelect checks if CountRows(collection) >= MaxPhotos. If all slots are filled the overlay closes automatically — the user lands back on the grid with all thumbnails populated.
SVG badges
Check marks and remove icons use static SVG data URIs on Image@2.2.3 controls rather than Classic/Icon. This avoids the Power Apps SVG caching bug (dynamic colors in data URIs don't re-evaluate reactively) and gives consistent rendering on both iOS and Android. Colors are baked into the SVG string.
Collection initialization
The ClearCollect + Clear pattern in OnVisible is required. The ClearCollect with a typed dummy record establishes the column schema so Power Apps resolves SlotIndex, Photo, and Name as typed columns. The Clear then empties it. Skipping this step causes LookUp and Filter to fail at runtime with type errors.
Architecture
~45 controls — GroupContainers, Galleries, Images, Buttons, Text, Rectangle, Badge, Camera.
Self-contained overlay — camera UI lives inside the component, no screen-level controls needed.
Zero cross-control references — all height math uses inlined With() formulas to survive copy/paste.
cmpPhotoUpload
├── conTopSection (AutoLayout Vertical)
│ ├── conTopHeader (AutoLayout Horizontal)
│ │ ├── conTopCamPill → icnTopCam
│ │ ├── lblTopTitle
│ │ └── badgeTopCount (Badge@0.0.24)
│ ├── recTopDivider
│ ├── galTopSlots (Vertical, WrapCount=MaxColumns)
│ │ ├── conTopEmpty (dashed placeholder)
│ │ │ ├── conTopEmptyIcon → icnTopEmptyCam
│ │ │ └── lblTopAddPhoto
│ │ ├── btnTopTap (transparent, opens overlay — capture)
│ │ ├── imgTopPreview (filled slot photo)
│ │ ├── btnTopView (transparent, opens overlay — view)
│ │ ├── imgTopCheck (SVG green check badge)
│ │ └── imgTopRemove (SVG X button)
│ └── lblTopHelper
│
├── conBottomSection (mirrors top section)
│
├── conActions (AutoLayout Horizontal)
│ ├── btnCancel
│ └── btnSave → OnSave()
│
└── conCamOverlay (ManualLayout, X=0 Y=0, Height=App.Height)
├── conOverlayHeader (AutoLayout Horizontal)
│ ├── btnOverlayBack
│ ├── lblOverlayTitle (section + mode label)
│ └── btnOverlayConfirm
├── camOverlay (Camera@2.4.0, visible in capture mode)
├── imgViewFull (Image@2.2.3, visible in view mode)
├── btnReplacePhoto (visible in view mode)
└── galOverlayThumbs (Vertical, right 38%)
├── btnThumbSelect (view mode tap target)
├── imgThumb (photo or SampleImage placeholder)
├── lblThumbNum
└── imgThumbRemove (SVG red X, centered bottom)Community
Use the toolbar to format · or type markdown directly