Skip to main content

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.

IMPORTANT — Repeater Keys Must Be Stable and Unique

The key property on a repeater identifies each row for list diffing and tap-action routing. Two rules that must never be broken:

  1. 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.

  2. 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 keyItem."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;
}
tip

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

PropertyTypeDescription
dataKeystringPath to the JSON array in the API response
keystringUnique, stable identifier field within each item — used for list diffing and tap routing
templatestringVisual layout: "CARD" or "GRID"
fieldsarrayField definitions with key (data property) and label (display text)
onTapActionstringAction ID to execute when a row is tapped
trailingActionobjectSwipe-to-reveal action on the trailing edge of each item
emptyMessagestringText 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" }
]
}
TemplateBest for
CARDRich content with 3-4 fields per item — document lists, detail summaries
GRIDCompact 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