Drag a row by its handle, type in it, add and remove rows. The panel below the list shows each row's
clientId following it to a new index.
0 a7159006… Passport 1 51d0d91b… Charger 2 d773dfd7… Sunscreen
The code#
These are the example app's own files — the demo above runs the same schema and controller.
@keyedSchema
library;
import 'package:keyed_form_schema/keyed_form_schema.dart';
part 'packing_schema.kfg.dart';
final _packingSchema = ks.object({
'items': ks
.list(
ks.object(className: 'PackingItemSchema', {
'label': ks.string(error: .text('Give it a name')).min(1),
'packed': ks.boolean().defaultTo(false),
}),
)
.min(1, error: .text('Add at least one item')),
});
import 'package:dnd_kit_flutter/dnd_kit_flutter.dart';
import 'package:flutter/material.dart';
import 'package:keyed_form_flutter/keyed_form_flutter.dart';
import 'packing_schema.dart';
part 'packing_row.dart';
class PackingList extends StatefulWidget {
const PackingList({super.key});
@override
State<PackingList> createState() => _PackingListState();
}
class _PackingListState extends State<PackingList> {
final dnd = DndController();
@override
void dispose() {
dnd.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return KeyedFieldList<PackingSchema, PackingItemSchema>(
field: PackingFields.items,
builder: (context, items, list) => Stack(
children: [
SortableScope(
controller: dnd,
strategy: SortableStrategies.dropOnOver,
offsetResolver: SortableOffsets.verticalList,
itemIds: [for (final item in items) DndId(item.clientId)],
onMove: (d) => list.move(d.fromIndex, d.toIndex),
child: Column(
children: [
for (final item in items)
SortableItem(
key: ValueKey(item.clientId),
id: DndId(item.clientId),
activationConstraint: const DndSensorActivationConstraint(
distance: 4,
),
builder: (context, drag, child) => AnimatedContainer(
duration: const Duration(milliseconds: 150),
transform: Matrix4.translationValues(0, drag.offset.y, 0),
child: Opacity(
opacity: drag.isDragging ? 0 : 1,
child: child,
),
),
child: _PackingRow(
fields: PackingFields.item(itemClientId: item.clientId),
onRemove: () => list.removeById(item.clientId),
),
),
TextButton.icon(
onPressed: () => list.append(PackingItemSchema.create()),
icon: const Icon(Icons.add),
label: const Text('Add item'),
),
],
),
),
DndDragOverlay(
controller: dnd,
builder: (context, drag) => Material(
elevation: 4,
child: ListTile(
leading: const Icon(Icons.drag_indicator),
title: Text(list.byId(drag.activeId.value)?.label ?? ''),
),
),
),
],
),
);
}
}
part of 'packing_list.dart';
class _PackingRow extends StatelessWidget {
const _PackingRow({required this.fields, required this.onRemove});
final ItemFieldRefs fields;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
return Row(
children: [
const DndDragHandle(child: Icon(Icons.drag_indicator)),
KeyedFormField<PackingSchema, bool>(
field: fields.packed,
anchor: false,
builder: (context, f) => Checkbox(
value: f.value ?? false,
onChanged: (v) => f.onChanged(v ?? false),
),
),
Expanded(
child: KeyedFormField.text<PackingSchema>(
field: fields.label,
builder: (context, f, controller) => TextField(
controller: controller,
onTapOutside: (_) => f.onBlur(),
decoration: InputDecoration(
labelText: 'Item',
errorText: f.errorText,
),
),
),
),
IconButton(onPressed: onRemove, icon: const Icon(Icons.close)),
],
);
}
}
Editing the list#
form.field(PackingFields.items).list() returns the row editor. Every edit takes an id or an index and keeps ids stable:
| Call | Does |
|---|---|
append(item) | adds a row at the end |
insertAfter(clientId, item) | adds a row below another |
move(from, to) | reorders; to is the index after removal |
removeById(clientId) | drops a row and its errors |
Stable identity beats array indexes#
Use each row's clientId to build its field reference and widget key. Its index changes after insertion, removal, or a move; its identity does not. This keeps values, errors, focus, and dirty state attached to the same record.
Rules for the whole list#
A list field takes its own rules next to the per-row ones — here .min(1) keeps at least one row.