Skip to content

Instantly share code, notes, and snippets.

@pskink
Last active September 3, 2025 16:10
Show Gist options
  • Save pskink/d9cd375c990b29a493d059542b279d7f to your computer and use it in GitHub Desktop.
Save pskink/d9cd375c990b29a493d059542b279d7f to your computer and use it in GitHub Desktop.
CustomMultiChildLayout widget to implement a menu attached to floating parent
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
void main() {
runApp(MaterialApp(home: DemoPage(), debugShowCheckedModeBanner: false));
}
class DemoPage extends StatefulWidget {
const DemoPage({super.key});
@override
State<DemoPage> createState() => _DemoPageState();
}
class _DemoPageState extends State<DemoPage> with TickerProviderStateMixin {
final moveNotifier = ValueNotifier(0);
late final menuController = MenuController(this);
bool menuVisible = true;
int activeEntry = 0;
late final entries = [
HandleEntry(
alignment: Alignment(-0.9, -0.9),
child: Text('hello world',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
),
HandleEntry(
alignment: Alignment(0.9, -0.5),
child: Text('hello dart',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
),
HandleEntry(
alignment: Alignment(-0.5, 0.5),
child: Text('hello dash',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
Material(
color: Colors.green.shade100,
child: Padding(
padding: const EdgeInsets.all(4),
child: Text.rich(TextSpan(
children: [
TextSpan(text: 'HOW TO USE: ', style: TextStyle(fontWeight: FontWeight.bold)),
TextSpan(text: 'drag the orange widget to the right side of the window (double click to open / close the menu)'),
],
)),
),
),
Expanded(
child: CustomMultiChildLayout(
delegate: _DemoPageDelegate(
entries: entries,
activeEntry: activeEntry,
menuController: menuController,
moveNotifier: moveNotifier,
),
children: [
// background gradient
LayoutId(
id: #background,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
colors: [
Colors.white,
Colors.grey.shade300,
],
center: Alignment(0, 0.5),
focal: Alignment(0, 0),
),
),
),
),
// handles
for (int i = 0; i < entries.length; i++)
LayoutId(
id: 'handle-$i',
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 75,
),
child: GestureDetector(
onDoubleTap: () => setState(() => menuVisible = !menuVisible),
onTap: () {
if (activeEntry == i) return;
setState(() {
entries.add(entries.removeAt(i));
menuController.fly();
activeEntry = entries.length - 1;
});
},
onPanUpdate: (d) {
if (activeEntry != i) return;
entries[activeEntry].offset += d.delta;
moveNotifier.value++;
},
child: _TargetCard(
isActive: activeEntry == i,
child: entries[i].child,
),
),
),
),
// menu
LayoutId(
id: #menu,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 175),
child: _AnchoredMenu(
margin: const EdgeInsets.symmetric(horizontal: 4).copyWith(bottom: 8),
visible: menuVisible,
child: _OptionsList(),
),
),
),
],
),
),
],
),
);
}
@override
void dispose() {
menuController.dispose();
moveNotifier.dispose();
super.dispose();
}
}
class _DemoPageDelegate extends MultiChildLayoutDelegate {
_DemoPageDelegate({
required this.entries,
required this.activeEntry,
required this.menuController,
required this.moveNotifier,
}) : super(relayout: Listenable.merge([moveNotifier, ...menuController.listenables]));
final List<HandleEntry> entries;
final int activeEntry;
final MenuController menuController;
final ValueNotifier<int> moveNotifier;
double _target = double.infinity;
final alignLabels = ['left side', 'center', 'right side'];
@override
void performLayout(Size size) {
// timeDilation = 10;
layoutChild(#background, BoxConstraints.tight(size));
// debugPrint('activeEntry: $activeEntry');
// debugPrint('menuOffsetTween: $menuOffsetTween');
final menuSize = layoutChild(#menu, BoxConstraints.loose(size));
// debugPrint('menuSize: $menuSize');
Rect activeHandleRect = Rect.zero;
for (int i = 0; i < entries.length; i++) {
final handleId = 'handle-$i';
final handleSize = layoutChild(handleId, BoxConstraints.loose(size));
// debugPrint('handleSize: $handleSize');
final entry = entries[i];
if (entry.offset == Offset.infinite) {
entry.offset = entry.alignment.inscribe(handleSize, Offset.zero & size).topLeft;
}
// position handle
final handleOffset = Offset(
entry.offset.dx.clamp(0, max(size.width - handleSize.width, 0)),
entry.offset.dy.clamp(0, max(size.height - handleSize.height, 0)),
);
if (i == activeEntry) {
activeHandleRect = handleOffset & handleSize;
}
positionChild(handleId, handleOffset);
}
assert(activeHandleRect != Rect.zero);
// position menu
Rect menuRect = Alignment.topCenter.inscribe(menuSize, activeHandleRect);
final dx = (activeHandleRect.width + menuSize.width) / 2;
final align = _getAlign(size, activeHandleRect, menuRect);
final target = dx * align;
final alignLabel = alignLabels[align + 1];
if (target != _target) {
debugPrint('animate to $alignLabel');
_target = target;
menuController.animateAlignment(target);
}
menuController.flyEnd = menuRect.topLeft.translate(target, 0);
final effectiveOffset = !menuController.isFlying?
menuRect.topLeft.translate(menuController.currentAlignX, 0) :
menuController.currentFlyOffset();
positionChild(#menu, effectiveOffset);
}
@override
bool shouldRelayout(_DemoPageDelegate oldDelegate) =>
activeEntry != oldDelegate.activeEntry;
int _getAlign(Size size, Rect handleRect, Rect menuRect) {
// right
if (size.width - handleRect.right > menuRect.width) return 1;
// left
if (handleRect.left > menuRect.width) return -1;
// center
return 0;
}
}
class MenuController {
MenuController(TickerProvider vsync) :
_menuAlignController = AnimationController.unbounded(vsync: vsync, duration: Durations.medium2),
_menuFlyController = AnimationController(vsync: vsync, duration: Durations.medium2);
final AnimationController _menuAlignController;
final AnimationController _menuFlyController;
late final Animation<Offset> _menuFlyAnimation = _menuFlyTween
.chain(CurveTween(curve: Curves.easeOut))
.animate(_menuFlyController);
final Tween<Offset> _menuFlyTween = Tween<Offset>();
// final Tween<Offset> _menuFlyTween = TestFlyTween();
Iterable<Listenable> get listenables => [_menuAlignController, _menuFlyController];
double get currentAlignX => _menuAlignController.value;
bool get isFlying => _menuFlyAnimation.isAnimating;
set flyEnd(Offset end) => _menuFlyTween.end = end;
Offset currentFlyOffset() {
return flyEnd = _menuFlyAnimation.value;
}
TickerFuture fly() {
_menuFlyTween.begin = _menuFlyTween.end;
return _menuFlyController.forward(from: 0);
}
TickerFuture animateAlignment(double target) {
return _menuAlignController.animateTo(target, curve: Curves.easeOut);
}
void dispose() {
_menuAlignController.dispose();
_menuFlyController.dispose();
}
}
// class TestFlyTween extends Tween<Offset> {
// @override
// Offset transform(double t) {
// final transform = super.transform(t);
// print('### $this + ${t.toStringAsFixed(2)} == $transform');
// return transform;
// }
// }
// =============================================================================
class _AnchoredMenu extends StatelessWidget {
const _AnchoredMenu({
this.margin = const EdgeInsets.symmetric(horizontal: 8),
required this.visible,
required this.child,
});
final EdgeInsets margin;
final bool visible;
final Widget child;
@override
Widget build(BuildContext context) {
const duration = Durations.medium4;
return DecoratedBox(
position: DecorationPosition.foreground,
decoration: BoxDecoration(
border: Border(
top: Divider.createBorderSide(context),
),
),
child: ClipRect(
child: AnimatedAlign(
alignment: Alignment.bottomCenter,
duration: duration,
heightFactor: visible ? 1 : 0,
child: AnimatedOpacity(
opacity: visible ? 1 : 0,
duration: duration,
child: Padding(
padding: margin,
child: Material(
elevation: 4,
clipBehavior: Clip.antiAlias,
borderRadius: BorderRadius.vertical(bottom: Radius.circular(8)),
color: Theme.of(context).colorScheme.surface,
child: child,
),
),
),
),
),
);
}
}
class _TargetCard extends StatelessWidget {
const _TargetCard({
required this.isActive,
required this.child,
});
final bool isActive;
final Widget child;
@override
Widget build(BuildContext context) {
return Material(
borderRadius: BorderRadius.circular(8),
elevation: 4,
clipBehavior: Clip.antiAlias,
child: AnimatedContainer(
duration: Durations.long4,
color: isActive? Colors.orange : Colors.yellow,
child: Padding(
padding: const EdgeInsets.all(4),
child: child,
),
),
);
}
}
class _OptionsList extends StatelessWidget {
final icons = [
('edit', Icons.edit), ('cut', Icons.cut), ('copy', Icons.copy),
('paste', Icons.paste), ('delete', Icons.delete)
];
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
dense: true,
title: const Text('Actions', style: TextStyle(fontWeight: FontWeight.w600)),
trailing: const Icon(Icons.tune),
),
const Divider(height: 1),
for (final (label, iconData) in icons)
ListTile(
dense: true,
leading: Icon(iconData),
title: Text('$label action'),
onTap: () => debugPrint('[$label action] clicked'),
),
],
);
}
}
class HandleEntry {
HandleEntry({
required this.alignment,
required this.child,
});
final Alignment alignment;
Offset offset = Offset.infinite;
final Widget child;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment