Using a Repeater
A repeater component displays a scrollable list of items on a mobile page. In this tutorial you will build a list view that loads data from Business Central, renders it in card and grid templates, and responds to user interaction.
Prerequisites
Complete the Creating a Mobile Service and Page with Data Binding tutorials first.
The key property on a repeater identifies each row for list diffing and tap-action routing. Two rules that must never be broken:
-
Every row must have a unique key value. If two rows in the same repeater share the same key, the client cannot distinguish between them. This causes the wrong row to be selected on tap and can crash the app.
-
The key value for a given logical row must not change between API calls. The client diffs the list by key to animate additions and removals. If a row's key changes on a refresh (e.g., because you are using a sequential index instead of a stable business identifier), the client treats the old row as deleted and the new one as added, breaking selection state and triggering unnecessary animations.
Use a natural business key — Item."No.", WhseReceiptLine."Line No.", BinContent."Entry No." — not an array index or a generated GUID that changes per call.
// ✅ Correct — stable, unique business key
ItemObj.Add('No', Item."No.");
// ❌ Wrong — index changes meaning if list is filtered or sorted
ItemObj.Add('index', Counter);
// ❌ Wrong — CreateGuid() generates a new value on every API call.
// Compose sees every row as a brand new item on every refresh,
// destroying and rebuilding the entire list: visible flicker,
// lost scroll position, and wasted rendering work every time.
ItemObj.Add('id', Format(CreateGuid()));
If you have no natural unique key, concatenate multiple fields: Item."No." + '|' + Item."Variant Code".
Step 1: Return Array Data from AL
Your service codeunit must return a JSON array containing one object per record. The key you use for the array must match the repeater's dataKey property.
namespace MyCompany.MeshDemo;
using Aptean.Mesh.SDUI;
codeunit 50100 MyItemService implements IBCRPC220FDW
{
procedure RPC(var State: Codeunit MobileRPCState220FDW) ResponseJson: JsonObject
begin
case State.Method() of
'GetItems':
exit(GetItems(State));
'SelectItem':
exit(SelectItem(State));
end;
ResponseJson := State.Serialize();
end;
local procedure GetItems(var State: Codeunit MobileRPCState220FDW) Result: JsonObject
var
Item: Record Item;
Items: JsonArray;
ItemObj: JsonObject;
begin
Item.SetRange(Blocked, false);
if Item.FindSet() then
repeat
Clear(ItemObj);
ItemObj.Add('No', Item."No."); // stable unique key
ItemObj.Add('Description', Item.Description);
ItemObj.Add('Category', Item."Item Category Code");
ItemObj.Add('UOM', Item."Base Unit of Measure");
Items.Add(ItemObj);
until Item.Next() = 0;
Result.Add('items', Items);
end;
local procedure SelectItem(var State: Codeunit MobileRPCState220FDW) Result: JsonObject
var
ItemNo: Code[20];
NavigateData: JsonObject;
begin
Evaluate(ItemNo, State.GetInput('No', true));
NavigateData.Add('ItemNo', ItemNo);
State.Command().Navigate('ITEM-DETAIL', NavigateData);
Result := State.Serialize();
end;
[EventSubscriber(ObjectType::Codeunit, Codeunit::MobileRPCRegistry220FDW,
OnResolveService, '', false, false)]
local procedure HandleMyService(
CodeunitId: Integer;
var Service: Interface IBCRPC220FDW;
var IsServiceResolved: Boolean)
begin
if IsServiceResolved then
exit;
if CodeunitId = Codeunit::MyItemService then begin
Service := this;
IsServiceResolved := true;
end;
end;
}
See HomeService220FDW for how the base app builds document list views. It uses a dedicated helper codeunit to collect and filter documents across multiple source types.
Step 2: Define the Repeater in Page JSON
Create resources/MobilePages/ItemList.json. The repeater's dataKey points at the items array returned by your service, and key must reference the stable unique field from each object:
{
"header": {
"title": "Items",
"refresh": true
},
"body": [
{
"type": "repeater",
"dataKey": "items",
"key": "No",
"template": "CARD",
"onTapAction": "onItemTap",
"emptyMessage": "No items found",
"fields": [
{ "key": "No", "label": "Item No." },
{ "key": "Description", "label": "Description" },
{ "key": "Category", "label": "Category" },
{ "key": "UOM", "label": "Unit of Measure" }
]
}
],
"contract": {
"initialAction": "onLoadItems",
"actions": [
{ "id": "onLoadItems", "type": "api", "method": "GetItems", "params": [] },
{ "id": "onItemTap", "type": "api", "method": "SelectItem", "params": ["No"] }
]
}
}
Repeater Properties
| Property | Type | Description |
|---|---|---|
dataKey | string | Path to the JSON array in the API response |
key | string | Unique, stable identifier field within each item — used for list diffing and tap routing |
template | string | Visual layout: "CARD" or "GRID" |
fields | array | Field definitions with key (data property) and label (display text) |
onTapAction | string | Action ID to execute when a row is tapped |
trailingAction | object | Swipe-to-reveal action on the trailing edge of each item |
emptyMessage | string | Text shown when the data array is empty |
Step 3: Add a Swipe Action
The trailingAction property defines an action that appears when the user swipes a list item to the left:
{
"type": "repeater",
"dataKey": "items",
"key": "No",
"template": "CARD",
"onTapAction": "onItemTap",
"trailingAction": {
"action": "onDeleteItem",
"icon": "delete"
},
"fields": [
{ "key": "No", "label": "Item No." },
{ "key": "Description", "label": "Description" }
]
}
The base app uses this for label printing on receipt lines:
"trailingAction": { "action": "onPrintLot", "icon": "print", "visibleKey": "showPrintAction" }
The optional visibleKey property controls visibility per-row based on a boolean field in the data.
Step 4: Use the GRID Template
Switch from CARD to GRID for a more compact, tabular layout:
{
"type": "repeater",
"dataKey": "items",
"key": "No",
"template": "GRID",
"onTapAction": "onItemTap",
"emptyMessage": "No items found",
"fields": [
{ "key": "No", "label": "No." },
{ "key": "Description", "label": "Description" }
]
}
| Template | Best for |
|---|---|
CARD | Rich content with 3-4 fields per item — document lists, detail summaries |
GRID | Compact tabular data with 1-2 fields — bin contents, line items |
Step 5: Handle the Tap Action
When the user taps a row, the onTapAction fires and sends the tapped row's data as parameters:
local procedure SelectItem(var State: Codeunit MobileRPCState220FDW) Result: JsonObject
var
ItemNo: Code[20];
NavigateData: JsonObject;
begin
Evaluate(ItemNo, State.GetInput('No', true));
NavigateData.Add('ItemNo', ItemNo);
State.Command().Navigate('ITEM-DETAIL', NavigateData);
Result := State.Serialize();
end;
State.GetInput('No', true) reads the No parameter sent from the tapped row. true means the value is required.
Complete Example
{
"header": { "title": "Items", "refresh": true },
"body": [
{
"type": "input",
"inputId": "query",
"placeholder": "Search items...",
"action": "onLoadItems"
},
{
"type": "repeater",
"dataKey": "items",
"key": "No",
"template": "CARD",
"onTapAction": "onItemTap",
"trailingAction": { "action": "onDeleteItem", "icon": "delete" },
"emptyMessage": "No items found",
"fields": [
{ "key": "No", "label": "Item No." },
{ "key": "Description", "label": "Description" },
{ "key": "Category", "label": "Category" },
{ "key": "UOM", "label": "Unit of Measure" }
]
}
],
"contract": {
"initialAction": "onLoadItems",
"actions": [
{ "id": "onLoadItems", "type": "api", "method": "GetItems", "params": ["query"] },
{ "id": "onItemTap", "type": "api", "method": "SelectItem", "params": ["No"] },
{ "id": "onDeleteItem", "type": "api", "method": "DeleteItem", "params": ["No"] }
]
}
}
What's Next
- Navigation — Navigate between pages and pass parameters
- Internationalization — Translate your repeater field labels