Skip to content

Instantly share code, notes, and snippets.

@passsy
Last active October 21, 2024 03:57
Show Gist options
  • Save passsy/8ea2cab6de53f472e5fe797bdc7f114a to your computer and use it in GitHub Desktop.
Save passsy/8ea2cab6de53f472e5fe797bdc7f114a to your computer and use it in GitHub Desktop.
A Flutter overlay implementation that allows alligning the overlay freely around the target (button)
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
/// Written by PHNTM GmbH
void main() {
runApp(const AdvancedOverlaySample());
}
class AdvancedOverlaySample extends StatefulWidget {
const AdvancedOverlaySample({super.key});
@override
State<AdvancedOverlaySample> createState() => _AdvancedOverlaySampleState();
}
class _AdvancedOverlaySampleState extends State<AdvancedOverlaySample> {
final AdvancedOverlayController controller = AdvancedOverlayController();
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('AdvancedOverlay Sample'),
),
body: Align(
alignment: Alignment(0, -0.5),
child: SizedBox(
width: 200,
height: 100,
child: AdvancedOverlay(
// Adjust where the overlay is anchored here
targetAnchor: Alignment.bottomRight,
overlayAnchor: Alignment.topRight,
//
overlayController: controller,
overlayBuilder: (BuildContext context) {
return SizedBox(
width: 300,
height: 154,
child: Card(
child: Align(
alignment: Alignment.topLeft,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(title: Text('Option 1'), onTap: () {}),
ListTile(title: Text('Option 2'), onTap: () {}),
ListTile(title: Text('Option 3'), onTap: () {}),
],
),
),
),
);
},
child: GestureDetector(
onTap: () {
if (controller.isShowing) {
controller.hide();
} else {
controller.show();
}
},
child: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(2),
),
),
onPressed: () {
if (controller.isShowing) {
controller.hide();
} else {
controller.show();
}
},
child: const Center(
child: Text('Show Overlay'),
),
),
),
),
),
),
),
);
}
}
/// An advanced overlay augmenting the [OverlayPortal] with additional features.
///
/// These are some additinonal features:
/// [alignOverlayWithButtonSize] sizes the overlay to the button size. This is useful when using in combinations with form fields.
/// [alwaysOnScreen] ensures the overlay is always on screen. Turn off to not have the overlay move when it does not fit on the screen.
/// [onTapOutside] closes the overlay when tapping nearby.
class AdvancedOverlay extends StatefulWidget {
/// Creates an advanced overlay.
AdvancedOverlay({
super.key,
required this.overlayController,
required this.overlayBuilder,
required this.child,
Alignment? overlayAnchor,
Alignment? targetAnchor,
Offset? animationOffset,
Offset? overlayOffset,
this.alwaysOnScreen,
bool? alignOverlayWithButtonSize,
this.onTapOutside,
this.overlayWidth,
this.onClose,
bool? includeChildOnTapOutside,
bool? hideOnTapOutside,
this.barrierColor = const Color(0x33000000),
this.useRootOverlay,
}) : overlayAnchor = overlayAnchor ?? Alignment.topLeft,
targetAnchor = targetAnchor ?? Alignment.bottomLeft,
animationOffset = animationOffset ?? _animationOffset(overlayAnchor ?? Alignment.topLeft),
overlayOffset = overlayOffset ?? Offset.zero,
hideOnTapOutside = hideOnTapOutside ?? true,
includeChildOnTapOutside = includeChildOnTapOutside ?? false,
alignOverlayWithButtonSize = alignOverlayWithButtonSize ?? false;
static Offset _animationOffset(Alignment alignment) {
if (alignment.y < 0) {
return const Offset(0, -8);
}
if (alignment.y > 0) {
return const Offset(0, 8);
}
return Offset.zero;
}
/// The controller that manages the overlay.
final AdvancedOverlayController overlayController;
/// The builder for the overlay content.
final WidgetBuilder overlayBuilder;
/// The anchor alignment of the overlay.
final Alignment overlayAnchor;
/// The anchor alignment of the target.
final Alignment targetAnchor;
/// The direction of the open animation in which the overlay should slightly move.
///
/// Offset(0,0) means no movement.
/// Offset(0.1,0) means the overlay will move 10% to the right.
final Offset animationOffset;
/// When true, always moves the overlay in the visible area of the screen, even if it means that the overlay is not aligned with the button.
///
/// This is useful when content is shown within a scrollable area and the overlay should always be visible
final bool? alwaysOnScreen;
/// If the overlay should align with the button width
final bool alignOverlayWithButtonSize;
/// If the overlay should close when tapping nearby.
final VoidCallback? onTapOutside;
/// The target that triggers the overlay.
final Widget child;
/// A fixed width of the overlay
final double? overlayWidth;
/// The offset of the overlay.
final Offset overlayOffset;
/// A callback that is called when the overlay is closed.
final VoidCallback? onClose;
/// If the overlay should hide when tapping outside.
/// This needs to be false when the child is using a [MouseRegion] to open/close the overlay.
final bool hideOnTapOutside;
/// If the child should be included in the tap outside test.
/// If true, when the child is tapped the overlay will not be closed.
final bool includeChildOnTapOutside;
/// The color of the barrier behind the overlay.
final Color? barrierColor;
/// Whether the overlay should be rendered in the root overlay
///
/// Defaults to true, using the root [Overlay], not the closest
final bool? useRootOverlay;
@override
State<AdvancedOverlay> createState() => _AdvancedOverlayState();
}
class _AdvancedOverlayState extends State<AdvancedOverlay> with SingleTickerProviderStateMixin {
final _link = LayerLink();
final GlobalKey _childKey = GlobalKey();
/// The size of the widget at the moment the trigger was clicked.
///
/// When [includeChildOnTapOutside] is true, the child will be moved into the overlay.
/// The size will be used to place a placeholder at the old position to prevent the layout from jumping.
Size? _childSize;
late final AnimationController _barrierController;
late Animation<Color?> _barrierColorAnimation;
@override
void initState() {
super.initState();
_barrierController = AnimationController(duration: const Duration(milliseconds: 200), vsync: this);
_barrierColorAnimation = ColorTween(begin: const Color(0x00000000), end: widget.barrierColor)
.animate(CurvedAnimation(parent: _barrierController, curve: Curves.easeInOutCubic));
_populateController();
}
@override
void didUpdateWidget(covariant AdvancedOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
oldWidget.overlayController._onClose = null;
oldWidget.overlayController._onOpen = null;
if (oldWidget.barrierColor != widget.barrierColor) {
_barrierColorAnimation = ColorTween(begin: const Color(0x00000000), end: widget.barrierColor)
.animate(CurvedAnimation(parent: _barrierController, curve: Curves.easeInOutCubic));
}
_populateController();
}
void _populateController() {
widget.overlayController._onClose = () async {
await _barrierController.reverse();
widget.onClose?.call();
};
widget.overlayController._onOpen = () async {
_updateChildSize();
_barrierController.forward();
};
}
@override
void dispose() {
widget.overlayController._onClose = null;
widget.overlayController._onOpen = null;
_barrierController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final child = () {
final trigger = KeyedSubtree(
key: _childKey,
child: widget.child,
);
if (!widget.includeChildOnTapOutside) {
return trigger;
}
return ListenableBuilder(
listenable: widget.overlayController,
builder: (BuildContext context, Widget? child) {
if (widget.overlayController.isShowing) {
// Placeholder to prevent layout jumping at the location where the child was before it was moved into the overlay.
return SizedBox(
width: _childSize!.width,
height: _childSize!.height,
);
}
return trigger;
},
);
}();
final portal = widget.useRootOverlay == false
? OverlayPortal(
controller: widget.overlayController._portalController,
overlayChildBuilder: overlayChildBuilder,
child: child,
)
: OverlayPortal.targetsRootOverlay(
controller: widget.overlayController._portalController,
overlayChildBuilder: overlayChildBuilder,
child: child,
);
return CompositedTransformTarget(
link: _link,
child: portal,
);
}
Widget overlayChildBuilder(BuildContext context) {
return Stack(
children: [
if (widget.hideOnTapOutside)
Positioned.fill(
key: const ValueKey('barrier'),
child: AnimatedModalBarrier(
color: _barrierColorAnimation,
onDismiss: () async {
await _barrierController.reverse();
widget.onTapOutside?.call();
if (widget.hideOnTapOutside) {
widget.overlayController.hide();
}
},
),
),
// If the trigger should be included in the tap outside test, we put it in the overlay too to be fully
// recognized by Gesture Listeners like GestureDetector
if (widget.includeChildOnTapOutside)
CompositedTransformFollower(
key: const ValueKey('follower'),
link: _link,
child: SizedBox(
width: _childSize!.width,
height: _childSize!.height,
child: KeyedSubtree(
key: _childKey,
child: widget.child,
),
),
),
AdvancedOverlayFollower(
key: const ValueKey('widget.overlayBuilder'),
link: _link,
followerAnchor: widget.overlayAnchor,
targetAnchor: widget.targetAnchor,
alwaysOnScreen: widget.alwaysOnScreen,
targetKey: _childKey,
alignOverlayWithButtonSize: widget.alignOverlayWithButtonSize,
overlayWidth: widget.overlayWidth,
offset: widget.overlayOffset,
onClose: widget.onClose,
useRootOverlay: widget.useRootOverlay,
child: _OverlayRevealAnimation(
controller: _barrierController,
animationOffset: widget.animationOffset,
show: widget.overlayController.isShowing,
child: widget.overlayBuilder(context),
),
),
],
);
}
void _updateChildSize() {
final RenderBox? renderBox = _childKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null) {
return;
}
if (!renderBox.hasSize) {
return;
}
if (_childSize == renderBox.size) {
return;
}
setState(() {
_childSize = renderBox.size;
});
}
}
/// A widget that follows a [LayerLink] and is always on screen.
class AdvancedOverlayFollower extends SingleChildRenderObjectWidget {
/// Creates a [AdvancedOverlayFollower].
const AdvancedOverlayFollower({
super.key,
required this.link,
required this.targetKey,
required this.targetAnchor,
required this.followerAnchor,
this.offset = Offset.zero,
this.alwaysOnScreen,
this.alignOverlayWithButtonSize,
this.overlayWidth,
this.onClose,
super.child,
this.useRootOverlay,
});
/// The link to the target.
final LayerLink link;
/// The offset of the overlay.
final Offset offset;
/// The anchor alignment of the target.
final Alignment targetAnchor;
/// The anchor alignment of the overlay.
final Alignment followerAnchor;
/// If the overlay should always be on screen.
final bool? alwaysOnScreen;
/// If the overlay should align with the button size.
final bool? alignOverlayWithButtonSize;
/// A fixed width of the overlay
final double? overlayWidth;
/// A callback that is called when the overlay is closed.
final VoidCallback? onClose;
/// Whether the overlay should be rendered in the root overlay
///
/// Defaults to true, using the root [Overlay], not the closest
final bool? useRootOverlay;
/// The globalKey of the target, allowing to read the size of the render object
final GlobalKey targetKey;
Rect _getOverlayRect(BuildContext context, {required bool rootOverlay}) {
final OverlayState overlay = Overlay.of(context, rootOverlay: rootOverlay);
final box = (overlay.context as Element).renderObject! as RenderBox;
final position = box.localToGlobal(Offset.zero);
final rect = Rect.fromLTWH(position.dx, position.dy, box.size.width, box.size.height);
return rect;
}
@override
RenderAdvancedOverlayFollower createRenderObject(BuildContext context) {
return RenderAdvancedOverlayFollower(
link: link,
offset: offset,
leaderAnchor: targetAnchor,
followerAnchor: followerAnchor,
alwaysOnScreen: alwaysOnScreen,
alignOverlayWithButtonSize: alignOverlayWithButtonSize,
overlayWidth: overlayWidth,
targetKey: targetKey,
overlayRect: _getOverlayRect(context, rootOverlay: useRootOverlay ?? true),
);
}
@override
void updateRenderObject(BuildContext context, RenderAdvancedOverlayFollower renderObject) {
renderObject
..link = link
..offset = offset
..leaderAnchor = targetAnchor
..followerAnchor = followerAnchor
..alwaysOnScreen = alwaysOnScreen
..alignOverlayWithButtonSize = alignOverlayWithButtonSize
..overlayWidth = overlayWidth
..targetKey = targetKey
..overlayRect = _getOverlayRect(context, rootOverlay: useRootOverlay ?? true);
}
}
/// A render object that follows a [LayerLink] and is always on screen.
class RenderAdvancedOverlayFollower extends RenderProxyBox {
/// Creates a [RenderAdvancedOverlayFollower].
RenderAdvancedOverlayFollower({
required LayerLink link,
Offset offset = Offset.zero,
required Alignment leaderAnchor,
required Alignment followerAnchor,
required GlobalKey targetKey,
bool? alwaysOnScreen,
bool? alignOverlayWithButtonSize,
double? overlayWidth,
RenderBox? child,
required Rect overlayRect,
}) : _link = link,
_offset = offset,
_leaderAnchor = leaderAnchor,
_alwaysOnScreen = alwaysOnScreen,
_overlayWidth = overlayWidth,
_alignOverlayWithButtonSize = alignOverlayWithButtonSize,
_followerAnchor = followerAnchor,
_targetKey = targetKey,
_overlayRect = overlayRect {
this.child = child;
}
/// The offset to apply to the origin of the linked [RenderLeaderLayer] to
/// obtain this render object's origin.
Offset get offset => _offset;
Offset _offset;
set offset(Offset value) {
if (_offset == value) {
return;
}
_offset = value;
markNeedsLayout();
}
/// The connection between target an follower
LayerLink get link => _link;
LayerLink _link;
set link(LayerLink value) {
if (_link != value) {
_link = value;
markNeedsLayout();
}
}
/// The anchor alignment of the target.
Alignment get leaderAnchor => _leaderAnchor;
Alignment _leaderAnchor;
set leaderAnchor(Alignment value) {
if (_leaderAnchor != value) {
_leaderAnchor = value;
markNeedsLayout();
}
}
/// The anchor alignment of the follower.
Alignment get followerAnchor => _followerAnchor;
Alignment _followerAnchor;
set followerAnchor(Alignment value) {
if (_followerAnchor != value) {
_followerAnchor = value;
markNeedsLayout();
}
}
/// If the overlay should always be within the Overlay
bool? get alwaysOnScreen => _alwaysOnScreen;
bool? _alwaysOnScreen;
set alwaysOnScreen(bool? value) {
if (_alwaysOnScreen != value) {
_alwaysOnScreen = value;
markNeedsLayout();
}
}
/// If the overlay should have the same width as the trigger
bool? get alignOverlayWithButtonSize => _alignOverlayWithButtonSize;
bool? _alignOverlayWithButtonSize;
set alignOverlayWithButtonSize(bool? value) {
if (_alignOverlayWithButtonSize != value) {
_alignOverlayWithButtonSize = value;
markNeedsLayout();
}
}
/// The exact width of the overlay
double? get overlayWidth => _overlayWidth;
double? _overlayWidth;
set overlayWidth(double? value) {
if (_overlayWidth != value) {
_overlayWidth = value;
markNeedsLayout();
}
}
/// The globalKey of the target, allowing to read the size of the render object
GlobalKey get targetKey => _targetKey;
GlobalKey _targetKey;
set targetKey(GlobalKey value) {
if (_targetKey != value) {
_targetKey = value;
markNeedsLayout();
}
}
/// The size and position of the [Overlay], hosting the follower
Rect get overlayRect => _overlayRect;
Rect _overlayRect;
set overlayRect(Rect value) {
if (_overlayRect != value) {
_overlayRect = value;
markNeedsLayout();
}
}
@override
bool get alwaysNeedsCompositing => true;
@override
void setupParentData(covariant RenderObject child) {
if (child.parentData is! ParentData) {
child.parentData = BoxParentData();
}
}
@override
void performLayout() {
if (_link.leader == null) {
// We don't know where to put the overlay on the screen.
return;
}
BoxConstraints childConstraints = constraints;
/// calculate sizes
size = childConstraints.biggest;
if (_alignOverlayWithButtonSize == true) {
childConstraints = childConstraints.loosen().copyWith(
maxWidth: _link.leaderSize?.width ?? constraints.biggest.width,
minWidth: _link.leaderSize?.width,
);
child!.layout(childConstraints, parentUsesSize: true);
} else if (_overlayWidth != null) {
childConstraints = childConstraints.copyWith(maxWidth: _overlayWidth, minWidth: 0).loosen();
size = childConstraints.biggest;
child!.layout(childConstraints, parentUsesSize: true);
} else {
size = (child?..layout(constraints, parentUsesSize: true))?.size ?? computeSizeForNoChild(constraints);
}
}
@override
void paint(PaintingContext context, Offset offset) {
final Size? leaderSize = _link.leaderSize;
final useLeaderSize = leaderSize == null;
final anchorSize = _leaderAnchor.alongSize(leaderSize!);
final followerSize = _followerAnchor.alongSize(size);
final paintOffset = this.offset;
// Follower offset based on it's size and the anchor
final Offset effectiveLinkedOffset = useLeaderSize ? paintOffset : anchorSize - followerSize + paintOffset;
Offset onScreenOffset = Offset.zero;
if (_alwaysOnScreen == true) {
final box = _targetKey.currentContext?.findRenderObject() as RenderBox?;
final targetPosition = box!.localToGlobal(Offset.zero);
final Offset startOfLeader = targetPosition;
if (_followerAnchor == Alignment.topLeft ||
_followerAnchor == Alignment.topCenter ||
_followerAnchor == Alignment.topRight) {
// check if overlay overflows the bottom
final followerHeight = size.height;
final followerBottomEdge = startOfLeader.dy + followerHeight;
final overlayBottomEdge = overlayRect.bottom;
final bottomOverflow = followerBottomEdge - overlayBottomEdge;
if (bottomOverflow > 0) {
onScreenOffset = onScreenOffset.translate(0, -bottomOverflow);
}
}
if (_followerAnchor == Alignment.topLeft ||
_followerAnchor == Alignment.centerLeft ||
_followerAnchor == Alignment.bottomLeft) {
// check if overlay overflows the right
final followerWidth = size.width;
final followerRightEdge = startOfLeader.dx + followerWidth;
final rightOverflow = followerRightEdge - overlayRect.right;
if (rightOverflow > 0) {
onScreenOffset = onScreenOffset.translate(-rightOverflow, 0);
}
}
if (_followerAnchor == Alignment.bottomRight ||
_followerAnchor == Alignment.bottomCenter ||
_followerAnchor == Alignment.bottomLeft) {
// check if overlay overflows the top
final followerHeight = size.height;
final followerTopEdge = startOfLeader.dy - followerHeight;
final topOverflow = followerTopEdge - overlayRect.top;
if (topOverflow < 0) {
onScreenOffset = onScreenOffset.translate(0, -topOverflow);
}
}
if (_followerAnchor == Alignment.topRight ||
_followerAnchor == Alignment.centerRight ||
_followerAnchor == Alignment.bottomRight) {
// check if overlay overflows the left
final followerWidth = size.width;
final followerLeftEdge = startOfLeader.dx - followerWidth;
final leftOverflow = followerLeftEdge - overlayRect.left;
if (leftOverflow < 0) {
onScreenOffset = onScreenOffset.translate(-leftOverflow, 0);
}
}
}
layer = FollowerLayer(
link: _link,
showWhenUnlinked: false,
linkedOffset: effectiveLinkedOffset + onScreenOffset,
unlinkedOffset: offset,
);
context.pushLayer(
layer!,
super.paint,
Offset.zero,
childPaintBounds: const Rect.fromLTRB(
// We don't know where we'll end up, so we have no idea what our cull rect should be.
double.negativeInfinity,
double.negativeInfinity,
double.infinity,
double.infinity,
),
);
}
/// The layer we created when we were last painted.
@override
FollowerLayer? get layer => super.layer as FollowerLayer?;
/// Return the transform that was used in the last composition phase, if any.
///
/// If the [FollowerLayer] has not yet been created, was never composited, or
/// was unable to determine the transform (see
/// [FollowerLayer.getLastTransform]), this returns the identity matrix (see
/// [Matrix4.identity].
Matrix4 getCurrentTransform() {
return layer?.getLastTransform() ?? Matrix4.identity();
}
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
// Disables the hit testing if this render object is hidden.
// if (_link.leader == null && !showWhenUnlinked) {
if (_link.leader == null) {
return false;
}
// RenderFollowerLayer objects don't check if they are
// themselves hit, because it's confusing to think about
// how the untransformed size and the child's transformed
// position interact.
return hitTestChildren(result, position: position);
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
return result.addWithPaintTransform(
transform: getCurrentTransform(),
position: position,
hitTest: (BoxHitTestResult result, Offset position) {
return super.hitTestChildren(result, position: position);
},
);
}
@override
void applyPaintTransform(RenderBox child, Matrix4 transform) {
transform.multiply(getCurrentTransform());
}
}
class _OverlayRevealAnimation extends StatefulWidget {
const _OverlayRevealAnimation({
required this.child,
required this.show,
required this.controller,
required this.animationOffset,
});
final Widget child;
final bool show;
final AnimationController controller;
final Offset animationOffset;
@override
State<_OverlayRevealAnimation> createState() => _OverlayRevealAnimationState();
}
class _OverlayRevealAnimationState extends State<_OverlayRevealAnimation> {
@override
Widget build(BuildContext context) {
return MatrixTransition(
onTransform: (value) {
final offset = widget.animationOffset * (1 - widget.controller.value);
return Matrix4.identity()..translate(offset.dx, offset.dy);
},
animation: widget.controller,
child: FadeTransition(
opacity: Tween<double>(begin: 0.1, end: 1).animate(
CurvedAnimation(parent: widget.controller, curve: Curves.easeOutCubic, reverseCurve: Curves.easeOutCubic),
),
child: widget.child,
),
);
}
}
/// A controller for the [AdvancedOverlay]. Basically a wrapper around the [OverlayPortalController] but with a state.
class AdvancedOverlayController extends ChangeNotifier {
final _portalController = OverlayPortalController();
FutureOr<void> Function()? _onClose;
FutureOr<void> Function()? _onOpen;
/// If the overlay is showing. See [OverlayPortalController.isShowing]
bool get isShowing => _portalController.isShowing;
/// see [OverlayPortalController.show]
Future<void> show() async {
_portalController.show();
await _onOpen?.call();
notifyListeners();
}
/// see [OverlayPortalController.hide]
Future<void> hide() async {
_portalController.hide();
await _onClose?.call();
notifyListeners();
}
/// see [OverlayPortalController.toggle]
Future<void> toggle() async {
if (isShowing) {
await hide();
} else {
await show();
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment