Skip to content

Commit

Permalink
Merge pull request #35 from solid-software/fix/popup-suggestions-over…
Browse files Browse the repository at this point in the history
…flow

Fixed popup overflow by suggestions. Fixes #32
  • Loading branch information
mitryp committed May 29, 2023
2 parents 9a1c678 + 35138c8 commit c546aaa
Show file tree
Hide file tree
Showing 3 changed files with 128 additions and 95 deletions.
11 changes: 6 additions & 5 deletions lib/domain/typedefs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import 'package:languagetool_textfield/domain/mistake.dart';
import 'package:languagetool_textfield/utils/popup_overlay_renderer.dart';

/// Callback used to build popup body
typedef MistakeBuilderCallback = Widget Function(
PopupOverlayRenderer popupRenderer,
Mistake mistake,
ColoredTextEditingController controller,
);
typedef MistakeBuilderCallback = Widget Function({
required PopupOverlayRenderer popupRenderer,
required Mistake mistake,
required ColoredTextEditingController controller,
required Offset mistakePosition,
});

/// Function called after mistake was clicked
typedef ShowPopupCallback = void Function(
Expand Down
165 changes: 113 additions & 52 deletions lib/utils/mistake_popup.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:languagetool_textfield/domain/typedefs.dart';
import 'package:languagetool_textfield/languagetool_textfield.dart';
Expand All @@ -21,29 +23,27 @@ class MistakePopup {
Offset popupPosition,
ColoredTextEditingController controller,
) {
final MistakeBuilderCallback builder =
mistakeBuilder ?? LanguageToolMistakePopup.new;

popupRenderer.render(
context,
position: popupPosition,
popupBuilder: (context) =>
mistakeBuilder?.call(popupRenderer, mistake, controller) ??
LanguageToolMistakePopup(
popupRenderer: popupRenderer,
mistake: mistake,
controller: controller,
),
popupBuilder: (context) => builder.call(
popupRenderer: popupRenderer,
mistake: mistake,
controller: controller,
mistakePosition: popupPosition,
),
);
}
}

/// Default mistake window that looks similar to LanguageTool popup
class LanguageToolMistakePopup extends StatelessWidget {
/// [LanguageToolMistakePopup] constructor
const LanguageToolMistakePopup({
super.key,
required this.popupRenderer,
required this.mistake,
required this.controller,
});
static const double _defaultVerticalMargin = 25.0;
static const double _defaultHorizontalMargin = 10.0;
static const double _defaultMaxWidth = 250.0;

/// Renderer used to display this window.
final PopupOverlayRenderer popupRenderer;
Expand All @@ -54,60 +54,121 @@ class LanguageToolMistakePopup extends StatelessWidget {
/// Controller of the text where mistake was found
final ColoredTextEditingController controller;

/// An on-screen position of the mistake
final Offset mistakePosition;

/// A maximum width of the popup.
/// If infinity, the popup will use all the available horizontal space.
final double maxWidth;

/// A maximum height of the popup.
/// If infinity, the popup will use all the available height between the
/// [mistakePosition] and the furthest border of the layout constraints.
final double maxHeight;

/// Horizontal popup margin.
final double horizontalMargin;

/// Vertical popup margin.
final double verticalMargin;

/// [LanguageToolMistakePopup] constructor
const LanguageToolMistakePopup({
super.key,
required this.popupRenderer,
required this.mistake,
required this.controller,
required this.mistakePosition,
this.maxWidth = _defaultMaxWidth,
this.maxHeight = double.infinity,
this.horizontalMargin = _defaultHorizontalMargin,
this.verticalMargin = _defaultVerticalMargin,
});

@override
Widget build(BuildContext context) {
const _borderRadius = 10.0;
const _mistakeNameFontSize = 13.0;
const _mistakeMessageFontSize = 15.0;
const _replacementButtonsSpacing = 10.0;

return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
boxShadow: const [BoxShadow(color: Colors.grey, blurRadius: 20)],
color: Colors.white,
borderRadius: BorderRadius.circular(_borderRadius),
const padding = 10.0;

final availableSpace = _calculateAvailableSpace(context);

return ConstrainedBox(
constraints: BoxConstraints(
maxWidth: maxWidth,
maxHeight: availableSpace,
),
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,
child: Container(
margin: EdgeInsets.symmetric(
horizontal: horizontalMargin,
vertical: verticalMargin,
),
padding: const EdgeInsets.all(padding),
decoration: BoxDecoration(
boxShadow: const [BoxShadow(color: Colors.grey, blurRadius: 20)],
color: Colors.white,
borderRadius: BorderRadius.circular(_borderRadius),
),
child: CustomScrollView(
shrinkWrap: true,
slivers: [
SliverToBoxAdapter(
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: padding),

// mistake message
Text(
mistake.message,
style: const TextStyle(fontSize: _mistakeMessageFontSize),
),
const SizedBox(height: padding),
],
),
),
),
const SizedBox(height: 10),

// mistake message
Text(
mistake.message,
style: const TextStyle(fontSize: _mistakeMessageFontSize),
),
const SizedBox(height: 10),

// replacements
Wrap(
spacing: _replacementButtonsSpacing,
direction: Axis.horizontal,
children: mistake.replacements
.map(
(replacement) => ElevatedButton(
SliverList.builder(
itemCount: mistake.replacements.length,
itemBuilder: (context, index) {
final replacement = mistake.replacements[index];

return Padding(
padding: const EdgeInsets.all(_replacementButtonsSpacing / 2),
child: ElevatedButton(
onPressed: () {
controller.replaceMistake(mistake, replacement);
popupRenderer.dismiss();
},
child: Text(replacement),
),
)
.toList(growable: false),
),
],
);
},
),
],
),
),
);
}

double _calculateAvailableSpace(BuildContext context) {
final mediaQuery = MediaQuery.of(context);

final availableSpaceBottom = mediaQuery.size.height - mistakePosition.dy;
final availableSpaceTop = mistakePosition.dy;

return min(max(availableSpaceBottom, availableSpaceTop), maxHeight);
}
}
47 changes: 9 additions & 38 deletions lib/utils/popup_overlay_renderer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,12 @@ import 'dart:math';

import 'package:flutter/material.dart';

/// defaultPopupWidth
const defaultPopupWidth = 250.0;

/// defaultHorizontalPadding
const defaultHorizontalPadding = 10.0;

/// defaultVerticalMargin
const defaultVerticalPadding = 30.0;

/// Renderer used to show popup window overlay
class PopupOverlayRenderer {
OverlayEntry? _overlayEntry;

/// Max width of popup window
final double width;

/// [PopupOverlayRenderer] constructor
PopupOverlayRenderer({this.width = defaultPopupWidth});
PopupOverlayRenderer();

/// Render overlay entry on the screen with dismiss logic
OverlayEntry render(
Expand All @@ -38,10 +26,7 @@ class PopupOverlayRenderer {
children: [
CustomSingleChildLayout(
delegate: PopupOverlayLayoutDelegate(position),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: width),
child: popupBuilder(context),
),
child: popupBuilder(context),
),
],
),
Expand All @@ -63,25 +48,11 @@ class PopupOverlayRenderer {

/// Class that calculates where to place popup window on the screen
class PopupOverlayLayoutDelegate extends SingleChildLayoutDelegate {
/// max width of popup window
final double width;

/// desired position of popup window
final Offset position;

/// padding of screen for popup window
final double horizontalPadding;

/// vertical distance to offset from [position]
final double verticalPadding;

/// [PopupOverlayLayoutDelegate] constructor
const PopupOverlayLayoutDelegate(
this.position, {
this.width = defaultPopupWidth,
this.horizontalPadding = defaultHorizontalPadding,
this.verticalPadding = defaultVerticalPadding,
});
const PopupOverlayLayoutDelegate(this.position);

@override
Offset getPositionForChild(Size size, Size childSize) {
Expand All @@ -96,21 +67,21 @@ class PopupOverlayLayoutDelegate extends SingleChildLayoutDelegate {
);
double dx = _popupRect.left;
// limiting X offset
dx = max(horizontalPadding, dx);
dx = max(0, dx);
final rightBorderPosition = dx + childSize.width;
final rightScreenBorderOverflow = rightBorderPosition - size.width;
if (rightScreenBorderOverflow >= 0) {
dx -= rightScreenBorderOverflow + horizontalPadding;
if (rightScreenBorderOverflow > 0) {
dx -= rightScreenBorderOverflow;
}

// under the desired position
double dy = position.dy + verticalPadding;
double dy = max(0, position.dy);
final bottomBorderPosition = dy + childSize.height;
final bottomScreenBorderOverflow = bottomBorderPosition - size.height;
// if not enough space underneath, rendering above the desired position
if (bottomScreenBorderOverflow >= 0) {
if (bottomScreenBorderOverflow > 0) {
final newBottomBorderPosition = position.dy - childSize.height;
dy = newBottomBorderPosition - verticalPadding;
dy = newBottomBorderPosition;
}

return Offset(dx, dy);
Expand Down

0 comments on commit c546aaa

Please sign in to comment.