// 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/cupertino.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart' show SelectionChangedCause, SuggestionSpan; import 'adaptive_text_selection_toolbar.dart'; import 'colors.dart'; import 'material.dart'; import 'spell_check_suggestions_toolbar_layout_delegate.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 maximum number of suggestions in the toolbar is 3, plus a delete button. const int _kMaxSuggestions = 3; /// 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. /// /// See also: /// /// * [CupertinoSpellCheckSuggestionsToolbar], which is similar but builds an /// iOS-style spell check toolbar. class SpellCheckSuggestionsToolbar extends StatelessWidget { /// Constructs a [SpellCheckSuggestionsToolbar]. /// /// [buttonItems] must not contain more than four items, generally three /// suggestions and one delete button. const SpellCheckSuggestionsToolbar({ super.key, required this.anchor, required this.buttonItems, }) : assert(buttonItems.length <= _kMaxSuggestions + 1); /// Constructs a [SpellCheckSuggestionsToolbar] with the default children for /// an [EditableText]. /// /// See also: /// * [CupertinoSpellCheckSuggestionsToolbar.editableText], which is similar /// but builds an iOS-style toolbar. SpellCheckSuggestionsToolbar.editableText({ super.key, required EditableTextState editableTextState, }) : buttonItems = buildButtonItems(editableTextState) ?? <ContextMenuButtonItem>[], anchor = getToolbarAnchor(editableTextState.contextMenuAnchors); /// {@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. /// /// Must not contain more than four items, typically three suggestions and a /// delete button. /// /// See also: /// /// * [AdaptiveTextSelectionToolbar.buttonItems], the list of /// [ContextMenuButtonItem]s that are used to build the buttons of the /// text selection toolbar. /// * [CupertinoSpellCheckSuggestionsToolbar.buttonItems], the list of /// [ContextMenuButtonItem]s used to build the Cupertino style spell check /// suggestions toolbar. final List<ContextMenuButtonItem> buttonItems; /// Builds the button items for the toolbar based on the available /// spell check suggestions. static List<ContextMenuButtonItem>? buildButtonItems( 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.take(_kMaxSuggestions)) { buttonItems.add(ContextMenuButtonItem( onPressed: () { if (!editableTextState.mounted) { return; } _replaceText( editableTextState, suggestion, spanAtCursorIndex.range, ); }, label: suggestion, )); } // Build delete button. final ContextMenuButtonItem deleteButton = ContextMenuButtonItem( onPressed: () { if (!editableTextState.mounted) { return; } _replaceText( editableTextState, '', editableTextState.currentTextEditingValue.composing, ); }, type: ContextMenuButtonType.delete, ); buttonItems.add(deleteButton); return buttonItems; } static void _replaceText(EditableTextState editableTextState, String text, TextRange replacementRange) { // Replacement cannot be performed if the text is read only or obscured. assert(!editableTextState.widget.readOnly && !editableTextState.widget.obscureText); final TextEditingValue newValue = editableTextState.textEditingValue.replaced( replacementRange, text, ); editableTextState.userUpdateTextEditingValue(newValue, SelectionChangedCause.toolbar); // Schedule a call to bringIntoView() after renderEditable updates. SchedulerBinding.instance.addPostFrameCallback((Duration duration) { if (editableTextState.mounted) { editableTextState.bringIntoView(editableTextState.textEditingValue.selection.extent); } }); editableTextState.hideToolbar(); } /// Determines the Offset that the toolbar will be anchored to. static Offset getToolbarAnchor(TextSelectionToolbarAnchors anchors) { // Since this will be positioned below the anchor point, use the secondary // anchor by default. 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) { if (buttonItems.isEmpty){ return const SizedBox.shrink(); } // Adjust toolbar height if needed. final double spellCheckSuggestionsToolbarHeight = _kDefaultToolbarHeight - (48.0 * (4 - buttonItems.length)); // Incorporate the padding distance between the content and toolbar. final MediaQueryData mediaQueryData = MediaQuery.of(context); final double softKeyboardViewInsetsBottom = mediaQueryData.viewInsets.bottom; final double paddingAbove = mediaQueryData.padding.top + CupertinoTextSelectionToolbar.kToolbarScreenPadding; // Makes up for the Padding. final Offset localAdjustment = Offset( CupertinoTextSelectionToolbar.kToolbarScreenPadding, paddingAbove, ); return Padding( padding: EdgeInsets.fromLTRB( CupertinoTextSelectionToolbar.kToolbarScreenPadding, paddingAbove, CupertinoTextSelectionToolbar.kToolbarScreenPadding, CupertinoTextSelectionToolbar.kToolbarScreenPadding + softKeyboardViewInsetsBottom, ), child: CustomSingleChildLayout( delegate: SpellCheckSuggestionsToolbarLayoutDelegate( anchor: anchor - 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: _SpellCheckSuggestionsToolbarContainer( 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.height, required this.children, }); final double height; final List<Widget> children; @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: SizedBox( // This width was eyeballed on a Pixel 4 emulator running Android // API 31 for the SpellCheckSuggestionsToolbar. width: 165.0, height: height, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: children, ), ), ); } }