Last active
November 21, 2023 19:21
-
-
Save clragon/6b8e63cb1c268b24558c2fd36d746779 to your computer and use it in GitHub Desktop.
floating extra content
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/foundation.dart'; | |
| import 'package:flutter/rendering.dart'; | |
| import 'package:flutter/material.dart'; | |
| void main() => runApp(const App()); | |
| class App extends StatelessWidget { | |
| const App({super.key}); | |
| @override | |
| Widget build(BuildContext context) { | |
| return MaterialApp( | |
| theme: ThemeData( | |
| colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), | |
| useMaterial3: true, | |
| ), | |
| home: const Home(), | |
| ); | |
| } | |
| } | |
| class Home extends StatefulWidget { | |
| const Home({super.key}); | |
| @override | |
| State<Home> createState() => _HomeState(); | |
| } | |
| class _HomeState extends State<Home> { | |
| FloaterLink link = FloaterLink(); | |
| bool followWidth = true; | |
| bool followHeight = true; | |
| AxisDirection direction = AxisDirection.down; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| } | |
| @override | |
| void dispose() { | |
| link.dispose(); | |
| super.dispose(); | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return Scaffold( | |
| body: Column( | |
| children: [ | |
| Expanded( | |
| child: Overlay( | |
| clipBehavior: Clip.none, | |
| initialEntries: [ | |
| OverlayEntry( | |
| builder: (context) => Center( | |
| child: Column( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: [ | |
| Row( | |
| mainAxisAlignment: MainAxisAlignment.center, | |
| children: [ | |
| Flexible( | |
| child: SizedBox( | |
| width: 400, | |
| child: FloaterTarget( | |
| link: link, | |
| child: Container( | |
| decoration: const BoxDecoration( | |
| color: Colors.blue, | |
| borderRadius: BorderRadius.all( | |
| Radius.circular(4), | |
| ), | |
| ), | |
| child: const Text( | |
| 'Test', | |
| style: TextStyle( | |
| color: Colors.white, | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| Floater( | |
| link: link, | |
| followHeight: followHeight, | |
| followWidth: followWidth, | |
| // offset: const Offset(0, 5), | |
| direction: direction, | |
| builder: (context) => Material( | |
| color: Colors.red, | |
| borderRadius: BorderRadius.circular(4), | |
| child: InkWell( | |
| onTap: () => print('tapped'), | |
| child: Stack( | |
| fit: StackFit.passthrough, | |
| children: [ | |
| const SizedBox.expand(), | |
| Positioned( | |
| top: 0, | |
| left: 0, | |
| child: Text( | |
| link.value.toString(), | |
| style: const TextStyle( | |
| color: Colors.white, | |
| ), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| Container( | |
| width: 400, | |
| height: 200, | |
| decoration: const BoxDecoration( | |
| color: Colors.green, | |
| borderRadius: BorderRadius.all( | |
| Radius.circular(4), | |
| ), | |
| ), | |
| child: Column( | |
| children: [ | |
| const Text( | |
| 'Outside of overlay', | |
| style: TextStyle( | |
| color: Colors.white, | |
| ), | |
| ), | |
| DropdownButton<AxisDirection>( | |
| value: direction, | |
| onChanged: (value) { | |
| setState(() { | |
| direction = value!; | |
| }); | |
| }, | |
| items: const [ | |
| DropdownMenuItem( | |
| value: AxisDirection.down, | |
| child: Text('Down'), | |
| ), | |
| DropdownMenuItem( | |
| value: AxisDirection.up, | |
| child: Text('Up'), | |
| ), | |
| DropdownMenuItem( | |
| value: AxisDirection.left, | |
| child: Text('Left'), | |
| ), | |
| DropdownMenuItem( | |
| value: AxisDirection.right, | |
| child: Text('Right'), | |
| ), | |
| ], | |
| ), | |
| SwitchListTile( | |
| title: const Text('Follow width'), | |
| value: followWidth, | |
| onChanged: (value) { | |
| setState(() { | |
| followWidth = value; | |
| }); | |
| }, | |
| ), | |
| SwitchListTile( | |
| title: const Text('Follow height'), | |
| value: followHeight, | |
| onChanged: (value) { | |
| setState(() { | |
| followHeight = value; | |
| }); | |
| }, | |
| ), | |
| ], | |
| ), | |
| ), | |
| ], | |
| ), | |
| ); | |
| } | |
| } | |
| /// Holds the last known size and offset of the widget | |
| /// to which the floater is attached. | |
| typedef FloaterInfo = ({ | |
| Size size, | |
| Offset offset, | |
| }); | |
| /// Connects a [Floater] with a [FloaterTarget]. | |
| /// | |
| /// All public members are for internal use only. | |
| class FloaterLink extends ValueNotifier<FloaterInfo> { | |
| FloaterLink() | |
| : super( | |
| (size: Size.zero, offset: Offset.zero), | |
| ); | |
| final LayerLink layerLink = LayerLink(); | |
| /// Manually trigger an update of the floater. | |
| /// | |
| /// This is useful when our automatic resizing fails, | |
| /// however, that should not happen. | |
| void markNeedsBuild() => notifyListeners(); | |
| } | |
| /// A widgt to which a [Floater] can be attached. | |
| class FloaterTarget extends StatelessWidget { | |
| const FloaterTarget({ | |
| super.key, | |
| required this.link, | |
| required this.child, | |
| }); | |
| final FloaterLink link; | |
| final Widget child; | |
| @override | |
| Widget build(BuildContext context) { | |
| return CompositedTransformTarget( | |
| link: link.layerLink, | |
| child: _FloaterTarget( | |
| link: link, | |
| child: child, | |
| ), | |
| ); | |
| } | |
| } | |
| class _FloaterTarget extends SingleChildRenderObjectWidget { | |
| const _FloaterTarget({ | |
| Key? key, | |
| Widget? child, | |
| required this.link, | |
| }) : super(key: key, child: child); | |
| final FloaterLink link; | |
| @override | |
| RenderObject createRenderObject(BuildContext context) { | |
| return _RenderFloaterTarget( | |
| controller: link, | |
| ); | |
| } | |
| @override | |
| void updateRenderObject( | |
| BuildContext context, _RenderFloaterTarget renderObject) { | |
| renderObject.controller = link; | |
| } | |
| @override | |
| void didUnmountRenderObject(_RenderFloaterTarget renderObject) { | |
| renderObject.controller = null; | |
| } | |
| } | |
| class _RenderFloaterTarget extends RenderProxyBox { | |
| _RenderFloaterTarget({ | |
| RenderBox? child, | |
| required this.controller, | |
| }) : super(child); | |
| FloaterLink? controller; | |
| @override | |
| void performLayout() { | |
| super.performLayout(); | |
| controller?.value = ( | |
| size: size, | |
| offset: controller!.value.offset, | |
| ); | |
| } | |
| @override | |
| void paint(PaintingContext context, Offset offset) { | |
| super.paint(context, offset); | |
| controller?.value = ( | |
| size: controller!.value.size, | |
| offset: localToGlobal(Offset.zero), | |
| ); | |
| } | |
| } | |
| /// A widget that can float next to a [FloaterTarget], inheriting its size. | |
| /// | |
| /// Floaters use the surrounding [Overlay] to position themselves. | |
| class Floater extends StatefulWidget { | |
| const Floater({ | |
| super.key, | |
| this.child, | |
| required this.builder, | |
| required this.link, | |
| this.followWidth = true, | |
| this.followHeight = true, | |
| this.direction = AxisDirection.down, | |
| this.offset = Offset.zero, | |
| this.autoFlip = false, | |
| this.autoFlipHeight = 64, | |
| }); | |
| /// The widget below this widget in the tree. | |
| final Widget? child; | |
| /// Builds the content of the floater. | |
| final WidgetBuilder builder; | |
| /// The link to the [FloaterTarget] to which the floater should attach. | |
| final FloaterLink link; | |
| /// Whether the floater should inherit the width of the target. | |
| final bool followWidth; | |
| /// Whether the floater should inherit the height of the target. | |
| final bool followHeight; | |
| /// The desired direction of the floater. | |
| /// | |
| /// The floater will try to open in this direction. | |
| /// This also defines how much space the floater has to grow. | |
| final AxisDirection direction; | |
| /// The offset of the floater from the target. | |
| /// | |
| /// This is treated directionally, i.e. the offset is applied | |
| /// in the direction of the floater. | |
| /// | |
| /// For example, if [direction] is [AxisDirection.down], | |
| /// the offset is treated as is. | |
| /// If [direction] is [AxisDirection.right], | |
| /// the offset is applied as if it was [Offset(offset.dy, -offset.dx)]. | |
| final Offset offset; | |
| /// Whether the floater should automatically flip direction if there's not enough space. | |
| /// | |
| /// The minimum height of the floater is defined by [autoFlipHeight]. | |
| /// Defaults to false. | |
| final bool autoFlip; | |
| /// The minimum height of the floater before it attempts to flip direction. | |
| final double autoFlipHeight; | |
| /// Returns the [FloaterData] of the closest [Floater] ancestor, or null if there is no [Floater] ancestor. | |
| static FloaterData? maybeOf(BuildContext context) => | |
| context.dependOnInheritedWidgetOfExactType<_FloaterProvider>()?.data; | |
| /// Returns the [FloaterData] of the closest [Floater] ancestor. | |
| static FloaterData of(BuildContext context) { | |
| FloaterData? data = maybeOf(context); | |
| if (data == null) { | |
| throw FlutterError.fromParts( | |
| [ | |
| ErrorSummary( | |
| 'Floater.of() called with a context ' | |
| 'that does not contain a Floater.', | |
| ), | |
| ErrorDescription( | |
| 'No Floater ancestor could be found ' | |
| 'starting from the context that was passed to Floater.of().', | |
| ), | |
| context.describeElement('The context used was'), | |
| ], | |
| ); | |
| } | |
| return data; | |
| } | |
| @override | |
| State<Floater> createState() => _FloaterState(); | |
| } | |
| class _FloaterState extends State<Floater> with WidgetsBindingObserver { | |
| OverlayPortalController controller = OverlayPortalController(); | |
| List<dynamic>? dependencies; | |
| EdgeInsets? insets; | |
| @override | |
| void initState() { | |
| super.initState(); | |
| WidgetsBinding.instance.addObserver(this); | |
| widget.link.addListener(updateOverlay); | |
| maybeUpdateOverlay(); | |
| } | |
| @override | |
| void didChangeDependencies() { | |
| super.didChangeDependencies(); | |
| maybeUpdateOverlay(); | |
| } | |
| @override | |
| void dispose() { | |
| widget.link.removeListener(updateOverlay); | |
| WidgetsBinding.instance.removeObserver(this); | |
| super.dispose(); | |
| } | |
| @override | |
| void didChangeMetrics() { | |
| // When the keyboard is toggled, we need to update the floater, | |
| // since its constraints might have changed. | |
| OverlayState overlay = Overlay.of(context); | |
| MediaQueryData mediaQuery = MediaQuery.of(overlay.context); | |
| if (insets != mediaQuery.viewInsets) { | |
| insets = mediaQuery.viewInsets; | |
| updateOverlay(); | |
| } | |
| } | |
| void maybeUpdateOverlay() { | |
| final List<dynamic> newDependencies = createDependencies(); | |
| if (!listEquals(dependencies, newDependencies)) { | |
| dependencies = newDependencies; | |
| updateOverlay(); | |
| } | |
| } | |
| void updateOverlay() { | |
| WidgetsBinding.instance.addPostFrameCallback((_) { | |
| setState(() {}); | |
| controller.show(); | |
| }); | |
| } | |
| List<dynamic> createDependencies() { | |
| return [ | |
| widget.link, | |
| widget.link.value, | |
| widget.direction, | |
| widget.offset, | |
| widget.followWidth, | |
| widget.followHeight, | |
| ]; | |
| } | |
| Size getDiretionSize( | |
| AxisDirection direction, | |
| Size overlaySize, | |
| Offset floaterOffset, | |
| Offset extra, | |
| Size size, | |
| ) { | |
| floaterOffset = getDirectionOffset( | |
| direction, | |
| floaterOffset, | |
| extra, | |
| ); | |
| return switch (direction) { | |
| AxisDirection.down => Size( | |
| overlaySize.width, | |
| overlaySize.height - floaterOffset.dy - size.height, | |
| ), | |
| AxisDirection.up => Size( | |
| overlaySize.width, | |
| floaterOffset.dy, | |
| ), | |
| AxisDirection.left => Size( | |
| floaterOffset.dx, | |
| overlaySize.height, | |
| ), | |
| AxisDirection.right => Size( | |
| overlaySize.width - floaterOffset.dx - size.width, | |
| overlaySize.height, | |
| ), | |
| }; | |
| } | |
| Offset getDirectionOffset( | |
| AxisDirection direction, | |
| Offset base, | |
| Offset extra, | |
| ) { | |
| return switch (direction) { | |
| AxisDirection.down => base + extra, | |
| AxisDirection.right => base + Offset(extra.dy, -extra.dx), | |
| AxisDirection.up => base + Offset(-extra.dx, -extra.dy), | |
| AxisDirection.left => base + Offset(-extra.dy, extra.dx), | |
| }; | |
| } | |
| (Alignment, Alignment) getDirectionAnchors(AxisDirection direction) { | |
| return switch (direction) { | |
| AxisDirection.down => (Alignment.bottomCenter, Alignment.topCenter), | |
| AxisDirection.up => (Alignment.topCenter, Alignment.bottomCenter), | |
| AxisDirection.left => (Alignment.centerLeft, Alignment.centerRight), | |
| AxisDirection.right => (Alignment.centerRight, Alignment.centerLeft), | |
| }; | |
| } | |
| EdgeInsets getDirectionPadding(AxisDirection direction, EdgeInsets padding) { | |
| return switch (direction) { | |
| AxisDirection.down => EdgeInsets.only( | |
| bottom: padding.bottom, | |
| left: padding.left, | |
| right: padding.right, | |
| ), | |
| AxisDirection.up => EdgeInsets.only( | |
| top: padding.top, | |
| left: padding.left, | |
| right: padding.right, | |
| ), | |
| AxisDirection.left => EdgeInsets.only( | |
| left: padding.left, | |
| top: padding.top, | |
| bottom: padding.bottom, | |
| ), | |
| AxisDirection.right => EdgeInsets.only( | |
| right: padding.right, | |
| top: padding.top, | |
| bottom: padding.bottom, | |
| ), | |
| }; | |
| } | |
| @override | |
| Widget build(BuildContext context) { | |
| return OverlayPortal( | |
| controller: controller, | |
| child: widget.child, | |
| overlayChildBuilder: (context) => Builder( | |
| builder: (context) { | |
| final FloaterInfo(:size, :offset) = widget.link.value; | |
| OverlayState overlay = Overlay.of(context); | |
| final RenderBox overlayBox = | |
| overlay.context.findRenderObject()! as RenderBox; | |
| Size available; | |
| Alignment targetAnchor; | |
| Alignment followerAnchor; | |
| Offset overlayOffset = overlayBox.localToGlobal(Offset.zero); | |
| Size overlaySize = overlayBox.size; | |
| MediaQueryData mediaQuery = MediaQuery.of(overlay.context); | |
| EdgeInsets viewPadding = mediaQuery.padding + mediaQuery.viewInsets; | |
| overlayOffset = Offset( | |
| max(overlayOffset.dx, viewPadding.left), | |
| max(overlayOffset.dy, viewPadding.top), | |
| ); | |
| overlaySize = Size( | |
| overlaySize.width - viewPadding.left - viewPadding.right, | |
| overlaySize.height - viewPadding.top - viewPadding.bottom, | |
| ); | |
| Offset floaterOffset = offset - overlayOffset; | |
| floaterOffset = Offset( | |
| max(0, floaterOffset.dx), | |
| max(0, floaterOffset.dy), | |
| ); | |
| EdgeInsets padding = viewPadding; | |
| AxisDirection direction = widget.direction; | |
| available = getDiretionSize( | |
| direction, | |
| overlaySize, | |
| floaterOffset, | |
| widget.offset, | |
| size, | |
| ); | |
| if (widget.autoFlip && available.height < widget.autoFlipHeight) { | |
| AxisDirection opposite = flipAxisDirection(widget.direction); | |
| Size maybeAvailable = getDiretionSize( | |
| opposite, | |
| overlaySize, | |
| floaterOffset, | |
| widget.offset, | |
| size, | |
| ); | |
| if (maybeAvailable.height > available.height) { | |
| direction = opposite; | |
| available = maybeAvailable; | |
| } | |
| } | |
| available = Size( | |
| max(0, available.width), | |
| max(0, available.height), | |
| ); | |
| (targetAnchor, followerAnchor) = getDirectionAnchors(direction); | |
| padding = getDirectionPadding(direction, padding); | |
| BoxConstraints constraints = BoxConstraints( | |
| maxWidth: available.width, | |
| maxHeight: available.height, | |
| ); | |
| Size target = Size( | |
| min(size.width, available.width), | |
| min(size.height, available.height), | |
| ); | |
| if (widget.followWidth) { | |
| constraints = constraints.enforce( | |
| BoxConstraints( | |
| maxWidth: target.width, | |
| ), | |
| ); | |
| } | |
| if (widget.followHeight) { | |
| constraints = constraints.enforce( | |
| BoxConstraints( | |
| maxHeight: target.height, | |
| ), | |
| ); | |
| } | |
| return CompositedTransformFollower( | |
| showWhenUnlinked: false, | |
| link: widget.link.layerLink, | |
| offset: getDirectionOffset( | |
| direction, | |
| Offset.zero, | |
| widget.offset, | |
| ), | |
| targetAnchor: targetAnchor, | |
| followerAnchor: followerAnchor, | |
| child: Padding( | |
| padding: padding, | |
| child: MediaQuery.removePadding( | |
| context: context, | |
| child: MediaQuery.removeViewInsets( | |
| context: context, | |
| child: Align( | |
| alignment: followerAnchor, | |
| child: ConstrainedBox( | |
| constraints: constraints, | |
| child: _FloaterProvider( | |
| data: FloaterData( | |
| size: Size( | |
| constraints.maxWidth, | |
| constraints.maxHeight, | |
| ), | |
| offset: floaterOffset, | |
| direction: widget.direction, | |
| effectiveDirection: direction, | |
| ), | |
| child: Builder( | |
| builder: widget.builder, | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| }, | |
| ), | |
| ); | |
| } | |
| } | |
| /// Information about the current state of a [Floater]. | |
| @immutable | |
| class FloaterData { | |
| const FloaterData({ | |
| required this.size, | |
| required this.offset, | |
| required this.direction, | |
| required this.effectiveDirection, | |
| }); | |
| /// The maximum size of the floater. | |
| final Size size; | |
| /// The global offset of the floater. | |
| final Offset offset; | |
| /// The desired direction of the floater. | |
| final AxisDirection direction; | |
| /// The effective direction of the floater. | |
| final AxisDirection effectiveDirection; | |
| @override | |
| bool operator ==(Object other) { | |
| return other is FloaterData && | |
| other.size == size && | |
| other.offset == offset && | |
| other.direction == direction && | |
| other.effectiveDirection == effectiveDirection; | |
| } | |
| @override | |
| int get hashCode => Object.hash( | |
| size, | |
| offset, | |
| direction, | |
| effectiveDirection, | |
| ); | |
| } | |
| class _FloaterProvider extends InheritedWidget { | |
| const _FloaterProvider({ | |
| required this.data, | |
| required Widget child, | |
| }) : super(child: child); | |
| final FloaterData data; | |
| @override | |
| bool updateShouldNotify(_FloaterProvider oldWidget) => oldWidget.data != data; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment