Wednesday, August 12, 2026

Nested Gallery in Power Apps – Group, Expand/Collapse & Dynamic Height

 𝐍𝐞𝐬𝐭𝐞𝐝 𝐆𝐚𝐥𝐥𝐞𝐫𝐲 𝐢𝐧 𝐏𝐨𝐰𝐞𝐫 𝐀𝐩𝐩𝐬 - 𝐆𝐫𝐨𝐮𝐩 & 𝐄𝐱𝐩𝐚𝐧𝐝/𝐂𝐨𝐥𝐥𝐚𝐩𝐬𝐞 

Introduction

A common requirement in a Canvas App is to take a large list of records and make it easier to work with. One of the requests that comes up often is: group the records by a field, let users expand and collapse each group, and still allow the user to search and take actions on the records.

A normal Table control is useful for a flat list, but it does not provide the grouped, expandable experience we need. The approach in this article uses a parent Gallery for the groups and a nested Gallery for the records inside each group. The grouping itself is created in Power Fx using GroupBy().



The example below uses a simple Help Desk / Request Management scenario with Departments, Request Types, and Requests. The pattern is not specific to Help Desk. The same approach can be used for Customers, Projects, Regions, Categories, Managers, or other business data.

What this article covers

  • Why the Table control is not enough when the requirement becomes hierarchical.

  • The overall parent-gallery and nested-gallery architecture.

  • How GroupBy() converts a flat Dataverse dataset into grouped records.

  • How to implement expand/collapse using colExpandedGroups.

  • How the flexible-height gallery solves the reflow problem.

  • How search and sorting are applied before grouping.

  • How child fields stay aligned with a static gallery header.

  • How multi-select is implemented with colSelectedItems.

  • How the selected records can be updated in bulk.

  • A few Dataverse-specific issues that are useful to know when applying the pattern.


1. The Problem: Flat Data, Hierarchical Requirement

The starting point is a normal list of Requests. Each row contains fields such as Request Number, Request Type, Department, Priority, Status, Due Date, Assigned To, and Owner.

The Table control already gives us useful functionality such as multi-select and sorting. The problem is that the data is still flat. If the user has thousands of records, it becomes difficult to scan the list or focus on one department at a time.

The requirement is therefore slightly different: the user should see a group header such as IT or HR, expand that group, see only its requests, and collapse it again when finished.

The design decision
This is not simply a visual change from Table to Gallery. The requirement needs a hierarchical UI, so we need one Gallery to represent the groups and another Gallery to represent the records inside each group.


2. Architecture: Two Galleries, One Data Transformation

The solution has three main pieces. First, Power Fx transforms the flat Requests table into grouped data. Second, the outer Gallery renders one row per group. Third, a child Gallery inside the outer Gallery renders the requests that belong to the current group.

Dataverse Requests
        |
        v
      Filter
        |
        v
      Sort
        |
        v
  Create GroupKey
        |
        v
      GroupBy()
        |
        v
  Sort the Groups
        |
        v
  GroupsGallery
        |
        +---- RequestsGallery
              (ThisItem.GroupedRecords)

There is an important distinction here. This is not the usual nested-gallery pattern where a parent record has a formal one-to-many relationship with child records, such as an Invoice and its Line Items. Here, the source data is flat and the grouping is created at runtime with GroupBy().

That makes the pattern reusable. The grouping key can be a Department, Customer, Region, Project, Category, Manager, or another business field.

3. Dataverse Data Model

The example uses three main Dataverse tables. The canvas app also uses Users and Teams as supporting data sources for the user/profile and Owner display.

Table

What it holds

Key columns

Department

The list of departments — IT, HR, Finance, Facilities, and so on

Department Name, Display Order

Request Type

The type of request under each department — Software Request, Leave Approval, VPN Request, etc.

Request Type Name, Department (lookup)

Request

The main table — this is what shows up in the app, grouped by department

Request, Request Type, Department, Priority, Status, Due Date, Assigned To, Owner


The Department field on Requests is used to determine the group. Display Order on Departments is used to control the order of the group headers without hard-coding IT, HR, Finance, and so on in the Canvas App.

Data source reminder
The canvas app connects to Requests, Request Types, Departments, Users, and Teams. Users and Teams are supporting sources used by the profile/Owner parts of the app.


4. Screen and Component Overview

The application uses a few supporting components around the gallery. These provide the search, column headers, user information, and bulk actions while the main focus remains the nested-gallery pattern. The header, gallery header, and action button containers are straightforward layout pieces, so the sections below describe what each one contains and how it's configured, rather than treating them as the main event.

RequestsScreen
|
+-- HeaderContainer
|
+-- MainContainer
|   |
|   +-- SearchRecordsText
|   |
|   +-- GalleryHeaderContainer
|   |
|   +-- GroupsGallery
|       |
|       +-- GroupChevronIcon
|       +-- GroupHeaderLbl
|       |
|       +-- RequestsGallery
|           |
|           +-- SelectCheckbox
|           +-- RequestNumberLbl
|           +-- TitleLbl / other field labels
|           +-- RequestTypeLbl
|           +-- PriorityLbl
|           +-- StatusLbl
|           +-- DueDateLbl
|           +-- AssignedToLbl
|           +-- OwnerLbl
|
+-- NoRecordsLbl
|
+-- ButtonContainer
    |
    +-- MarkInProgressButton
    +-- MarkCompletedButton

The HeaderContainer provides the application title, refresh action, and current-user information. The GalleryHeaderContainer is the static row of column headers. The Buttons at the bottom use the selected-record collection for bulk actions.


Component

Purpose

HeaderContainer

Displays the application title, refresh action, and current user information

SearchRecordsText

Captures the user's search text

GalleryHeaderContainer

Displays the column headers used to align the request fields

GroupsGallery

Displays one row for each group

GroupHeaderLbl

Displays the group name and record count

GroupChevronIcon

Indicates and controls the expanded/collapsed state

RequestsGallery

Displays the individual records belonging to the selected group

SelectCheckbox

Allows users to select individual records

ButtonContainer

Contains the bulk-action buttons

MarkInProgressButton

Updates selected records to In Progress

MarkCompletedButton

Updates selected records to Closed

NoRecordsLbl

Displays a message when no records match the search


5. Add the Supporting Header

The top header is a supporting application component rather than part of the nested-gallery logic. Add a HeaderContainer at the top of the screen to provide the application title, refresh action, and current-user information.

Control

Property

Value / Formula

HeaderContainer

LayoutDirection

Horizontal

HeaderContainer

LayoutJustifyContent

SpaceBetween


The main purpose is to keep the refresh action, application title, and user profile available without distracting from the gallery implementation.

6. Add the Search Box and Gallery Header

The search input sits above the data area. On its own, it doesn't do any filtering — it only captures what the user types. The actual filtering happens later, inside GroupsGallery.Items.

Control

Property

Value / Formula

SearchRecordsText

Control

Modern text input configured as a search field

SearchRecordsText

Placeholder

Filter Records / Search requests

SearchRecordsText

Value used by formulas

SearchRecordsText.Value


The GalleryHeaderContainer is the static column-header row that sits above GroupsGallery. It's laid out horizontally, and each individual header control has its own width, which together define where each column sits. The point of setting these up as a distinct row of controls — rather than just typing "Request #", "Title", and so on as plain text — is that every field inside the nested gallery will align itself to these exact headers a bit later, instead of using fixed pixel positions.

Control

Property

Value / Formula

GalleryHeaderContainer

LayoutDirection

Horizontal

GalleryHeaderContainer

Width

Parent.Width


7. Create the Parent GroupsGallery

Insert a blank flexible-height Gallery and rename it GroupsGallery. This is the outer gallery and it will represent groups, not individual requests.

Control

Property

Value / Formula

GroupsGallery

Gallery variant

Blank flexible height / VariableHeight

GroupsGallery

Name

GroupsGallery


The flexible-height variant is important because the height of a group changes when its child Gallery is expanded or collapsed. The outer Gallery needs to re-measure its template and move the groups below it.

8. Build the Complete Grouping Formula

The complete GroupsGallery.Items formula is easier to understand if it is read from the inside out. The complete formula is shown first and then explained step by step from the inside out.

SortByColumns(
    AddColumns(
        GroupBy(
            AddColumns(
                Sort(
                    Filter(
                        Requests,
                        IsBlank(SearchRecordsText.Value) ||
                        SearchRecordsText.Value in 'Request Name' ||
                        SearchRecordsText.Value in 'Request number' ||
                        SearchRecordsText.Value in Text(Request) ||
                        SearchRecordsText.Value in 'Assigned To'
                    ),
                    'Due Date',
                    SortOrder.Ascending
                ),
                GroupKey,
                Department.'Department Name'
            ),
            GroupKey,
            GroupedRecords
        ),
        SortRank,
        LookUp(
            Departments,
            'Department Name' = GroupKey,
            'Display Order'
        )
    ),
    "SortRank",
    SortOrder.Ascending
)


8.1 Filter the records first

The innermost Filter() is where the search is applied. If the search input is empty, all Requests are returned. Otherwise, only matching records continue through the rest of the formula.

Filter(
    Requests,
    IsBlank(SearchRecordsText.Value) ||
    SearchRecordsText.Value in 'Request Name' ||
    SearchRecordsText.Value in 'Request number' ||
    SearchRecordsText.Value in Text(Request) ||
    SearchRecordsText.Value in 'Assigned To'
)

Filtering before GroupBy() is important. The group counts and the records inside each group then reflect the current search result, rather than showing groups based on the full unfiltered table.

8.2 Sort the individual records

Sort(
    Filter(...),
    'Due Date',
    SortOrder.Ascending
)

This controls the order of the individual Requests within each group. The current implementation uses ascending Due Date, so earlier due dates appear first.

8.3 Create GroupKey

AddColumns(
    Sort(...),
    GroupKey,
    Department.'Department Name'
)

GroupKey is created dynamically. It is not a Dataverse column. In this example the Department lookup is used to read Department Name. Using a generic name such as GroupKey also makes the pattern easier to reuse in other apps.

8.4 Group the records with GroupBy()

GroupBy(
    AddColumns(...),
    GroupKey,
    GroupedRecords
)

This is the main transformation. Instead of one row for every Request, the result contains one row for every unique GroupKey. Each group row carries a nested table called GroupedRecords, containing the Requests that belong to that group.

Important concept
GroupedRecords is not a column in Dataverse. GroupBy() creates it at runtime. This is why the nested Gallery can later use ThisItem.GroupedRecords as its Items property.


8.5 Add SortRank and sort the groups

AddColumns(
    GroupBy(...),
    SortRank,
    LookUp(
        Departments,
        'Department Name' = GroupKey,
        'Display Order'
    )
)

SortByColumns(
    ...,
    "SortRank",
    SortOrder.Ascending
)

The first Sort() in the formula orders Requests. The final SortByColumns() orders the groups. Display Order is stored in Dataverse, so the business can change group order without changing the Canvas App formula.

9. Add the Nested RequestsGallery

Inside the GroupsGallery template, insert a standard vertical Gallery and rename it RequestsGallery. The nested Gallery should be a direct child of the outer Gallery template. Do not wrap it in another Group or container, because the variable-height reflow relies on the nested Gallery being directly inside the template.

Control

Property

Value / Formula

RequestsGallery

Name

RequestsGallery

RequestsGallery

Items

ThisItem.GroupedRecords

RequestsGallery

Width

Parent.TemplateWidth


RequestsGallery.Items

ThisItem.GroupedRecords

The parent Gallery has already performed the grouping. The child Gallery only needs to read the GroupedRecords table belonging to the current group row.

10. Initialize the Client-Side State

Two collections are used for UI state. colExpandedGroups tracks which groups are open, while colSelectedItems tracks which Requests the user has selected.

10.1 App.OnStart

ClearCollect(colSelectedItems, FirstN(Requests, 0));
ClearCollect(colExpandedGroups, [{GroupKey: "***init***"}]);
Clear(colExpandedGroups);

The first line creates an empty selection collection with the same record shape as Requests. The second and third lines establish the GroupKey column in colExpandedGroups and then clear the temporary row so the app starts with no expanded groups.

Keep the two states separate
colExpandedGroups controls the UI layout. colSelectedItems controls record selection. Neither collection replaces Dataverse as the system of record.


11. Add the Group Header and Chevron

Inside GroupsGallery, add a group header label and a chevron icon. The label shows the department name and current record count. The chevron indicates whether the group is expanded or collapsed.

Control

Property

Value / Formula

GroupHeaderLbl

Text

ThisItem.GroupKey & " (" & CountRows(ThisItem.GroupedRecords) & ")"

GroupHeaderLbl

X

40

GroupHeaderLbl

Width

GroupsGallery.Width - 60


GroupHeaderLbl.Text

ThisItem.GroupKey &
" (" &
CountRows(ThisItem.GroupedRecords) &
")

The parent Gallery row now represents a group rather than a Request, so ThisItem.GroupKey is the department value for the current group. CountRows(ThisItem.GroupedRecords) gives the number of matching Requests in that group.

11.1 Chevron state

GroupChevronIcon.Icon

If(
    !IsEmpty(
        Filter(
            colExpandedGroups,
            GroupKey = ThisItem.GroupKey
        )
    ),
    Icon.ChevronDown,
    Icon.ChevronRight
)

The icon reads the same colExpandedGroups collection used by RequestsGallery.Visible. That keeps the visual state and the actual expansion state in sync.

11.2 Expand/collapse OnSelect

GroupHeaderLbl.OnSelect and GroupChevronIcon.OnSelect

If(
    !IsEmpty(
        Filter(
            colExpandedGroups,
            GroupKey = ThisItem.GroupKey
        )
    ),
    ForAll(
        ThisItem.GroupedRecords As Row,
        Remove(
            colSelectedItems,
            LookUp(
                colSelectedItems,
                'Request number' = Row.'Request number'
            )
        )
    );
    Remove(
        colExpandedGroups,
        LookUp(
            colExpandedGroups,
            GroupKey = ThisItem.GroupKey
        )
    ),
    Collect(
        colExpandedGroups,
        {GroupKey: ThisItem.GroupKey}
    )
)

The same OnSelect formula is used on the group label and chevron. If the group is already expanded, it is removed from colExpandedGroups. If the group is collapsed, its GroupKey is collected.

There is one deliberate UX rule in the collapse branch: selected records belonging to the group are also removed from colSelectedItems. That prevents hidden records from remaining selected for a later bulk action.

12. Control the Nested Gallery Visibility and Height

Control

Property

Value / Formula

RequestsGallery

Visible

!IsEmpty(Filter(colExpandedGroups, GroupKey = ThisItem.GroupKey))

RequestsGallery

TemplateSize

52

RequestsGallery

Height

If(Self.Visible, CountRows(ThisItem.GroupedRecords) * 52, 0)


RequestsGallery.Visible

!IsEmpty(
    Filter(
        colExpandedGroups,
        GroupKey = ThisItem.GroupKey
    )
)

RequestsGallery.Height

If(
    Self.Visible,
    CountRows(ThisItem.GroupedRecords) * 52,
    0
)

The child Gallery is visible only when its GroupKey exists in colExpandedGroups. Its height is the number of grouped records multiplied by the fixed child row height of 52 pixels.

The key point is that the child Gallery does not use a fixed height large enough for every group. The height changes with the number of records. Because GroupsGallery is the flexible-height variant, the groups below it move down when the child Gallery grows and move back up when it collapses.

13. The Reflow Problem: Why Flexible Height Matters

It is useful to understand what happens when the parent Gallery is a normal Vertical Gallery. If the child Gallery grows but the parent template does not re-measure its height, the expanded records can overlap the next group instead of pushing it down.

A similar issue occurs if the inner Gallery is wrapped inside a Group container and the container height is changed programmatically. For this pattern, the reliable structure is a variable-height outer Gallery with the child Gallery directly inside the template.

Practical rule
If you are debugging overlap, first check the parent Gallery variant and then check that RequestsGallery is directly inside the GroupsGallery template.


14. Align the Child Fields with the Header

The static GalleryHeaderContainer sits above the grouped data. The fields inside RequestsGallery should use the corresponding header controls for X and Width instead of unrelated hard-coded values.

Control

Property

Value / Formula

SelectCheckbox

X

GalleryHeaderContainer.X + CheckboxHeader.X

RequestNumberLbl

X

GalleryHeaderContainer.X + RequestNumberHeader.X

RequestNumberLbl

Width

RequestNumberHeader.Width


Example: RequestNumberLbl.X

GalleryHeaderContainer.X + RequestNumberHeader.X

Example: RequestNumberLbl.Width

RequestNumberHeader.Width

The same pattern is repeated for Department, Request Type, Priority, Status, Due Date, Assigned To, Owner, and the other displayed fields. This keeps the data columns directly underneath their static headers and makes the layout more stable when the screen width changes.


14.1 Typical field mapping

Child control

Property

Typical binding

RequestNumberLbl

Text

ThisItem.'Request number'

TitleLbl

Text

ThisItem.'Request Name' / Title field used by your table

RequestTypeLbl

Text

ThisItem.'Request Type'

PriorityLbl

Text

ThisItem.Priority

StatusLbl

Text

ThisItem.Status

DueDateLbl

Text

ThisItem.'Due Date'

AssignedToLbl

Text

ThisItem.'Assigned To'

OwnerLbl

Text

Owner display formula shown below

Use the exact display name of your fields in the app. The important pattern is not the control naming; it is that each row field is bound to ThisItem and aligned to its matching header.

15. Add Multi-Select to the Nested Gallery

The Table control provides SelectedItems automatically. A Gallery does not provide the same built-in multi-select behavior, so the selection state is maintained explicitly in colSelectedItems.

SelectCheckbox.Default

!IsEmpty(
    Filter(
        colSelectedItems,
        'Request number' = ThisItem.'Request number'
    )
)

SelectCheckbox.OnCheck

Collect(
    colSelectedItems,
    ThisItem
)

SelectCheckbox.OnUncheck

Remove(
    colSelectedItems,
    LookUp(
        colSelectedItems,
        'Request number' = ThisItem.'Request number'
    )
)

Control

Property

Value / Formula

SelectCheckbox

Text

""

SelectCheckbox

X

GalleryHeaderContainer.X + CheckboxHeader.X


The checkbox simply reflects the collection. OnCheck adds the current record. OnUncheck removes it. The collection is therefore the source of truth for selection.

16. Bulk Actions

The selected records can now be used for a bulk business action. In the example application there are two buttons: Mark In Progress and Mark Completed.

16.1 Mark In Progress

MarkInProgressButton.DisplayMode

If(
    CountRows(colSelectedItems) = 0,
    DisplayMode.Disabled,
    DisplayMode.Edit
)

MarkInProgressButton.OnSelect

ForAll(
    colSelectedItems,
    Patch(
        Requests,
        ThisRecord,
        {
            cr3d5_status:
            'Status (Requests)'.'In Progress'
        }
    )
);
Refresh(Requests);
Clear(colSelectedItems);
Reset(GroupsGallery);
Clear(colExpandedGroups);
Notify(
    "Status updated",
    NotificationType.Success,
    2000
)

The formula loops through the selected Requests and updates the status in Dataverse. It then refreshes the Requests source, clears selection, resets the parent Gallery, closes the expanded groups, and shows a success notification.

16.2 Mark Completed

MarkCompletedButton.DisplayMode

If(
    CountRows(colSelectedItems) = 0,
    DisplayMode.Disabled,
    DisplayMode.Edit
)

MarkCompletedButton.OnSelect

ForAll(
    colSelectedItems,
    Patch(
        Requests,
        ThisRecord,
        {
            cr3d5_status:
            'Status (Requests)'.'Closed'
        }
    )
);
Refresh(Requests);
Clear(colSelectedItems);
Reset(GroupsGallery);
Clear(colExpandedGroups);
Notify(
    "Status updated",
    NotificationType.Success,
    2000
)

The second button follows the same pattern, but updates the selected records to the Closed choice value.

17. Refresh and Empty State

17.1 Refresh

RefreshIcon.OnSelect

Refresh(Requests);
Refresh(Departments);
Refresh('Request Types');
Notify(
    "Data refreshed successfully",
    NotificationType.Success,
    2000
)

The refresh action gives the user a simple way to get the latest data if records have been changed elsewhere.

17.2 Empty state

NoRecordsLbl.Visible

IsEmpty(GroupsGallery.AllItems)

NoRecordsLbl can display a simple message such as "No requests found" when a search returns no matching records.

18. Dataverse Notes and Common Gotchas

Most of the pattern is generic Power Fx, but Dataverse introduces a few details worth keeping in mind.

18.1 Choice columns

Switch(
    Text(ThisItem.Priority),
    "High", RGBA(196, 50, 50, 1),
    "Medium", RGBA(204, 133, 0, 1),
    "Low", RGBA(0, 120, 212, 1),
    RGBA(32, 32, 32, 1)
)

Converting a Choice value to text can make comparisons straightforward when the UI needs to apply a color or branch based on the displayed choice value.

18.2 Naming collisions with system columns

Dataverse tables already have system columns such as Status, Status Reason, Owner, Created On, and Modified On. If you create a custom column using a system name, Power Apps may disambiguate it in formulas. This is why a Patch formula can contain a reference such as 'Status (Requests)'.

Patch(
    Requests,
    ThisRecord,
    {
        cr3d5_status:
        'Status (Requests)'.'In Progress'
    }
)

For new designs, it is cleaner to avoid naming custom columns after Dataverse system fields.

18.3 Owner is a polymorphic lookup

If(
    IsType(ThisItem.Owner, Users),
    AsType(ThisItem.Owner, Users).'Full Name',
    IsType(ThisItem.Owner, Teams),
    AsType(ThisItem.Owner, Teams).'Team Name',
    "Unassigned"
)

Owner can point to a User or a Team. Users and Teams should therefore be available as data sources when this formula is used.

18.4 Search and delegation

The example search includes Text(Request) to search the Request field. If the field is a large/memo-style column, converting it with Text() can force client-side evaluation and therefore limit scalability.

SearchRecordsText.Value in Text(Request)

For a small or moderate demo this is acceptable. For large production datasets, use searchable text fields or another server-side search approach rather than depending on a non-delegable memo-field search.

19. Putting It All Together

Dataverse
  |
  v
Filter + Search
  |
  v
Sort Requests by Due Date
  |
  v
Add GroupKey
  |
  v
GroupBy() -> GroupedRecords
  |
  v
Add SortRank
  |
  v
Sort Groups by Display Order
  |
  v
GroupsGallery
  |
  +--> GroupHeaderLbl / GroupChevronIcon
  |
  +--> RequestsGallery
        |
        +--> SelectCheckbox
        +--> Request fields
  |
  +--> colExpandedGroups
  +--> colSelectedItems
  |
  v
Bulk Patch -> Refresh Dataverse

The final pattern is straightforward once the data transformation is understood. The Requests table starts flat. Power Fx filters and sorts it, adds a grouping key, groups the records, and orders the groups. GroupsGallery renders those group rows, while RequestsGallery renders the GroupedRecords for the current group.

20. Where This Pattern Can Be Reused

The Help Desk example is illustrative; the same pattern can be reused anywhere a flat dataset needs to become a grouped interface

Business scenario

Possible GroupKey

Help Desk

Department

Customer Management

Customer

Project Management

Project

Regional Operations

Region

Case Management

Category or Queue

Resource Management

Manager or Business Unit

The reusable idea is not the Help Desk screen. It is the data and UI pattern:

Flat data -> GroupBy() -> Parent Gallery -> Nested Gallery -> Expand/Collapse -> Dynamic Height -> Selection / Actions

Conclusion

The main idea behind this implementation is simple: take a flat Dataverse dataset, reshape it with Power Fx, and then let the Canvas App render that grouped shape through two Galleries.

The outer Gallery represents the groups. The nested Gallery represents the records inside each group. colExpandedGroups controls the open and closed state, and the child Gallery height is calculated from the number of records so the parent Gallery can reflow correctly.

Once that foundation is in place, search, selection, and bulk actions can be added without changing the basic architecture.

That is what makes this pattern useful beyond the Help Desk example. The grouping field can change, the child fields can change, and the business action can change, while the core nested-gallery design remains the same.


Followers

Power Dynamix YouTube Videos