keyed_form

Dynamic lists

Rows are addressed by a stable id, never by index — so text, errors and focus follow a row wherever it moves.

Comparison copy: this is the previous documentation and may contain outdated guidance. Read the reorganized guides

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.

// 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  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:

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