From 04ceb893d7015fd9a2b2e0fd3f37dc807d5a2dc4 Mon Sep 17 00:00:00 2001 From: d-rooX Date: Tue, 23 May 2023 21:27:24 +0300 Subject: [PATCH] flexible popup positioning --- example/lib/app.dart | 5 +- lib/domain/typedefs.dart | 2 +- lib/languagetool_textfield.dart | 1 + lib/utils/mistake_popup.dart | 167 +++++++------------------- lib/utils/popup_overlay_renderer.dart | 110 +++++++++++++++++ 5 files changed, 155 insertions(+), 130 deletions(-) create mode 100644 lib/utils/popup_overlay_renderer.dart diff --git a/example/lib/app.dart b/example/lib/app.dart index 8ca5ca8..1813941 100644 --- a/example/lib/app.dart +++ b/example/lib/app.dart @@ -27,14 +27,11 @@ class _AppState extends State { @override Widget build(BuildContext context) { - const _popupWidth = 250.0; - const _popupHeight = 150.0; - return Material( // column here for test purposes; // change mainAxisAlignment to test popup behaviour child: Column( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, children: [ LanguageToolTextField( style: const TextStyle(), diff --git a/lib/domain/typedefs.dart b/lib/domain/typedefs.dart index 1994a32..353381b 100644 --- a/lib/domain/typedefs.dart +++ b/lib/domain/typedefs.dart @@ -1,7 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:languagetool_textfield/core/controllers/colored_text_editing_controller.dart'; import 'package:languagetool_textfield/domain/mistake.dart'; -import 'package:languagetool_textfield/utils/mistake_popup.dart'; +import 'package:languagetool_textfield/utils/popup_overlay_renderer.dart'; /// Callback used to build popup body typedef MistakeBuilderCallback = Widget Function( diff --git a/lib/languagetool_textfield.dart b/lib/languagetool_textfield.dart index 700067e..46d764d 100644 --- a/lib/languagetool_textfield.dart +++ b/lib/languagetool_textfield.dart @@ -11,3 +11,4 @@ export 'implementations/lang_tool_service.dart'; export 'implementations/throttling_lang_tool_service.dart'; export 'presentation/language_tool_text_field.dart'; export 'utils/mistake_popup.dart'; +export 'utils/popup_overlay_renderer.dart'; diff --git a/lib/utils/mistake_popup.dart b/lib/utils/mistake_popup.dart index a6619ce..966f4eb 100644 --- a/lib/utils/mistake_popup.dart +++ b/lib/utils/mistake_popup.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:languagetool_textfield/domain/typedefs.dart'; import 'package:languagetool_textfield/languagetool_textfield.dart'; @@ -40,92 +38,6 @@ class MistakePopup { } } -/// Renderer used to show popup window overlay -class PopupOverlayRenderer { - OverlayEntry? _overlayEntry; - - /// [PopupOverlayRenderer] constructor - PopupOverlayRenderer(); - - /// Render overlay entry on the screen with dismiss logic - OverlayEntry render( - BuildContext context, { - required Offset position, - required WidgetBuilder popupBuilder, - }) { - final Offset _popupPosition = _calculatePosition(context, position); - - final _createdEntry = OverlayEntry( - builder: (context) => GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: dismiss, - child: Material( - color: Colors.transparent, - child: Stack( - children: [ - Positioned( - left: _popupPosition.dx, - top: _popupPosition.dy, - child: popupBuilder(context), - ), - ], - ), - ), - ), - ); - - Overlay.of(context).insert(_createdEntry); - _overlayEntry = _createdEntry; - - return _createdEntry; - } - - /// Function that bounds given offset to screen sizes - Offset _calculatePosition(BuildContext context, Offset position) { - final _screenSize = MediaQuery.of(context).size; - - // todo find window size somehow - const width = 250.0; - const height = 250.0; - - final _popupRect = Rect.fromCenter( - center: position, - width: width, - height: height, - ); - - const _screenBorderPadding = 10.0; - - double dx = _popupRect.left; - // limiting X offset - dx = max(_screenBorderPadding, dx); - final rightBorderPosition = dx + width; - final rightScreenBorderOverflow = rightBorderPosition - _screenSize.width; - if (rightScreenBorderOverflow >= 0) { - dx -= rightScreenBorderOverflow + _screenBorderPadding; - } - - const _verticalMargin = 30.0; - // under the desired position - double dy = position.dy + _verticalMargin; - final bottomBorderPosition = dy + height; - final bottomScreenBorderOverflow = - bottomBorderPosition - _screenSize.height; - // if not enough space underneath, rendering above the desired position - if (bottomScreenBorderOverflow >= 0) { - final newBottomBorderPosition = position.dy - height; - dy = newBottomBorderPosition - _verticalMargin; - } - - return Offset(dx, dy); - } - - /// Remove popup - void dismiss() { - _overlayEntry?.remove(); - } -} - /// Default mistake window that looks similar to LanguageTool popup class LanguageToolMistakePopup extends StatelessWidget { /// [LanguageToolMistakePopup] constructor @@ -150,53 +62,58 @@ class LanguageToolMistakePopup extends StatelessWidget { const _borderRadius = 10.0; const _mistakeNameFontSize = 13.0; const _mistakeMessageFontSize = 15.0; + const _replacementButtonsSpacing = 10.0; return Container( + width: popupRenderer.maxWidth, padding: const EdgeInsets.all(10), decoration: BoxDecoration( boxShadow: const [BoxShadow(color: Colors.grey, blurRadius: 20)], color: Colors.white, borderRadius: BorderRadius.circular(_borderRadius), ), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 250), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - mistake.type.name, - style: TextStyle( - color: Colors.grey.shade700, - fontSize: _mistakeNameFontSize, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 10), - Text( - mistake.message, - style: const TextStyle( - // fontStyle: FontStyle.italic, - fontSize: _mistakeMessageFontSize, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // mistake type + Text( + mistake.type.name, + style: TextStyle( + color: Colors.grey.shade700, + fontSize: _mistakeNameFontSize, + fontWeight: FontWeight.w500, ), - const SizedBox(height: 10), - Wrap( - spacing: 10, - direction: Axis.horizontal, - children: mistake.replacements - .map( - (replacement) => ElevatedButton( - onPressed: () { - controller.replaceMistake(mistake, replacement); - popupRenderer.dismiss(); - }, - child: Text(replacement), - ), - ) - .toList(growable: false), + ), + const SizedBox(height: 10), + + // mistake message + Text( + mistake.message, + style: const TextStyle( + // fontStyle: FontStyle.italic, + fontSize: _mistakeMessageFontSize, ), - ], - ), + ), + const SizedBox(height: 10), + + // replacements + Wrap( + spacing: _replacementButtonsSpacing, + direction: Axis.horizontal, + children: mistake.replacements + .map( + (replacement) => ElevatedButton( + onPressed: () { + controller.replaceMistake(mistake, replacement); + popupRenderer.dismiss(); + }, + child: Text(replacement), + ), + ) + .toList(growable: false), + ), + ], ), ); } diff --git a/lib/utils/popup_overlay_renderer.dart b/lib/utils/popup_overlay_renderer.dart new file mode 100644 index 0000000..912781a --- /dev/null +++ b/lib/utils/popup_overlay_renderer.dart @@ -0,0 +1,110 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +/// +const defaultPopupWidth = 250.0; + +/// Renderer used to show popup window overlay +class PopupOverlayRenderer { + OverlayEntry? _overlayEntry; + + /// Max width of popup window + final double maxWidth; + + /// [PopupOverlayRenderer] constructor + PopupOverlayRenderer({this.maxWidth = defaultPopupWidth}); + + /// Render overlay entry on the screen with dismiss logic + OverlayEntry render( + BuildContext context, { + required Offset position, + required WidgetBuilder popupBuilder, + }) { + // final Offset _popupPosition = _calculatePosition(context, position); + + final _createdEntry = OverlayEntry( + builder: (context) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: dismiss, + child: Material( + color: Colors.transparent, + child: Stack( + children: [ + Positioned( + child: CustomSingleChildLayout( + delegate: PopupOverlayLayoutDelegate(maxWidth, position), + child: popupBuilder(context), + ), + ) + ], + ), + ), + ), + ); + + Overlay.of(context).insert(_createdEntry); + _overlayEntry = _createdEntry; + + return _createdEntry; + } + + /// Remove popup + void dismiss() { + _overlayEntry?.remove(); + } +} + +/// Class that calculates where to place popup window on the screen +class PopupOverlayLayoutDelegate extends SingleChildLayoutDelegate { + /// max width of popup window + final double maxWidth; + + /// desired position of popup window + final Offset position; + + /// [PopupOverlayLayoutDelegate] constructor + const PopupOverlayLayoutDelegate(this.maxWidth, this.position); + + @override + Offset getPositionForChild(Size size, Size childSize) { + return _calculatePosition(size, position, childSize); + } + + Offset _calculatePosition(Size size, Offset position, Size childSize) { + final _popupRect = Rect.fromCenter( + center: position, + width: childSize.width, + height: childSize.height, + ); + const _screenBorderPadding = 10.0; + print("size: $size"); + + double dx = _popupRect.left; + // limiting X offset + dx = max(_screenBorderPadding, dx); + final rightBorderPosition = dx + childSize.width; + final rightScreenBorderOverflow = rightBorderPosition - size.width; + if (rightScreenBorderOverflow >= 0) { + dx -= rightScreenBorderOverflow + _screenBorderPadding; + } + + const _verticalMargin = 30.0; + // under the desired position + double dy = position.dy + _verticalMargin; + final bottomBorderPosition = dy + childSize.height; + final bottomScreenBorderOverflow = bottomBorderPosition - size.height; + // if not enough space underneath, rendering above the desired position + if (bottomScreenBorderOverflow >= 0) { + final newBottomBorderPosition = position.dy - childSize.height; + dy = newBottomBorderPosition - _verticalMargin; + } + + return Offset(dx, dy); + } + + @override + bool shouldRelayout(covariant PopupOverlayLayoutDelegate oldDelegate) { + return false; + } +}