Last active
September 3, 2025 16:10
-
-
Save pskink/d9cd375c990b29a493d059542b279d7f to your computer and use it in GitHub Desktop.
CustomMultiChildLayout widget to implement a menu attached to floating parent
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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