From 4f97476ad3a44ff6e7b0324b2b6a105d4f6fccdf Mon Sep 17 00:00:00 2001 From: Amir Panahandeh Date: Sun, 28 Apr 2024 10:48:12 +0330 Subject: [PATCH] Fix toolbar selector positioning (#329) --- .../lib/src/widgets/editor_toolbar.dart | 226 ++++++++++++++---- .../test/widgets/editor_toolbar_test.dart | 68 +++++- 2 files changed, 235 insertions(+), 59 deletions(-) diff --git a/packages/fleather/lib/src/widgets/editor_toolbar.dart b/packages/fleather/lib/src/widgets/editor_toolbar.dart index 79f177c2..e1bb1a9e 100644 --- a/packages/fleather/lib/src/widgets/editor_toolbar.dart +++ b/packages/fleather/lib/src/widgets/editor_toolbar.dart @@ -406,12 +406,10 @@ class _ColorButtonState extends State { }; final maxWidth = isMobile ? 200.0 : 100.0; - final renderBox = context.findRenderObject() as RenderBox; - final offset = renderBox.localToGlobal(Offset.zero) + Offset(0, buttonSize); - final completer = Completer(); final selector = Material( + key: const Key('color_selector'), elevation: 4.0, color: Theme.of(context).canvasColor, child: Container( @@ -421,19 +419,7 @@ class _ColorButtonState extends State { onSelectedColor: completer.complete)), ); - return SelectorScope.of(context).pushSelector( - Stack( - children: [ - Positioned( - key: const Key('color_palette'), - top: offset.dy, - left: offset.dx, - child: selector, - ) - ], - ), - completer, - ); + return SelectorScope.showSelector(context, selector, completer); } @override @@ -648,32 +634,19 @@ class _SelectHeadingButtonState extends State { } Future?> _selectHeading() async { - final renderBox = context.findRenderObject() as RenderBox; - final offset = - renderBox.localToGlobal(Offset.zero) + Offset(0, buttonHeight); final themeData = FleatherTheme.of(context)!; final completer = Completer?>(); final selector = Material( + key: const Key('heading_selector'), elevation: 4.0, borderRadius: BorderRadius.circular(2), color: Theme.of(context).canvasColor, child: _HeadingList(theme: themeData, onSelected: completer.complete), ); - return SelectorScope.of(context).pushSelector( - Stack( - children: [ - Positioned( - top: offset.dy, - left: offset.dx, - child: selector, - ) - ], - ), - completer, - ); + return SelectorScope.showSelector(context, selector, completer); } } @@ -1228,12 +1201,19 @@ class FLIconButton extends StatelessWidget { } class SelectorScope extends StatefulWidget { + final Widget child; + const SelectorScope({super.key, required this.child}); static SelectorScopeState of(BuildContext context) => context.findAncestorStateOfType()!; - final Widget child; + /// The [context] should belong to the presenter widget. + static Future showSelector( + BuildContext context, Widget selector, Completer completer, + {bool rootOverlay = false}) => + SelectorScope.of(context) + .showSelector(context, selector, completer, rootOverlay: rootOverlay); @override State createState() => SelectorScopeState(); @@ -1242,42 +1222,65 @@ class SelectorScope extends StatefulWidget { class SelectorScopeState extends State { OverlayEntry? _overlayEntry; - Future pushSelector(Widget selector, Completer completer) { + /// The [context] should belong to the presenter widget. + Future showSelector( + BuildContext context, Widget selector, Completer completer, + {bool rootOverlay = false}) { _overlayEntry?.remove(); - final overlayEntry = OverlayEntry( - builder: (context) => TapRegion( - child: selector, - onTapOutside: (_) => completer.complete(null), + + final overlay = Overlay.of(context, rootOverlay: rootOverlay); + + final RenderBox presenter = context.findRenderObject() as RenderBox; + final RenderBox overlayBox = + overlay.context.findRenderObject() as RenderBox; + final offset = Offset(0.0, presenter.size.height); + final position = RelativeRect.fromSize( + Rect.fromPoints( + presenter.localToGlobal(offset, ancestor: overlayBox), + presenter.localToGlobal( + presenter.size.bottomRight(Offset.zero) + offset, + ancestor: overlayBox, + ), ), + overlayBox.size, ); - overlayEntry.addListener(() { - if (!overlayEntry.mounted && !completer.isCompleted) { - completer.complete(null); - } - }); - completer.future.whenComplete(() { - overlayEntry.remove(); - if (_overlayEntry == overlayEntry) { + + final mediaQueryData = MediaQuery.of(context); + final textDirection = Directionality.of(context); + + _overlayEntry = OverlayEntry( + builder: (context) => CustomSingleChildLayout( + delegate: _SelectorLayout( + position, + textDirection, + mediaQueryData.padding, + DisplayFeatureSubScreen.avoidBounds(mediaQueryData).toSet(), + ), + child: TapRegion( + child: selector, + onTapOutside: (_) => completer.complete(null), + ), + ), + ); + _overlayEntry?.addListener(() { + if (_overlayEntry?.mounted != true && !completer.isCompleted) { + _overlayEntry?.dispose(); _overlayEntry = null; + completer.complete(null); } }); - _overlayEntry = overlayEntry; - Overlay.of(context).insert(_overlayEntry!); + completer.future.whenComplete(removeEntry); + overlay.insert(_overlayEntry!); return completer.future; } void removeEntry() { if (_overlayEntry == null) return; _overlayEntry!.remove(); + _overlayEntry!.dispose(); _overlayEntry = null; } - @override - void didUpdateWidget(covariant SelectorScope oldWidget) { - super.didUpdateWidget(oldWidget); - removeEntry(); - } - @override void dispose() { super.dispose(); @@ -1289,3 +1292,120 @@ class SelectorScopeState extends State { return widget.child; } } + +const _selectorScreenPadding = 8.0; + +// This is a clone of _PopupMenuRouteLayout from Flutter with some modifications +class _SelectorLayout extends SingleChildLayoutDelegate { + _SelectorLayout( + this.position, + this.textDirection, + this.padding, + this.avoidBounds, + ); + + // Rectangle of underlying button, relative to the overlay's dimensions. + final RelativeRect position; + + // Whether to prefer going to the left or to the right. + final TextDirection textDirection; + + // The padding of unsafe area. + EdgeInsets padding; + + // List of rectangles that we should avoid overlapping. Unusable screen area. + final Set avoidBounds; + + // We put the child wherever position specifies, so long as it will fit within + // the specified parent size padded (inset) by [_selectorScreenPadding]. + // If necessary, we adjust the child's position so that it fits. + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) { + // The menu can be at most the size of the overlay minus 8.0 pixels in each + // direction. + return BoxConstraints.loose(constraints.biggest).deflate( + const EdgeInsets.all(_selectorScreenPadding) + padding, + ); + } + + @override + Offset getPositionForChild(Size size, Size childSize) { + // size: The size of the overlay. + // childSize: The size of the menu, when fully open, as determined by + // getConstraintsForChild. + + final double y = position.top; + + // Find the ideal horizontal position. + double x; + if (position.right > childSize.width) { + // Menu button is closer to the left edge, so grow to the right, aligned to the left edge. + x = position.left; + } else if (position.left > childSize.width) { + // Menu button is closer to the right edge, so grow to the left, aligned to the right edge. + x = size.width - position.right - childSize.width; + } else { + switch (textDirection) { + case TextDirection.rtl: + x = size.width - position.right - childSize.width; + case TextDirection.ltr: + x = position.left; + } + } + + final Offset wantedPosition = Offset(x, y); + final Offset originCenter = position.toRect(Offset.zero & size).center; + final Iterable subScreens = + DisplayFeatureSubScreen.subScreensInBounds( + Offset.zero & size, avoidBounds); + final Rect subScreen = _closestScreen(subScreens, originCenter); + return _fitInsideScreen(subScreen, childSize, wantedPosition); + } + + Rect _closestScreen(Iterable screens, Offset point) { + Rect closest = screens.first; + for (final Rect screen in screens) { + if ((screen.center - point).distance < + (closest.center - point).distance) { + closest = screen; + } + } + return closest; + } + + Offset _fitInsideScreen(Rect screen, Size childSize, Offset wantedPosition) { + double x = wantedPosition.dx; + double y = wantedPosition.dy; + // Avoid going outside an area defined as the rectangle 8.0 pixels from the + // edge of the screen in every direction. + if (x < screen.left + _selectorScreenPadding + padding.left) { + x = screen.left + _selectorScreenPadding + padding.left; + } else if (x + childSize.width > + screen.right - _selectorScreenPadding - padding.right) { + x = screen.right - + childSize.width - + _selectorScreenPadding - + padding.right; + } + if (y < screen.top + _selectorScreenPadding + padding.top) { + y = _selectorScreenPadding + padding.top; + } else if (y + childSize.height > + screen.bottom - _selectorScreenPadding - padding.bottom) { + y = screen.bottom - + childSize.height - + _selectorScreenPadding - + padding.bottom; + } + + return Offset(x, y); + } + + @override + bool shouldRelayout(_SelectorLayout oldDelegate) { + return position != oldDelegate.position || + textDirection != oldDelegate.textDirection || + padding != oldDelegate.padding || + !setEquals(avoidBounds, oldDelegate.avoidBounds); + } +} diff --git a/packages/fleather/test/widgets/editor_toolbar_test.dart b/packages/fleather/test/widgets/editor_toolbar_test.dart index 2cb6fe80..ac8bbfba 100644 --- a/packages/fleather/test/widgets/editor_toolbar_test.dart +++ b/packages/fleather/test/widgets/editor_toolbar_test.dart @@ -322,13 +322,13 @@ void main() { await tester.tap(backgroundButton); await tester.pumpAndSettle(); final colorElement = find.descendant( - of: find.byKey(const Key('color_palette')), + of: find.byKey(const Key('color_selector')), matching: find.byType(RawMaterialButton)); expect(colorElement, findsNWidgets(17)); await tester.tap(find .descendant( - of: find.byKey(const Key('color_palette')), + of: find.byKey(const Key('color_selector')), matching: find.byType(RawMaterialButton)) .last); await tester.pumpAndSettle(throttleDuration); @@ -350,7 +350,7 @@ void main() { await tester.tap(backgroundButton); await tester.pumpAndSettle(); final colorElement = find.descendant( - of: find.byKey(const Key('color_palette')), + of: find.byKey(const Key('color_selector')), matching: find.byType(RawMaterialButton)); expect( colorElement, @@ -359,7 +359,7 @@ void main() { await tester.tap(find .descendant( - of: find.byKey(const Key('color_palette')), + of: find.byKey(const Key('color_selector')), matching: find.byType(RawMaterialButton)) .last); await tester.pumpAndSettle(throttleDuration); @@ -412,13 +412,69 @@ void main() { await tester.pump(); await tester.tap(find.byIcon(Icons.mode_edit_outline_outlined)); await tester.pump(); - expect(find.byKey(const Key('color_palette')), findsOneWidget); + expect(find.byKey(const Key('color_selector')), findsOneWidget); await tester.tap(find.byType(TextButton)); await tester.pump(); - expect(find.byKey(const Key('color_palette')), findsNothing); + expect(find.byKey(const Key('color_selector')), findsNothing); await tester.pumpAndSettle(throttleDuration); }); }); + + group('SelectorScope', () { + testWidgets('Correctly places the selector in a visible area of screen', + (WidgetTester tester) async { + const padding = EdgeInsets.all(32); + final controller = FleatherController(); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + appBar: AppBar(), + body: Column( + children: [ + FleatherToolbar.basic(controller: controller), + ], + ), + ), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(padding: padding), + child: child!, + ), + )); + final validRect = tester.getRect(find.byType(Scaffold)).deflate(32); + await tester.tap(find.byType(SelectHeadingButton)); + await tester.pump(); + expect( + validRect.expandToInclude( + tester.getRect(find.byKey(const Key('heading_selector')))), + equals(validRect), + ); + await tester.pumpWidget(MaterialApp( + home: Scaffold( + appBar: AppBar(), + body: Column( + children: [ + const Expanded(child: SizedBox()), + FleatherToolbar.basic(controller: controller), + ], + ), + ), + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(padding: padding), + child: child!, + ), + )); + await tester.tap(find.byType(SelectHeadingButton)); + await tester.pump(); + expect( + validRect.expandToInclude( + tester.getRect(find.byKey(const Key('heading_selector')))), + equals(validRect), + ); + expect( + tester.getRect(find.byKey(const Key('heading_selector'))).bottom, + tester.getRect(find.byType(Scaffold)).bottom - padding.bottom - 8, + ); + }); + }); } Future performToggle(WidgetTester tester, FleatherController controller,