blob: cf0c218c832eb5e473a4b0f4759587145fe72b35 [file] [log] [blame] [edit]
// 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,
),
),
);
}
}