lazy_text_field 1.0.1 copy "lazy_text_field: ^1.0.1" to clipboard
lazy_text_field: ^1.0.1 copied to clipboard

Pure-Flutter lazy text fields for table and grid cells with exact constrained-width height measurement.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:lazy_text_field/lazy_text_field.dart';

void main() {
  runApp(const LazyTextFieldExampleApp());
}

class LazyTextFieldExampleApp extends StatelessWidget {
  const LazyTextFieldExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff0f766e)),
        useMaterial3: true,
      ),
      home: const LazyTextFieldExampleScreen(),
    );
  }
}

class LazyTextFieldExampleScreen extends StatefulWidget {
  const LazyTextFieldExampleScreen({super.key});

  @override
  State<LazyTextFieldExampleScreen> createState() =>
      _LazyTextFieldExampleScreenState();
}

class _LazyTextFieldExampleScreenState
    extends State<LazyTextFieldExampleScreen> {
  static const _rowCount = 18;
  static const _rowNumberWidth = 56.0;
  static const _taskWidth = 280.0;
  static const _ownerWidth = 180.0;
  static const _statusWidth = 168.0;
  static const _notesWidth = 360.0;
  static const _rowGap = 10.0;
  static const _fieldHeight = 58.0;
  static const _rowPadding = 8.0;
  static const _scrollbarGutter = 12.0;

  final _rows = List<_DemoRow>.generate(_rowCount, (index) {
    final owners = ['Ari', 'Mina', 'Noah', 'Kai', 'Lena', 'Sam'];
    final statuses = ['Draft', 'Ready', 'Blocked', 'Review', 'Shipped'];

    return _DemoRow(
      task: index % 6 == 0
          ? 'Long wrapped title for row $index'
          : 'Update cell $index',
      owner: owners[index % owners.length],
      status: statuses[index % statuses.length],
      notes: switch (index) {
        0 => 'Line one\nLine two\nLine three\nLine four',
        7 => 'Plan\nBuild\nVerify\nShip',
        14 => 'Static view\nEdit view\nSame text origin\nStable height',
        _ => switch (index % 5) {
          1 => 'Click this lazy field to edit.',
          2 => '',
          3 => 'Narrow columns wrap without row-height surprises.',
          _ => 'Decoration stays outside the real TextField.',
        },
      },
    );
  });

  String? _activeCellId;
  TextEditingController? _controller;
  FocusNode? _focusNode;
  final _horizontalScrollController = ScrollController();
  LazyInputDecorationVisibility _visibility =
      LazyInputDecorationVisibility.always;
  _HeightMode _heightMode = _HeightMode.onEditFull;
  LazyTextFieldStartEditSelection _startEditSelection =
      LazyTextFieldStartEditSelection.tapPosition;
  bool _showIcons = true;
  double _fontSize = 14;
  bool _bold = false;
  bool _italic = false;

  @override
  void dispose() {
    _controller?.dispose();
    _focusNode?.dispose();
    _horizontalScrollController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final activeLabel = _activeCellId == null
        ? 'No active controller'
        : 'Active controller: $_activeCellId';

    return Scaffold(
      appBar: AppBar(
        title: const Text('LazyTextField demo'),
        actions: [
          IconButton(
            tooltip: 'Clear active editor',
            icon: const Icon(Icons.close),
            onPressed: _stopEditing,
          ),
        ],
      ),
      body: Column(
        children: [
          _Toolbar(
            visibility: _visibility,
            heightMode: _heightMode,
            startEditSelection: _startEditSelection,
            showIcons: _showIcons,
            fontSize: _fontSize,
            bold: _bold,
            italic: _italic,
            activeLabel: activeLabel,
            onVisibilityChanged: (value) {
              setState(() {
                _visibility = value;
              });
            },
            onHeightModeChanged: (value) {
              setState(() {
                _heightMode = value;
              });
            },
            onStartEditSelectionChanged: (value) {
              setState(() {
                _startEditSelection = value;
              });
            },
            onShowIconsChanged: (value) {
              setState(() {
                _showIcons = value;
              });
            },
            onFontSizeChanged: (value) {
              setState(() {
                _fontSize = value;
              });
            },
            onBoldChanged: (value) {
              setState(() {
                _bold = value;
              });
            },
            onItalicChanged: (value) {
              setState(() {
                _italic = value;
              });
            },
          ),
          const Divider(height: 1),
          Expanded(
            child: Scrollbar(
              controller: _horizontalScrollController,
              thumbVisibility: true,
              child: SingleChildScrollView(
                controller: _horizontalScrollController,
                padding: const EdgeInsets.all(16),
                scrollDirection: Axis.horizontal,
                child: SizedBox(
                  width:
                      _rowNumberWidth +
                      _taskWidth +
                      _ownerWidth +
                      _statusWidth +
                      _notesWidth +
                      _rowGap * 4 +
                      _rowPadding * 2 +
                      16,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      const _HeaderRow(),
                      const SizedBox(height: 8),
                      Expanded(
                        child: ListView.separated(
                          itemCount: _rows.length,
                          separatorBuilder: (context, index) =>
                              const SizedBox(height: 8),
                          itemBuilder: _buildRow,
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildRow(BuildContext context, int rowIndex) {
    final colorScheme = Theme.of(context).colorScheme;
    final row = _rows[rowIndex];

    return Padding(
      padding: const EdgeInsets.only(right: 16.0),
      child: DecoratedBox(
        decoration: BoxDecoration(
          color: colorScheme.surface,
          border: Border.all(color: colorScheme.outlineVariant),
          borderRadius: BorderRadius.circular(6),
        ),
        child: Padding(
          padding: const EdgeInsets.all(_rowPadding),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              SizedBox(
                width: _rowNumberWidth,
                height: _fieldHeight,
                child: Align(
                  alignment: Alignment.centerLeft,
                  child: Text(
                    '#${rowIndex + 1}',
                    style: Theme.of(context).textTheme.labelMedium,
                  ),
                ),
              ),
              const SizedBox(width: _rowGap),
              _FieldCell(
                width: _taskWidth,
                value: row.task,
                child: _lazyField(rowIndex, _DemoColumn.task, row.task),
              ),
              const SizedBox(width: _rowGap),
              _FieldCell(
                width: _ownerWidth,
                value: row.owner,
                child: _lazyField(rowIndex, _DemoColumn.owner, row.owner),
              ),
              const SizedBox(width: _rowGap),
              _FieldCell(
                width: _statusWidth,
                value: row.status,
                child: _lazyField(rowIndex, _DemoColumn.status, row.status),
              ),
              const SizedBox(width: _rowGap),
              _FieldCell(
                width: _notesWidth,
                value: row.notes,
                child: _lazyField(rowIndex, _DemoColumn.notes, row.notes),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _lazyField(int rowIndex, _DemoColumn column, String value) {
    final cellId = 'r${rowIndex + 1}-${column.name}';
    final isEditing = _activeCellId == cellId;
    final isFixedHeight =
        _heightMode == _HeightMode.fixed ||
        (_heightMode == _HeightMode.onEditFull && !isEditing);
    final field = LazyTextField(
      cellId: cellId,
      text: value,
      isEditing: isEditing,
      controller: isEditing ? _controller : null,
      focusNode: isEditing ? _focusNode : null,
      style: TextStyle(
        fontSize: _fontSize,
        height: 1.25,
        fontWeight: _bold ? FontWeight.w700 : FontWeight.w400,
        fontStyle: _italic ? FontStyle.italic : FontStyle.normal,
      ),
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
      decoration: _decoration(context, column),
      decorationVisibility: _visibility,
      maxHeight: isFixedHeight ? _fieldHeight : null,
      // Keep text width identical in static and edit. Reserving scrollbar
      // gutter only while maxHeight is set (onEditFull static) widens the
      // viewport on edit and changes soft-wrap. Only "Fixed" mode uses the
      // internal scrollbar gutter in both modes.
      reservedTrailingWidth: 0,
      scrollbarGutter: _heightMode == _HeightMode.fixed ? _scrollbarGutter : 0,
      onStartEditing: () => _startEditing(cellId, value),
      onStartEditingWithDetails: (details) =>
          _startEditing(cellId, value, textOffset: details.textOffset),
      onChanged: (nextValue) {
        setState(() {
          _setCellValue(rowIndex, column, nextValue);
        });
      },
      onSubmitted: (_) => _stopEditing(),
      onTapOutside: (_) => _stopEditing(),
    );

    if (!isFixedHeight) {
      return field;
    }

    return SizedBox(height: _fieldHeight, child: field);
  }

  LazyInputDecoration _decoration(BuildContext context, _DemoColumn column) {
    final colorScheme = Theme.of(context).colorScheme;
    final borderRadius = BorderRadius.circular(4);
    final normalBorder = OutlineInputBorder(
      borderRadius: borderRadius,
      borderSide: BorderSide(color: colorScheme.outline, width: 1.2),
    );
    final focusedBorder = OutlineInputBorder(
      borderRadius: borderRadius,
      borderSide: BorderSide(color: colorScheme.primary, width: 2),
    );

    return LazyInputDecoration(
      filled: true,
      fillColor: colorScheme.surfaceContainerLowest,
      hoverColor: colorScheme.secondaryContainer.withValues(alpha: 0.35),
      focusColor: colorScheme.primaryContainer.withValues(alpha: 0.35),
      border: normalBorder,
      enabledBorder: normalBorder,
      focusedBorder: focusedBorder,
      hintText: 'Click to edit',
      hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
      prefixIcon: _showIcons
          ? Icon(column.icon, size: (_fontSize + 2).clamp(14, 22))
          : null,
      prefixIconConstraints: const BoxConstraints(minWidth: 30),
    );
  }

  void _startEditing(String cellId, String value, {int? textOffset}) {
    if (_activeCellId == cellId) return;
    _controller?.dispose();
    _focusNode?.dispose();
    setState(() {
      _activeCellId = cellId;
      _controller = TextEditingController(text: value);
      _focusNode = FocusNode();
      _controller!.selection = _selectionForStartEdit(
        value,
        textOffset: textOffset,
      );
    });
  }

  TextSelection _selectionForStartEdit(String value, {int? textOffset}) {
    return switch (_startEditSelection) {
      LazyTextFieldStartEditSelection.beginning =>
        const TextSelection.collapsed(offset: 0),
      LazyTextFieldStartEditSelection.end => TextSelection.collapsed(
        offset: value.length,
      ),
      LazyTextFieldStartEditSelection.tapPosition => TextSelection.collapsed(
        offset: (textOffset ?? value.length).clamp(0, value.length),
      ),
      LazyTextFieldStartEditSelection.fullSelection => TextSelection(
        baseOffset: 0,
        extentOffset: value.length,
      ),
    };
  }

  void _stopEditing() {
    if (_activeCellId == null) return;
    setState(() {
      _activeCellId = null;
      _controller?.dispose();
      _focusNode?.dispose();
      _controller = null;
      _focusNode = null;
    });
  }

  void _setCellValue(int rowIndex, _DemoColumn column, String value) {
    final row = _rows[rowIndex];
    switch (column) {
      case _DemoColumn.task:
        row.task = value;
      case _DemoColumn.owner:
        row.owner = value;
      case _DemoColumn.status:
        row.status = value;
      case _DemoColumn.notes:
        row.notes = value;
    }
  }
}

class _Toolbar extends StatelessWidget {
  const _Toolbar({
    required this.visibility,
    required this.heightMode,
    required this.startEditSelection,
    required this.showIcons,
    required this.fontSize,
    required this.bold,
    required this.italic,
    required this.activeLabel,
    required this.onVisibilityChanged,
    required this.onHeightModeChanged,
    required this.onStartEditSelectionChanged,
    required this.onShowIconsChanged,
    required this.onFontSizeChanged,
    required this.onBoldChanged,
    required this.onItalicChanged,
  });

  static const _fontSizes = <double>[10, 11, 12, 13, 14, 15, 16];

  final LazyInputDecorationVisibility visibility;
  final _HeightMode heightMode;
  final LazyTextFieldStartEditSelection startEditSelection;
  final bool showIcons;
  final double fontSize;
  final bool bold;
  final bool italic;
  final String activeLabel;
  final ValueChanged<LazyInputDecorationVisibility> onVisibilityChanged;
  final ValueChanged<_HeightMode> onHeightModeChanged;
  final ValueChanged<LazyTextFieldStartEditSelection>
  onStartEditSelectionChanged;
  final ValueChanged<bool> onShowIconsChanged;
  final ValueChanged<double> onFontSizeChanged;
  final ValueChanged<bool> onBoldChanged;
  final ValueChanged<bool> onItalicChanged;

  @override
  Widget build(BuildContext context) {
    return Material(
      color: Theme.of(context).colorScheme.surface,
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Wrap(
          spacing: 12,
          runSpacing: 12,
          crossAxisAlignment: WrapCrossAlignment.center,
          children: [
            SizedBox(
              width: 320,
              child: SegmentedButton<LazyInputDecorationVisibility>(
                segments: const [
                  ButtonSegment(
                    value: LazyInputDecorationVisibility.editing,
                    icon: Icon(Icons.edit),
                    label: Text('Edit'),
                  ),
                  ButtonSegment(
                    value: LazyInputDecorationVisibility.always,
                    icon: Icon(Icons.visibility),
                    label: Text('Always'),
                  ),
                  ButtonSegment(
                    value: LazyInputDecorationVisibility.never,
                    icon: Icon(Icons.visibility_off),
                    label: Text('None'),
                  ),
                ],
                selected: {visibility},
                onSelectionChanged: (selection) {
                  onVisibilityChanged(selection.single);
                },
              ),
            ),
            SizedBox(
              width: 392,
              child: SegmentedButton<_HeightMode>(
                segments: const [
                  ButtonSegment(
                    value: _HeightMode.fixed,
                    icon: Icon(Icons.height),
                    label: Text('Fixed'),
                  ),
                  ButtonSegment(
                    value: _HeightMode.full,
                    icon: Icon(Icons.open_in_full),
                    label: Text('Full'),
                  ),
                  ButtonSegment(
                    value: _HeightMode.onEditFull,
                    icon: Icon(Icons.edit_note),
                    label: Text('Edit full'),
                  ),
                ],
                selected: {heightMode},
                onSelectionChanged: (selection) {
                  onHeightModeChanged(selection.single);
                },
              ),
            ),
            SizedBox(
              width: 520,
              child: SegmentedButton<LazyTextFieldStartEditSelection>(
                segments: const [
                  ButtonSegment(
                    value: LazyTextFieldStartEditSelection.beginning,
                    icon: Icon(Icons.first_page),
                    label: Text('Begin'),
                  ),
                  ButtonSegment(
                    value: LazyTextFieldStartEditSelection.end,
                    icon: Icon(Icons.last_page),
                    label: Text('End'),
                  ),
                  ButtonSegment(
                    value: LazyTextFieldStartEditSelection.tapPosition,
                    icon: Icon(Icons.ads_click),
                    label: Text('Click'),
                  ),
                  ButtonSegment(
                    value: LazyTextFieldStartEditSelection.fullSelection,
                    icon: Icon(Icons.select_all),
                    label: Text('Select'),
                  ),
                ],
                selected: {startEditSelection},
                onSelectionChanged: (selection) {
                  onStartEditSelectionChanged(selection.single);
                },
              ),
            ),
            SizedBox(
              width: 112,
              child: FilterChip(
                avatar: const Icon(Icons.dashboard_customize, size: 18),
                label: const Text('Icons'),
                selected: showIcons,
                onSelected: onShowIconsChanged,
              ),
            ),
            SizedBox(
              width: 148,
              child: DropdownMenu<double>(
                key: ValueKey(fontSize),
                initialSelection: fontSize,
                label: const Text('Font size'),
                leadingIcon: const Icon(Icons.format_size),
                inputDecorationTheme: const InputDecorationTheme(
                  isDense: true,
                  border: OutlineInputBorder(),
                  contentPadding: EdgeInsets.symmetric(horizontal: 8),
                ),
                dropdownMenuEntries: [
                  for (final size in _fontSizes)
                    DropdownMenuEntry<double>(
                      value: size,
                      label: '${size.toInt()}',
                    ),
                ],
                onSelected: (value) {
                  if (value != null) {
                    onFontSizeChanged(value);
                  }
                },
              ),
            ),
            PopupMenuButton<_TextStyleAction>(
              tooltip: 'Text styles',
              icon: Icon(
                Icons.text_fields,
                color: bold || italic
                    ? Theme.of(context).colorScheme.primary
                    : null,
              ),
              itemBuilder: (context) {
                return [
                  CheckedPopupMenuItem<_TextStyleAction>(
                    value: _TextStyleAction.bold,
                    checked: bold,
                    child: const Text('Bold'),
                  ),
                  CheckedPopupMenuItem<_TextStyleAction>(
                    value: _TextStyleAction.italic,
                    checked: italic,
                    child: const Text('Italic'),
                  ),
                ];
              },
              onSelected: (action) {
                switch (action) {
                  case _TextStyleAction.bold:
                    onBoldChanged(!bold);
                  case _TextStyleAction.italic:
                    onItalicChanged(!italic);
                }
              },
            ),
            SizedBox(
              width: 260,
              child: Align(
                alignment: Alignment.centerLeft,
                child: Text(
                  activeLabel,
                  maxLines: 1,
                  overflow: TextOverflow.ellipsis,
                  style: Theme.of(context).textTheme.bodySmall,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _HeaderRow extends StatelessWidget {
  const _HeaderRow();

  @override
  Widget build(BuildContext context) {
    final textStyle = Theme.of(context).textTheme.labelLarge;

    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: _LazyWidths.rowPadding),
      child: Row(
        children: [
          _HeaderCell(
            width: _LazyWidths.rowNumber,
            label: 'Row',
            style: textStyle,
          ),
          const SizedBox(width: _LazyWidths.gap),
          _HeaderCell(width: _LazyWidths.task, label: 'Task', style: textStyle),
          const SizedBox(width: _LazyWidths.gap),
          _HeaderCell(
            width: _LazyWidths.owner,
            label: 'Owner',
            style: textStyle,
          ),
          const SizedBox(width: _LazyWidths.gap),
          _HeaderCell(
            width: _LazyWidths.status,
            label: 'Status',
            style: textStyle,
          ),
          const SizedBox(width: _LazyWidths.gap),
          _HeaderCell(
            width: _LazyWidths.notes,
            label: 'Notes',
            style: textStyle,
          ),
        ],
      ),
    );
  }
}

class _HeaderCell extends StatelessWidget {
  const _HeaderCell({
    required this.width,
    required this.label,
    required this.style,
  });

  final double width;
  final String label;
  final TextStyle? style;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: width,
      child: Align(
        alignment: Alignment.centerLeft,
        child: Text(label, style: style),
      ),
    );
  }
}

class _FieldCell extends StatelessWidget {
  const _FieldCell({
    required this.width,
    required this.child,
    required this.value,
  });

  final double width;
  final Widget child;
  final String value;

  @override
  Widget build(BuildContext context) {
    final tooltipValue = value.isEmpty ? '(empty)' : value;

    return SizedBox(
      width: width,
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Expanded(child: child),
          const SizedBox(width: _LazyWidths.scrollbarGutter),
          const SizedBox(width: 4),
          SizedBox(
            width: 28,
            height: _LazyWidths.fieldHeight,
            child: Tooltip(
              message: 'LazyTextField value:\n$tooltipValue',
              waitDuration: const Duration(milliseconds: 250),
              child: Icon(
                Icons.info_outline,
                size: 18,
                color: Theme.of(context).colorScheme.primary,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class _DemoRow {
  _DemoRow({
    required this.task,
    required this.owner,
    required this.status,
    required this.notes,
  });

  String task;
  String owner;
  String status;
  String notes;
}

enum _DemoColumn {
  task(Icons.subject),
  owner(Icons.person_outline),
  status(Icons.flag_outlined),
  notes(Icons.notes);

  const _DemoColumn(this.icon);

  final IconData icon;
}

enum _HeightMode { fixed, full, onEditFull }

enum _TextStyleAction { bold, italic }

abstract final class _LazyWidths {
  static const rowNumber = _LazyTextFieldExampleScreenState._rowNumberWidth;
  static const task = _LazyTextFieldExampleScreenState._taskWidth;
  static const owner = _LazyTextFieldExampleScreenState._ownerWidth;
  static const status = _LazyTextFieldExampleScreenState._statusWidth;
  static const notes = _LazyTextFieldExampleScreenState._notesWidth;
  static const gap = _LazyTextFieldExampleScreenState._rowGap;
  static const fieldHeight = _LazyTextFieldExampleScreenState._fieldHeight;
  static const rowPadding = _LazyTextFieldExampleScreenState._rowPadding;
  static const scrollbarGutter =
      _LazyTextFieldExampleScreenState._scrollbarGutter;
}
1
likes
0
points
135
downloads

Publisher

unverified uploader

Weekly Downloads

Pure-Flutter lazy text fields for table and grid cells with exact constrained-width height measurement.

Repository (GitHub)
View/report issues

Topics

#flutter #text-field #datatable #grid #performance

License

unknown (license)

Dependencies

flutter

More

Packages that depend on lazy_text_field