Skip to content

Commit

Permalink
[Android] Add spell check suggestions toolbar (#114460)
Browse files Browse the repository at this point in the history
* Add spell check suggestions toolbar

* Fix test and move menu

* Cleanup

* Cleanup and fix bug

* More cleanup

* Make height dynamic and use localized delete

* Begin adding tests

* Create var checking for results

* Add tests

* Fix analyze (sorta)

* Add back hideToolbar call for testing

* Add back hidetoolbar in ts and delete one in et

* Remove unecessary calls to hidToolbar

* Fix analyze and docs

* Test fix

* Fix container issue

* Clean up

* Fix analyze

* Move delegate

* Fix typos

* Start addressing review

* Continue addressing review

* Add assert

* Some refactoring

* Add test for button behavior

* Undo test change

* Make spell check results public

* Rearrange test

* Add comment

* Address review

* Finish addressing review

* remove unused imports

* Address nits

* Address review

* Fix formatting

* Refactor findsuggestionspanatcursorindex and textselectiontoolbar constraints

* Fix analyze:
  • Loading branch information
camsim99 authored Dec 20, 2022
1 parent fa3777b commit e0742eb
Show file tree
Hide file tree
Showing 18 changed files with 998 additions and 55 deletions.
2 changes: 2 additions & 0 deletions packages/flutter/lib/material.dart
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ export 'src/material/slider.dart';
export 'src/material/slider_theme.dart';
export 'src/material/snack_bar.dart';
export 'src/material/snack_bar_theme.dart';
export 'src/material/spell_check_suggestions_toolbar.dart';
export 'src/material/spell_check_suggestions_toolbar_layout_delegate.dart';
export 'src/material/stepper.dart';
export 'src/material/switch.dart';
export 'src/material/switch_list_tile.dart';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ class CupertinoTextSelectionToolbarButton extends StatelessWidget {
return localizations.pasteButtonLabel;
case ContextMenuButtonType.selectAll:
return localizations.selectAllButtonLabel;
case ContextMenuButtonType.delete:
case ContextMenuButtonType.custom:
return '';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,8 @@ class AdaptiveTextSelectionToolbar extends StatelessWidget {
return localizations.pasteButtonLabel;
case ContextMenuButtonType.selectAll:
return localizations.selectAllButtonLabel;
case ContextMenuButtonType.delete:
return localizations.deleteButtonTooltip.toUpperCase();
case ContextMenuButtonType.custom:
return '';
}
Expand Down
221 changes: 221 additions & 0 deletions packages/flutter/lib/src/material/spell_check_suggestions_toolbar.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter/services.dart' show SuggestionSpan;
import 'package:flutter/widgets.dart';

import 'adaptive_text_selection_toolbar.dart';
import 'colors.dart';
import 'material.dart';
import 'spell_check_suggestions_toolbar_layout_delegate.dart';
import 'text_selection_toolbar.dart';
import 'text_selection_toolbar_text_button.dart';

// The default height of the SpellCheckSuggestionsToolbar, which
// assumes there are the maximum number of spell check suggestions available, 3.
// Size eyeballed on Pixel 4 emulator running Android API 31.
const double _kDefaultToolbarHeight = 193.0;

/// The default spell check suggestions toolbar for Android.
///
/// Tries to position itself below the [anchor], but if it doesn't fit, then it
/// readjusts to fit above bottom view insets.
class SpellCheckSuggestionsToolbar extends StatelessWidget {
/// Constructs a [SpellCheckSuggestionsToolbar].
const SpellCheckSuggestionsToolbar({
super.key,
required this.anchor,
required this.buttonItems,
}) : assert(buttonItems != null);

/// {@template flutter.material.SpellCheckSuggestionsToolbar.anchor}
/// The focal point below which the toolbar attempts to position itself.
/// {@endtemplate}
final Offset anchor;

/// The [ContextMenuButtonItem]s that will be turned into the correct button
/// widgets and displayed in the spell check suggestions toolbar.
///
/// See also:
///
/// * [AdaptiveTextSelectionToolbar.buttonItems], the list of
/// [ContextMenuButtonItem]s that are used to build the buttons of the
/// text selection toolbar.
final List<ContextMenuButtonItem> buttonItems;

/// Padding between the toolbar and the anchor. Eyeballed on Pixel 4 emulator
/// running Android API 31.
static const double kToolbarContentDistanceBelow = TextSelectionToolbar.kHandleSize - 3.0;

/// Builds the default Android Material spell check suggestions toolbar.
static Widget _spellCheckSuggestionsToolbarBuilder(BuildContext context, Widget child) {
return _SpellCheckSuggestionsToolbarContainer(
child: child,
);
}

/// Builds the button items for the toolbar based on the available
/// spell check suggestions.
static List<ContextMenuButtonItem>? buildButtonItems(
BuildContext context,
EditableTextState editableTextState,
) {
// Determine if composing region is misspelled.
final SuggestionSpan? spanAtCursorIndex =
editableTextState.findSuggestionSpanAtCursorIndex(
editableTextState.currentTextEditingValue.selection.baseOffset,
);

if (spanAtCursorIndex == null) {
return null;
}

final List<ContextMenuButtonItem> buttonItems = <ContextMenuButtonItem>[];

// Build suggestion buttons.
for (final String suggestion in spanAtCursorIndex.suggestions) {
buttonItems.add(ContextMenuButtonItem(
onPressed: () {
editableTextState
.replaceComposingRegion(
SelectionChangedCause.toolbar,
suggestion,
);
},
label: suggestion,
));
}

// Build delete button.
final ContextMenuButtonItem deleteButton =
ContextMenuButtonItem(
onPressed: () {
editableTextState.replaceComposingRegion(
SelectionChangedCause.toolbar,
'',
);
},
type: ContextMenuButtonType.delete,
);
buttonItems.add(deleteButton);

return buttonItems;
}

/// Determines the Offset that the toolbar will be anchored to.
static Offset getToolbarAnchor(TextSelectionToolbarAnchors anchors) {
return anchors.secondaryAnchor == null ? anchors.primaryAnchor : anchors.secondaryAnchor!;
}

/// Builds the toolbar buttons based on the [buttonItems].
List<Widget> _buildToolbarButtons(BuildContext context) {
return buttonItems.map((ContextMenuButtonItem buttonItem) {
final TextSelectionToolbarTextButton button =
TextSelectionToolbarTextButton(
padding: const EdgeInsets.fromLTRB(20, 0, 0, 0),
onPressed: buttonItem.onPressed,
alignment: Alignment.centerLeft,
child: Text(
AdaptiveTextSelectionToolbar.getButtonLabel(context, buttonItem),
style: buttonItem.type == ContextMenuButtonType.delete ? const TextStyle(color: Colors.blue) : null,
),
);

if (buttonItem.type != ContextMenuButtonType.delete) {
return button;
}
return DecoratedBox(
decoration: const BoxDecoration(border: Border(top: BorderSide(color: Colors.grey))),
child: button,
);
}).toList();
}

@override
Widget build(BuildContext context) {
// Adjust toolbar height if needed.
final double spellCheckSuggestionsToolbarHeight =
_kDefaultToolbarHeight - (48.0 * (4 - buttonItems.length));
// Incorporate the padding distance between the content and toolbar.
final Offset anchorPadded =
anchor + const Offset(0.0, kToolbarContentDistanceBelow);
final MediaQueryData mediaQueryData = MediaQuery.of(context);
final double softKeyboardViewInsetsBottom = mediaQueryData.viewInsets.bottom;
final double paddingAbove = mediaQueryData.padding.top + TextSelectionToolbar.kToolbarScreenPadding;
// Makes up for the Padding.
final Offset localAdjustment = Offset(TextSelectionToolbar.kToolbarScreenPadding, paddingAbove);

return Padding(
padding: EdgeInsets.fromLTRB(
TextSelectionToolbar.kToolbarScreenPadding,
kToolbarContentDistanceBelow,
TextSelectionToolbar.kToolbarScreenPadding,
TextSelectionToolbar.kToolbarScreenPadding + softKeyboardViewInsetsBottom,
),
child: CustomSingleChildLayout(
delegate: SpellCheckSuggestionsToolbarLayoutDelegate(
anchor: anchorPadded - localAdjustment,
),
child: AnimatedSize(
// This duration was eyeballed on a Pixel 2 emulator running Android
// API 28 for the Material TextSelectionToolbar.
duration: const Duration(milliseconds: 140),
child: _spellCheckSuggestionsToolbarBuilder(context, _SpellCheckSuggestsionsToolbarItemsLayout(
height: spellCheckSuggestionsToolbarHeight,
children: <Widget>[..._buildToolbarButtons(context)],
)),
),
),
);
}
}

/// The Material-styled toolbar outline for the spell check suggestions
/// toolbar.
class _SpellCheckSuggestionsToolbarContainer extends StatelessWidget {
const _SpellCheckSuggestionsToolbarContainer({
required this.child,
});

final Widget child;

@override
Widget build(BuildContext context) {
return Material(
// This elevation was eyeballed on a Pixel 4 emulator running Android
// API 31 for the SpellCheckSuggestionsToolbar.
elevation: 2.0,
type: MaterialType.card,
child: child,
);
}
}

/// Renders the spell check suggestions toolbar items in the correct positions
/// in the menu.
class _SpellCheckSuggestsionsToolbarItemsLayout extends StatelessWidget {
const _SpellCheckSuggestsionsToolbarItemsLayout({
required this.height,
required this.children,
});

final double height;

final List<Widget> children;

@override
Widget build(BuildContext context) {
return SizedBox(
// This width was eyeballed on a Pixel 4 emulator running Android
// API 31 for the SpellCheckSuggestionsToolbar.
width: 165,
height: height,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart' show TextSelectionToolbarLayoutDelegate;

/// Positions the toolbar below [anchor] or adjusts it higher to fit above
/// the bottom view insets, if applicable.
///
/// See also:
///
/// * [SpellCheckSuggestionsToolbar], which uses this to position itself.
class SpellCheckSuggestionsToolbarLayoutDelegate extends SingleChildLayoutDelegate {
/// Creates an instance of [SpellCheckSuggestionsToolbarLayoutDelegate].
SpellCheckSuggestionsToolbarLayoutDelegate({
required this.anchor,
});

/// {@macro flutter.material.SpellCheckSuggestionsToolbar.anchor}
///
/// Should be provided in local coordinates.
final Offset anchor;

@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return constraints.loosen();
}

@override
Offset getPositionForChild(Size size, Size childSize) {
return Offset(
TextSelectionToolbarLayoutDelegate.centerOn(
anchor.dx,
childSize.width,
size.width,
),
// Positions child (of childSize) just enough upwards to fit within size
// if it otherwise does not fit below the anchor.
anchor.dy + childSize.height > size.height
? size.height - childSize.height
: anchor.dy,
);
}

@override
bool shouldRelayout(SpellCheckSuggestionsToolbarLayoutDelegate oldDelegate) {
return anchor != oldDelegate.anchor;
}
}
33 changes: 32 additions & 1 deletion packages/flutter/lib/src/material/text_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import 'magnifier.dart';
import 'material_localizations.dart';
import 'material_state.dart';
import 'selectable_text.dart' show iOSHorizontalOffset;
import 'spell_check_suggestions_toolbar.dart';
import 'text_selection.dart';
import 'theme.dart';

Expand Down Expand Up @@ -800,6 +801,32 @@ class TextField extends StatefulWidget {
decorationStyle: TextDecorationStyle.wavy,
);

/// Default builder for the spell check suggestions toolbar in the Material
/// style.
///
/// See also:
/// * [SpellCheckConfiguration.spellCheckSuggestionsToolbarBuilder], the
// builder configured to show a spell check suggestions toolbar.
@visibleForTesting
static Widget defaultSpellCheckSuggestionsToolbarBuilder(
BuildContext context,
EditableTextState editableTextState,
) {
final Offset anchor =
SpellCheckSuggestionsToolbar.getToolbarAnchor(editableTextState.contextMenuAnchors);
final List<ContextMenuButtonItem>? buttonItems =
SpellCheckSuggestionsToolbar.buildButtonItems(context, editableTextState);

if (buttonItems == null){
return const SizedBox.shrink();
}

return SpellCheckSuggestionsToolbar(
anchor: anchor,
buttonItems: buttonItems,
);
}

@override
State<TextField> createState() => _TextFieldState();

Expand Down Expand Up @@ -1192,7 +1219,11 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
widget.spellCheckConfiguration != const SpellCheckConfiguration.disabled()
? widget.spellCheckConfiguration!.copyWith(
misspelledTextStyle: widget.spellCheckConfiguration!.misspelledTextStyle
?? TextField.materialMisspelledTextStyle)
?? TextField.materialMisspelledTextStyle,
spellCheckSuggestionsToolbarBuilder:
widget.spellCheckConfiguration!.spellCheckSuggestionsToolbarBuilder
?? TextField.defaultSpellCheckSuggestionsToolbarBuilder
)
: const SpellCheckConfiguration.disabled();

TextSelectionControls? textSelectionControls = widget.selectionControls;
Expand Down
Loading

0 comments on commit e0742eb

Please sign in to comment.