keyed_form

Dynamic lists

Edit and reorder rows by stable identity so values and errors stay with the same record.

Rows are addressed by stable clientId, never by their changing index. The interactive example lets you drag rows, edit them, and add or remove entries; its panel shows each id moving with its row.

// RUNNING — drag ⠿, then type
Press space on the handle to lift, arrow keys to move, space to drop.
Press space on the handle to lift, arrow keys to move, space to drop.
Press space on the handle to lift, arrow keys to move, space to drop.
// ROWS — index · clientId · label
0  17258440…  Passport
1  c6a7f3c6…  Charger
2  ca448623…  Sunscreen

The example source#

These are the example app's own files—the interactive demo uses 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)),
      ],
    );
  }
}

Edit by identity#

form.field(PackingFields.items).list() returns the list editor. It supports operations such as append(item), insertAfter(clientId, item), move(from, to), and removeById(clientId). Prefer by-id edits when the target row is already known; indexes are positions and change after insertion, removal, or movement. For the complete operation set, see List operations.

Use each row's clientId to build its field reference and Flutter widget key. The row id remains stable while its index changes, keeping values, errors, and dirty tracking associated with the same record. See Flutter lists and virtualization for lazily built rows.

Validate the collection#

List-level rules belong on the list validator (the example requires at least one row); row fields retain their own validation. Collection semantics and limits are covered in Objects and collections.