ashlarui

Resizable

Panels with handles between them that drag to resize.

Resizable, as it looks in an app Resizable, as it looks in an app, dark
import 'package:flutter/widgets.dart';

import 'ui/resizable.dart';
import 'ui/theme.dart';

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

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    Widget pane(String label) => Center(
      child: Text(label, style: t.text.sm.copyWith(fontWeight: FontWeight.w600)),
    );
    return Container(
      width: 480,
      height: 240,
      clipBehavior: Clip.antiAlias,
      decoration: BoxDecoration(
        border: Border.all(color: t.colors.border),
        borderRadius: BorderRadius.circular(t.radiusLg),
      ),
      child: Resizable(
        withHandle: true,
        panels: [
          ResizablePanel(size: 1, minSize: .2, child: pane('Sidebar')),
          ResizablePanel(
            size: 2,
            minSize: .3,
            child: Resizable(
              axis: Axis.vertical,
              withHandle: true,
              panels: [
                ResizablePanel(size: 1, minSize: .2, child: pane('Editor')),
                ResizablePanel(size: 1, minSize: .2, child: pane('Terminal')),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

Add it

In your Flutter project:

$ ashlar add resizable

It writes theme.dart, palette.dart and resizable.dart into lib/ui, keeping any you already have.

Examples

Other ways to use it, each running live, with its code.

Upright, with a grip

Panels top to bottom, a grip on the handle.

Upright, with a grip, as it looks in an app Upright, with a grip, as it looks in an app, dark
// Upright, with a grip: Panels top to bottom, a grip on the handle.
import 'package:flutter/widgets.dart';

import 'ui/resizable.dart';
import 'ui/theme.dart';

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

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 360,
    height: 240,
    child: Builder(
      builder: (context) => DecoratedBox(
        decoration: BoxDecoration(
          border: Border.all(color: AshlarTheme.of(context).colors.border),
          borderRadius: BorderRadius.circular(12),
        ),
        child: const Resizable(
          axis: Axis.vertical,
          withHandle: true,
          panels: [
            ResizablePanel(child: Center(child: Text('Header'))),
            ResizablePanel(size: 3, child: Center(child: Text('Content'))),
          ],
        ),
      ),
    ),
  );
}

API

Read from the source above: each parameter with its type, its default, and what it is for.

ResizablePanelclass

One panel of a Resizable: what it shows, the share of the room it starts with, and the least it may shrink to.

Parameter Type Default What it is for
childrequired Widget What the panel shows.
size double 1 A share of the room, compared with the other panels' shares.
minSize double .1 A fraction of the room, from 0 to 1.

Resizableclass

Panels side by side, or one above the other, with handles between them that drag to share the room differently. Each handle also takes focus: the arrow keys move it.

Parameter Type Default What it is for
panelsrequired List<ResizablePanel> The panels, in order.
axis Axis Axis.horizontal Horizontal lays it out across; vertical, down.
withHandle bool false Draws a grip on each handle, to show it can be dragged.
onResize ValueChanged<List<double>>? Told each panel's share of the room, from 0 to 1, after every move: to keep it.

Source

What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add will not write over it.

lib/ui/resizable.dart 236 lines
// Ashlar UI: Resizable. Copied into your app by `ashlar add resizable`; it is yours to change.

import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';

import 'theme.dart';

/// One panel of a [Resizable]: what it shows, the share of the room it starts with, and
/// the least it may shrink to.
@immutable
class ResizablePanel {
  const ResizablePanel({required this.child, this.size = 1, this.minSize = .1});

  /// What the panel shows.
  final Widget child;

  /// A share of the room, compared with the other panels' shares.
  final double size;

  /// A fraction of the room, from 0 to 1.
  final double minSize;
}

/// Panels side by side, or one above the other, with handles between them that drag to
/// share the room differently. Each handle also takes focus: the arrow keys move it.
///
/// ```dart
/// Resizable(panels: [
///   ResizablePanel(size: 1, child: Sidebar()),
///   ResizablePanel(size: 3, child: Editor()),
/// ])
/// ```
class Resizable extends StatefulWidget {
  const Resizable({
    super.key,
    required this.panels,
    this.axis = Axis.horizontal,
    this.withHandle = false,
    this.onResize,
  });

  /// The panels, in order.
  final List<ResizablePanel> panels;

  /// Horizontal lays it out across; vertical, down.
  final Axis axis;

  /// Draws a grip on each handle, to show it can be dragged.
  final bool withHandle;

  /// Told each panel's share of the room, from 0 to 1, after every move: to keep it.
  final ValueChanged<List<double>>? onResize;

  @override
  State<Resizable> createState() => _ResizableState();
}

class _ResizableState extends State<Resizable> {
  late List<double> _sizes = _initial();

  List<double> _initial() {
    final total = widget.panels.fold<double>(0, (s, p) => s + p.size);
    return [for (final p in widget.panels) p.size / total];
  }

  @override
  void didUpdateWidget(Resizable old) {
    super.didUpdateWidget(old);
    if (old.panels.length != widget.panels.length) _sizes = _initial();
  }

  /// Moves the line after panel [i] by [delta], a fraction of the room.
  void _move(int i, double delta) {
    final a = widget.panels[i], b = widget.panels[i + 1];
    final pair = _sizes[i] + _sizes[i + 1];
    final next = (_sizes[i] + delta).clamp(a.minSize, pair - b.minSize);
    setState(() {
      _sizes[i] = next;
      _sizes[i + 1] = pair - next;
    });
    widget.onResize?.call(List.unmodifiable(_sizes));
  }

  @override
  Widget build(BuildContext context) => LayoutBuilder(
    builder: (context, box) {
      final horizontal = widget.axis == Axis.horizontal;
      final room =
          (horizontal ? box.maxWidth : box.maxHeight) -
          (widget.panels.length - 1);
      final children = <Widget>[];
      for (var i = 0; i < widget.panels.length; i++) {
        if (i > 0) {
          children.add(
            _Handle(
              axis: widget.axis,
              grip: widget.withHandle,
              share: _sizes[i - 1],
              room: room,
              onDrag: (px) => _move(i - 1, px / room),
            ),
          );
        }
        final extent = _sizes[i] * room;
        children.add(
          SizedBox(
            width: horizontal ? extent : null,
            height: horizontal ? null : extent,
            child: ClipRect(child: widget.panels[i].child),
          ),
        );
      }
      return Flex(
        direction: widget.axis,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: children,
      );
    },
  );
}

class _Handle extends StatefulWidget {
  const _Handle({
    required this.axis,
    required this.grip,
    required this.share,
    required this.room,
    required this.onDrag,
  });

  final Axis axis;
  final bool grip;

  /// The share of the panel before it, read out as the handle's value.
  final double share;

  /// The room the panels share, in pixels.
  final double room;
  final ValueChanged<double> onDrag;

  @override
  State<_Handle> createState() => _HandleState();
}

class _HandleState extends State<_Handle> {
  bool _focused = false;

  @override
  Widget build(BuildContext context) {
    final t = AshlarTheme.of(context);
    final c = t.colors;
    final horizontal = widget.axis == Axis.horizontal;
    // Across, the panels run from the start of the line, which is the right in
    // right-to-left text: a move to the right shrinks the first panel there.
    final sign = horizontal && isRtl(context) ? -1.0 : 1.0;
    const step = 16.0;
    return Semantics(
      slider: true,
      label: 'Resize',
      value: '${(widget.share * 100).round()}%',
      increasedValue:
          '${((widget.share + step / widget.room).clamp(0, 1) * 100).round()}%',
      decreasedValue:
          '${((widget.share - step / widget.room).clamp(0, 1) * 100).round()}%',
      onIncrease: () => widget.onDrag(step),
      onDecrease: () => widget.onDrag(-step),
      child: FocusableActionDetector(
        mouseCursor: horizontal
            ? SystemMouseCursors.resizeColumn
            : SystemMouseCursors.resizeRow,
        onShowFocusHighlight: (v) => setState(() => _focused = v),
        shortcuts: {
          SingleActivator(
            horizontal
                ? LogicalKeyboardKey.arrowRight
                : LogicalKeyboardKey.arrowDown,
          ): _NudgeIntent(
            step * sign,
          ),
          SingleActivator(
            horizontal
                ? LogicalKeyboardKey.arrowLeft
                : LogicalKeyboardKey.arrowUp,
          ): _NudgeIntent(
            -step * sign,
          ),
        },
        actions: {
          _NudgeIntent: CallbackAction<_NudgeIntent>(
            onInvoke: (i) => widget.onDrag(i.px),
          ),
        },
        child: GestureDetector(
          behavior: HitTestBehavior.translucent,
          onHorizontalDragUpdate: horizontal
              ? (d) => widget.onDrag(d.delta.dx * sign)
              : null,
          onVerticalDragUpdate: horizontal
              ? null
              : (d) => widget.onDrag(d.delta.dy),
          child: Container(
            width: horizontal ? 1 : null,
            height: horizontal ? null : 1,
            color: _focused ? c.ring : c.border,
            child: OverflowBox(
              // A wider strip than the line, so it is easy to grab.
              minWidth: horizontal ? 9 : null,
              maxWidth: horizontal ? 9 : null,
              minHeight: horizontal ? null : 9,
              maxHeight: horizontal ? null : 9,
              child: Center(
                child: widget.grip
                    ? Container(
                        width: horizontal ? 12 : 16,
                        height: horizontal ? 16 : 12,
                        decoration: BoxDecoration(
                          color: c.border,
                          borderRadius: BorderRadius.circular(t.radiusSm / 2),
                          border: Border.all(color: c.border),
                        ),
                      )
                    : const SizedBox.expand(),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

class _NudgeIntent extends Intent {
  const _NudgeIntent(this.px);

  final double px;
}