TextField context menu should fade on scroll on mobile devices (#138313)

This change affects Android and iOS devices using the TextField's context menu. After this change the context menu will fade out when scrolling the text and fade in when the scroll ends. 

If the scroll ends and the selection is outside of the view, then the toolbar will be scheduled to show in a future scroll end. This toolbar scheduling can be invalidated if the `TextEditingValue` changed anytime between the scheduling and when the toolbar is ready to be shown.

This change also fixes a regression where the TextField context menu would not fade when the selection handles where not visible.

When using the native browser context menu this behavior is not controlled by Flutter.

https://github.com/flutter/flutter/assets/948037/3f46bcbb-ba6f-456c-8473-e42919b9d572

Fixes #52425
Fixes #105804
Fixes #52426
This commit is contained in:
Renzo Olivares 2024-02-05 21:42:40 -08:00 committed by GitHub
parent 6322fef7e0
commit 0903bf7055
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 688 additions and 110 deletions

View file

@ -163,6 +163,14 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder extends TextSelectionGe
///
/// {@macro flutter.widgets.editableText.showCaretOnScreen}
///
/// ## Scrolling Considerations
///
/// If this [CupertinoTextField] is not a descendant of [Scaffold] and is being
/// used within a [Scrollable] or nested [Scrollable]s, consider placing a
/// [ScrollNotificationObserver] above the root [Scrollable] that contains this
/// [CupertinoTextField] to ensure proper scroll coordination for
/// [CupertinoTextField] and its components like [TextSelectionOverlay].
///
/// See also:
///
/// * <https://developer.apple.com/documentation/uikit/uitextfield>

View file

@ -202,6 +202,14 @@ class _SelectableTextSelectionGestureDetectorBuilder extends TextSelectionGestur
/// To make [SelectableText] react to touch events, use callback [onTap] to achieve
/// the desired behavior.
///
/// ## Scrolling Considerations
///
/// If this [SelectableText] is not a descendant of [Scaffold] and is being used
/// within a [Scrollable] or nested [Scrollable]s, consider placing a
/// [ScrollNotificationObserver] above the root [Scrollable] that contains this
/// [SelectableText] to ensure proper scroll coordination for [SelectableText]
/// and its components like [TextSelectionOverlay].
///
/// See also:
///
/// * [Text], which is the non selectable version of this widget.

View file

@ -186,6 +186,14 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete
/// ** See code in examples/api/lib/material/text_field/text_field.2.dart **
/// {@end-tool}
///
/// ## Scrolling Considerations
///
/// If this [TextField] is not a descendant of [Scaffold] and is being used
/// within a [Scrollable] or nested [Scrollable]s, consider placing a
/// [ScrollNotificationObserver] above the root [Scrollable] that contains this
/// [TextField] to ensure proper scroll coordination for [TextField] and its
/// components like [TextSelectionOverlay].
///
/// See also:
///
/// * [TextFormField], which integrates with the [Form] widget.

View file

@ -30,8 +30,11 @@ import 'framework.dart';
import 'localizations.dart';
import 'magnifier.dart';
import 'media_query.dart';
import 'notification_listener.dart';
import 'scroll_configuration.dart';
import 'scroll_controller.dart';
import 'scroll_notification.dart';
import 'scroll_notification_observer.dart';
import 'scroll_physics.dart';
import 'scroll_position.dart';
import 'scrollable.dart';
@ -684,6 +687,14 @@ class _DiscreteKeyFrameSimulation extends Simulation {
/// * When the virtual keyboard pops up.
/// {@endtemplate}
///
/// ## Scrolling Considerations
///
/// If this [EditableText] is not a descendant of [Scaffold] and is being used
/// within a [Scrollable] or nested [Scrollable]s, consider placing a
/// [ScrollNotificationObserver] above the root [Scrollable] that contains this
/// [EditableText] to ensure proper scroll coordination for [EditableText] and
/// its components like [TextSelectionOverlay].
///
/// {@template flutter.widgets.editableText.accessibility}
/// ## Troubleshooting Common Accessibility Issues
///
@ -2157,6 +2168,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
bool get _hasInputConnection => _textInputConnection?.attached ?? false;
TextSelectionOverlay? _selectionOverlay;
ScrollNotificationObserverState? _scrollNotificationObserver;
({TextEditingValue value, Rect selectionBounds})? _dataWhenToolbarShowScheduled;
bool _listeningToScrollNotificationObserver = false;
bool get _webContextMenuEnabled => kIsWeb && BrowserContextMenu.enabled;
final GlobalKey _scrollableKey = GlobalKey();
ScrollController? _internalScrollController;
@ -2846,7 +2862,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
clipboardStatus.addListener(_onChangedClipboardStatus);
widget.controller.addListener(_didChangeTextEditingValue);
widget.focusNode.addListener(_handleFocusChanged);
_scrollController.addListener(_onEditableScroll);
_cursorVisibilityNotifier.value = widget.showCursor;
_spellCheckConfiguration = _inferSpellCheckConfiguration(widget.spellCheckConfiguration);
_initProcessTextActions();
@ -2918,6 +2933,16 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
hideToolbar();
}
}
if (_listeningToScrollNotificationObserver) {
// Only update subscription when we have previously subscribed to the
// scroll notification observer. We only subscribe to the scroll
// notification observer when the context menu is shown on platforms that
// support _platformSupportsFadeOnScroll.
_scrollNotificationObserver?.removeListener(_handleContextMenuOnParentScroll);
_scrollNotificationObserver = ScrollNotificationObserver.maybeOf(context);
_scrollNotificationObserver?.addListener(_handleContextMenuOnParentScroll);
}
}
@override
@ -2965,11 +2990,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
updateKeepAlive();
}
if (widget.scrollController != oldWidget.scrollController) {
(oldWidget.scrollController ?? _internalScrollController)?.removeListener(_onEditableScroll);
_scrollController.addListener(_onEditableScroll);
}
if (!_shouldCreateInputConnection) {
_closeInputConnectionIfNeeded();
} else if (oldWidget.readOnly && _hasFocus) {
@ -3020,6 +3040,14 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}
}
void _disposeScrollNotificationObserver() {
_listeningToScrollNotificationObserver = false;
if (_scrollNotificationObserver != null) {
_scrollNotificationObserver!.removeListener(_handleContextMenuOnParentScroll);
_scrollNotificationObserver = null;
}
}
@override
void dispose() {
_internalScrollController?.dispose();
@ -3043,6 +3071,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
clipboardStatus.dispose();
_cursorVisibilityNotifier.dispose();
FocusManager.instance.removeListener(_unflagInternalFocus);
_disposeScrollNotificationObserver();
super.dispose();
assert(_batchEditDepth <= 0, 'unfinished batch edits: $_batchEditDepth');
}
@ -3635,9 +3664,161 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}
}
void _onEditableScroll() {
_selectionOverlay?.updateForScroll();
_scribbleCacheKey = null;
final bool _platformSupportsFadeOnScroll = switch (defaultTargetPlatform) {
TargetPlatform.android ||
TargetPlatform.iOS => true,
TargetPlatform.fuchsia ||
TargetPlatform.linux ||
TargetPlatform.macOS ||
TargetPlatform.windows => false,
};
bool _isInternalScrollableNotification(BuildContext? notificationContext) {
final ScrollableState? scrollableState = notificationContext?.findAncestorStateOfType<ScrollableState>();
return _scrollableKey.currentContext == scrollableState?.context;
}
bool _scrollableNotificationIsFromSameSubtree(BuildContext? notificationContext) {
if (notificationContext == null) {
return false;
}
BuildContext? currentContext = context;
// The notification context of a ScrollNotification points to the RawGestureDetector
// of the Scrollable. We get the ScrollableState associated with this notification
// by looking up the tree.
final ScrollableState? notificationScrollableState = notificationContext.findAncestorStateOfType<ScrollableState>();
if (notificationScrollableState == null) {
return false;
}
while (currentContext != null) {
final ScrollableState? scrollableState = currentContext.findAncestorStateOfType<ScrollableState>();
if (scrollableState == notificationScrollableState) {
return true;
}
currentContext = scrollableState?.context;
}
return false;
}
void _handleContextMenuOnParentScroll(ScrollNotification notification) {
// Do some preliminary checks to avoid expensive subtree traversal.
if (notification is! ScrollStartNotification
&& notification is! ScrollEndNotification) {
return;
}
if (notification is ScrollStartNotification
&& _dataWhenToolbarShowScheduled != null) {
return;
}
if (notification is ScrollEndNotification
&& _dataWhenToolbarShowScheduled == null) {
return;
}
if (notification is ScrollEndNotification
&& _dataWhenToolbarShowScheduled!.value != _value) {
_dataWhenToolbarShowScheduled = null;
_disposeScrollNotificationObserver();
return;
}
if (_isInternalScrollableNotification(notification.context)) {
return;
}
if (!_scrollableNotificationIsFromSameSubtree(notification.context)) {
return;
}
_handleContextMenuOnScroll(notification);
}
Rect _calculateDeviceRect() {
final Size screenSize = MediaQuery.sizeOf(context);
final ui.FlutterView view = View.of(context);
final double obscuredVertical = (view.padding.top + view.padding.bottom + view.viewInsets.bottom) / view.devicePixelRatio;
final double obscuredHorizontal = (view.padding.left + view.padding.right) / view.devicePixelRatio;
final Size visibleScreenSize = Size(screenSize.width - obscuredHorizontal, screenSize.height - obscuredVertical);
return Rect.fromLTWH(view.padding.left / view.devicePixelRatio, view.padding.top / view.devicePixelRatio, visibleScreenSize.width, visibleScreenSize.height);
}
bool _showToolbarOnScreenScheduled = false;
void _handleContextMenuOnScroll(ScrollNotification notification) {
if (_webContextMenuEnabled) {
return;
}
if (!_platformSupportsFadeOnScroll) {
_selectionOverlay?.updateForScroll();
return;
}
// When the scroll begins and the toolbar is visible, hide it
// until scrolling ends.
//
// The selection and renderEditable need to be visible within the current
// viewport for the toolbar to show when scrolling ends. If they are not
// then the toolbar is shown when they are scrolled back into view, unless
// invalidated by a change in TextEditingValue.
if (notification is ScrollStartNotification) {
if (_dataWhenToolbarShowScheduled != null) {
return;
}
final bool toolbarIsVisible = _selectionOverlay != null
&& _selectionOverlay!.toolbarIsVisible
&& !_selectionOverlay!.spellCheckToolbarIsVisible;
if (!toolbarIsVisible) {
return;
}
final List<TextBox> selectionBoxes = renderEditable.getBoxesForSelection(_value.selection);
final Rect selectionBounds = _value.selection.isCollapsed || selectionBoxes.isEmpty
? renderEditable.getLocalRectForCaret(_value.selection.extent)
: selectionBoxes
.map((TextBox box) => box.toRect())
.reduce((Rect result, Rect rect) => result.expandToInclude(rect));
_dataWhenToolbarShowScheduled = (value: _value, selectionBounds: selectionBounds);
_selectionOverlay?.hideToolbar();
} else if (notification is ScrollEndNotification) {
if (_dataWhenToolbarShowScheduled == null) {
return;
}
if (_dataWhenToolbarShowScheduled!.value != _value) {
// Value has changed so we should invalidate any toolbar scheduling.
_dataWhenToolbarShowScheduled = null;
_disposeScrollNotificationObserver();
return;
}
if (_showToolbarOnScreenScheduled) {
return;
}
_showToolbarOnScreenScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
_showToolbarOnScreenScheduled = false;
if (!mounted) {
return;
}
final Rect deviceRect = _calculateDeviceRect();
final bool selectionVisibleInEditable = renderEditable.selectionStartInViewport.value || renderEditable.selectionEndInViewport.value;
final Rect selectionBounds = MatrixUtils.transformRect(renderEditable.getTransformTo(null), _dataWhenToolbarShowScheduled!.selectionBounds);
final bool selectionOverlapsWithDeviceRect = !selectionBounds.hasNaN && deviceRect.overlaps(selectionBounds);
if (selectionVisibleInEditable
&& selectionOverlapsWithDeviceRect
&& _selectionInViewport(_dataWhenToolbarShowScheduled!.selectionBounds)) {
showToolbar();
_dataWhenToolbarShowScheduled = null;
}
}, debugLabel: 'EditableText.scheduleToolbar');
}
}
bool _selectionInViewport(Rect selectionBounds) {
RenderAbstractViewport? closestViewport = RenderAbstractViewport.maybeOf(renderEditable);
while (closestViewport != null) {
final Rect selectionBoundsLocalToViewport = MatrixUtils.transformRect(renderEditable.getTransformTo(closestViewport), selectionBounds);
if (selectionBoundsLocalToViewport.hasNaN
|| closestViewport.paintBounds.hasNaN
|| !closestViewport.paintBounds.overlaps(selectionBoundsLocalToViewport)) {
return false;
}
closestViewport = RenderAbstractViewport.maybeOf(closestViewport.parent);
}
return true;
}
TextSelectionOverlay _createSelectionOverlay() {
@ -3655,7 +3836,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
selectionDelegate: this,
dragStartBehavior: widget.dragStartBehavior,
onSelectionHandleTapped: widget.onSelectionHandleTapped,
contextMenuBuilder: contextMenuBuilder == null
contextMenuBuilder: contextMenuBuilder == null || _webContextMenuEnabled
? null
: (BuildContext context) {
return contextMenuBuilder(
@ -4315,7 +4496,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// functionality depending on the browser (such as translate). Due to this,
// we should not show a Flutter toolbar for the editable text elements
// unless the browser's context menu is explicitly disabled.
if (kIsWeb && BrowserContextMenu.enabled) {
if (_webContextMenuEnabled) {
return false;
}
@ -4325,11 +4506,21 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_liveTextInputStatus?.update();
clipboardStatus.update();
_selectionOverlay!.showToolbar();
// Listen to parent scroll events when the toolbar is visible so it can be
// hidden during a scroll on supported platforms.
if (_platformSupportsFadeOnScroll) {
_listeningToScrollNotificationObserver = true;
_scrollNotificationObserver?.removeListener(_handleContextMenuOnParentScroll);
_scrollNotificationObserver = ScrollNotificationObserver.maybeOf(context);
_scrollNotificationObserver?.addListener(_handleContextMenuOnParentScroll);
}
return true;
}
@override
void hideToolbar([bool hideHandles = true]) {
// Stop listening to parent scroll events when toolbar is hidden.
_disposeScrollNotificationObserver();
if (hideHandles) {
// Hide the handles and the toolbar.
_selectionOverlay?.hide();
@ -4356,9 +4547,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// platforms. Additionally, the Cupertino style toolbar can't be drawn on
// the web with the HTML renderer due to
// https://github.com/flutter/flutter/issues/123560.
final bool platformNotSupported = kIsWeb && BrowserContextMenu.enabled;
if (!spellCheckEnabled
|| platformNotSupported
|| _webContextMenuEnabled
|| widget.readOnly
|| _selectionOverlay == null
|| !_spellCheckResultsReceived
@ -4943,85 +5133,92 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
focusNode: widget.focusNode,
includeSemantics: false,
debugLabel: kReleaseMode ? null : 'EditableText',
child: Scrollable(
key: _scrollableKey,
excludeFromSemantics: true,
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
controller: _scrollController,
physics: widget.scrollPhysics,
dragStartBehavior: widget.dragStartBehavior,
restorationId: widget.restorationId,
// If a ScrollBehavior is not provided, only apply scrollbars when
// multiline. The overscroll indicator should not be applied in
// either case, glowing or stretching.
scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(
scrollbars: _isMultiline,
overscroll: false,
),
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return CompositedTransformTarget(
link: _toolbarLayerLink,
child: Semantics(
onCopy: _semanticsOnCopy(controls),
onCut: _semanticsOnCut(controls),
onPaste: _semanticsOnPaste(controls),
child: _ScribbleFocusable(
focusNode: widget.focusNode,
editableKey: _editableKey,
enabled: widget.scribbleEnabled,
updateSelectionRects: () {
_openInputConnection();
_updateSelectionRects(force: true);
},
child: SizeChangedLayoutNotifier(
child: _Editable(
key: _editableKey,
startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
inlineSpan: buildTextSpan(),
value: _value,
cursorColor: _cursorColor,
backgroundCursorColor: widget.backgroundCursorColor,
showCursor: _cursorVisibilityNotifier,
forceLine: widget.forceLine,
readOnly: widget.readOnly,
hasFocus: _hasFocus,
maxLines: widget.maxLines,
minLines: widget.minLines,
expands: widget.expands,
strutStyle: widget.strutStyle,
selectionColor: _selectionOverlay?.spellCheckToolbarIsVisible ?? false
? _spellCheckConfiguration.misspelledSelectionColor ?? widget.selectionColor
: widget.selectionColor,
textScaler: effectiveTextScaler,
textAlign: widget.textAlign,
textDirection: _textDirection,
locale: widget.locale,
textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.maybeOf(context),
textWidthBasis: widget.textWidthBasis,
obscuringCharacter: widget.obscuringCharacter,
obscureText: widget.obscureText,
offset: offset,
rendererIgnoresPointer: widget.rendererIgnoresPointer,
cursorWidth: widget.cursorWidth,
cursorHeight: widget.cursorHeight,
cursorRadius: widget.cursorRadius,
cursorOffset: widget.cursorOffset ?? Offset.zero,
selectionHeightStyle: widget.selectionHeightStyle,
selectionWidthStyle: widget.selectionWidthStyle,
paintCursorAboveText: widget.paintCursorAboveText,
enableInteractiveSelection: widget._userSelectionEnabled,
textSelectionDelegate: this,
devicePixelRatio: _devicePixelRatio,
promptRectRange: _currentPromptRectRange,
promptRectColor: widget.autocorrectionTextRectColor,
clipBehavior: widget.clipBehavior,
child: NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
_handleContextMenuOnScroll(notification);
_scribbleCacheKey = null;
return false;
},
child: Scrollable(
key: _scrollableKey,
excludeFromSemantics: true,
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
controller: _scrollController,
physics: widget.scrollPhysics,
dragStartBehavior: widget.dragStartBehavior,
restorationId: widget.restorationId,
// If a ScrollBehavior is not provided, only apply scrollbars when
// multiline. The overscroll indicator should not be applied in
// either case, glowing or stretching.
scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(
scrollbars: _isMultiline,
overscroll: false,
),
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return CompositedTransformTarget(
link: _toolbarLayerLink,
child: Semantics(
onCopy: _semanticsOnCopy(controls),
onCut: _semanticsOnCut(controls),
onPaste: _semanticsOnPaste(controls),
child: _ScribbleFocusable(
focusNode: widget.focusNode,
editableKey: _editableKey,
enabled: widget.scribbleEnabled,
updateSelectionRects: () {
_openInputConnection();
_updateSelectionRects(force: true);
},
child: SizeChangedLayoutNotifier(
child: _Editable(
key: _editableKey,
startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
inlineSpan: buildTextSpan(),
value: _value,
cursorColor: _cursorColor,
backgroundCursorColor: widget.backgroundCursorColor,
showCursor: _cursorVisibilityNotifier,
forceLine: widget.forceLine,
readOnly: widget.readOnly,
hasFocus: _hasFocus,
maxLines: widget.maxLines,
minLines: widget.minLines,
expands: widget.expands,
strutStyle: widget.strutStyle,
selectionColor: _selectionOverlay?.spellCheckToolbarIsVisible ?? false
? _spellCheckConfiguration.misspelledSelectionColor ?? widget.selectionColor
: widget.selectionColor,
textScaler: effectiveTextScaler,
textAlign: widget.textAlign,
textDirection: _textDirection,
locale: widget.locale,
textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.maybeOf(context),
textWidthBasis: widget.textWidthBasis,
obscuringCharacter: widget.obscuringCharacter,
obscureText: widget.obscureText,
offset: offset,
rendererIgnoresPointer: widget.rendererIgnoresPointer,
cursorWidth: widget.cursorWidth,
cursorHeight: widget.cursorHeight,
cursorRadius: widget.cursorRadius,
cursorOffset: widget.cursorOffset ?? Offset.zero,
selectionHeightStyle: widget.selectionHeightStyle,
selectionWidthStyle: widget.selectionWidthStyle,
paintCursorAboveText: widget.paintCursorAboveText,
enableInteractiveSelection: widget._userSelectionEnabled,
textSelectionDelegate: this,
devicePixelRatio: _devicePixelRatio,
promptRectRange: _currentPromptRectRange,
promptRectColor: widget.autocorrectionTextRectColor,
clipBehavior: widget.clipBehavior,
),
),
),
),
),
);
},
);
},
),
),
),
),

View file

@ -1432,6 +1432,7 @@ class SelectionOverlay {
context: context,
contextMenuBuilder: (BuildContext context) {
return _SelectionToolbarWrapper(
visibility: toolbarVisible,
layerLink: toolbarLayerLink,
offset: -renderBox.localToGlobal(Offset.zero),
child: contextMenuBuilder(context),
@ -2228,8 +2229,6 @@ class TextSelectionGestureDetectorBuilder {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.fuchsia:
// On mobile platforms the selection is set on tap up.
editableText.hideToolbar(false);
case TargetPlatform.iOS:
// On mobile platforms the selection is set on tap up.
break;
@ -2352,6 +2351,7 @@ class TextSelectionGestureDetectorBuilder {
break;
// On desktop platforms the selection is set on tap down.
case TargetPlatform.android:
editableText.hideToolbar(false);
if (isShiftPressedValid) {
_extendSelection(details.globalPosition, SelectionChangedCause.tap);
return;
@ -2359,6 +2359,7 @@ class TextSelectionGestureDetectorBuilder {
renderEditable.selectPosition(cause: SelectionChangedCause.tap);
editableText.showSpellCheckSuggestionsToolbar();
case TargetPlatform.fuchsia:
editableText.hideToolbar(false);
if (isShiftPressedValid) {
_extendSelection(details.globalPosition, SelectionChangedCause.tap);
return;

View file

@ -10034,7 +10034,7 @@ void main() {
skip: kIsWeb, // [intended]
);
testWidgets('text selection toolbar is hidden on tap down', (WidgetTester tester) async {
testWidgets('text selection toolbar is hidden on tap down on desktop platforms', (WidgetTester tester) async {
final TextEditingController controller = TextEditingController(
text: 'blah1 blah2',
);
@ -10077,7 +10077,7 @@ void main() {
expect(find.byType(CupertinoAdaptiveTextSelectionToolbar), findsNothing);
},
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }),
variant: TargetPlatformVariant.all(excluding: TargetPlatformVariant.mobile().values),
);
testWidgets('Does not shrink in height when enters text when there is large single-line placeholder', (WidgetTester tester) async {

View file

@ -11842,6 +11842,365 @@ void main() {
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux, TargetPlatform.windows }),
);
testWidgets(
'Toolbar hides on scroll start and re-appears on scroll end on Android and iOS',
(WidgetTester tester) async {
final TextEditingController controller = _textEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure ' * 20,
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
controller: controller,
),
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
final RenderEditable renderEditable = state.renderEditable;
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0));
await tester.pumpAndSettle();
// Long press should select word at position and show toolbar.
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
final bool targetPlatformIsiOS = defaultTargetPlatform == TargetPlatform.iOS;
final Finder contextMenuButtonFinder = targetPlatformIsiOS ? find.byType(CupertinoButton) : find.byType(TextButton);
// Context menu shows 5 buttons: cut, copy, paste, select all, share on Android.
// Context menu shows 6 buttons: cut, copy, paste, select all, lookup, share on iOS.
final int numberOfContextMenuButtons = targetPlatformIsiOS ? 6 : 5;
expect(
contextMenuButtonFinder,
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons),
);
// Scroll to the left, the toolbar should be hidden since we are scrolling.
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(TextField)));
await tester.pump();
await gesture.moveTo(tester.getBottomLeft(find.byType(TextField)));
await tester.pumpAndSettle();
expect(contextMenuButtonFinder, findsNothing);
// Scroll back to center, the toolbar should still be hidden since
// we are still scrolling.
await gesture.moveTo(tester.getCenter(find.byType(TextField)));
await tester.pumpAndSettle();
expect(contextMenuButtonFinder, findsNothing);
// Release finger to end scroll, toolbar should now be visible.
await gesture.up();
await tester.pumpAndSettle();
expect(
contextMenuButtonFinder,
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons),
);
expect(renderEditable.selectionStartInViewport.value, true);
expect(renderEditable.selectionEndInViewport.value, true);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.iOS }),
);
testWidgets(
'Toolbar hides on parent scrollable scroll start and re-appears on scroll end on Android and iOS',
(WidgetTester tester) async {
final TextEditingController controller = _textEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure ' * 20,
);
final Key key1 = UniqueKey();
final Key key2 = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: ListView(
children: <Widget>[
Container(
height: 400,
key: key1,
),
TextField(controller: controller),
Container(
height: 1000,
key: key2,
),
],
),
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
final RenderEditable renderEditable = state.renderEditable;
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0));
await tester.pumpAndSettle();
// Long press should select word at position and show toolbar.
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
final bool targetPlatformIsiOS = defaultTargetPlatform == TargetPlatform.iOS;
final Finder contextMenuButtonFinder = targetPlatformIsiOS ? find.byType(CupertinoButton) : find.byType(TextButton);
// Context menu shows 5 buttons: cut, copy, paste, select all, share on Android.
// Context menu shows 6 buttons: cut, copy, paste, select all, lookup, share on iOS.
final int numberOfContextMenuButtons = targetPlatformIsiOS ? 6 : 5;
expect(
contextMenuButtonFinder,
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons),
);
// Scroll down, the toolbar should be hidden since we are scrolling.
final TestGesture gesture = await tester.startGesture(tester.getBottomLeft(find.byKey(key1)));
await tester.pump();
await gesture.moveTo(tester.getTopLeft(find.byKey(key1)));
await tester.pumpAndSettle();
expect(contextMenuButtonFinder, findsNothing);
// Release finger to end scroll, toolbar should now be visible.
await gesture.up();
await tester.pumpAndSettle();
expect(
contextMenuButtonFinder,
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons),
);
expect(renderEditable.selectionStartInViewport.value, true);
expect(renderEditable.selectionEndInViewport.value, true);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.iOS }),
);
testWidgets(
'Toolbar can re-appear after being scrolled out of view on Android and iOS',
(WidgetTester tester) async {
final TextEditingController controller = _textEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure ' * 20,
);
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Center(
child: TextField(
controller: controller,
scrollController: scrollController,
),
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
final RenderEditable renderEditable = state.renderEditable;
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
expect(renderEditable.selectionStartInViewport.value, false);
expect(renderEditable.selectionEndInViewport.value, false);
await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0));
await tester.pumpAndSettle();
// Long press should select word at position and show toolbar.
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
final bool targetPlatformIsiOS = defaultTargetPlatform == TargetPlatform.iOS;
final Finder contextMenuButtonFinder = targetPlatformIsiOS ? find.byType(CupertinoButton) : find.byType(TextButton);
// Context menu shows 5 buttons: cut, copy, paste, select all, share on Android.
// Context menu shows 6 buttons: cut, copy, paste, select all, lookup, share on iOS.
final int numberOfContextMenuButtons = targetPlatformIsiOS ? 6 : 5;
expect(
contextMenuButtonFinder,
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons),
);
expect(renderEditable.selectionStartInViewport.value, true);
expect(renderEditable.selectionEndInViewport.value, true);
// Scroll to the end so the selection is no longer visible. This should
// hide the toolbar, but schedule it to be shown once the selection is
// visible again.
scrollController.animateTo(
500.0,
duration: const Duration(milliseconds: 100),
curve: Curves.linear,
);
await tester.pumpAndSettle();
expect(contextMenuButtonFinder, findsNothing);
expect(renderEditable.selectionStartInViewport.value, false);
expect(renderEditable.selectionEndInViewport.value, false);
// Scroll to the beginning where the selection is in view
// and the toolbar should show again.
scrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 100),
curve: Curves.linear,
);
await tester.pumpAndSettle();
expect(
contextMenuButtonFinder,
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons),
);
expect(renderEditable.selectionStartInViewport.value, true);
expect(renderEditable.selectionEndInViewport.value, true);
final TestGesture gesture = await tester.startGesture(textOffsetToPosition(tester, 0));
await tester.pump();
await gesture.up();
await gesture.down(textOffsetToPosition(tester, 0));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
// Double tap should select word at position and show toolbar.
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
expect(
contextMenuButtonFinder,
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons),
);
expect(renderEditable.selectionStartInViewport.value, true);
expect(renderEditable.selectionEndInViewport.value, true);
// Scroll to the end so the selection is no longer visible. This should
// hide the toolbar, but schedule it to be shown once the selection is
// visible again.
scrollController.animateTo(
500.0,
duration: const Duration(milliseconds: 100),
curve: Curves.linear,
);
await tester.pumpAndSettle();
expect(contextMenuButtonFinder, findsNothing);
expect(renderEditable.selectionStartInViewport.value, false);
expect(renderEditable.selectionEndInViewport.value, false);
// Tap to change the selection. This will invalidate the scheduled
// toolbar.
await gesture.down(tester.getCenter(find.byType(TextField)));
await tester.pump();
await gesture.up();
await tester.pumpAndSettle();
// Scroll to the beginning where the selection was previously
// and the toolbar should not show because it was invalidated.
scrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 100),
curve: Curves.linear,
);
await tester.pumpAndSettle();
expect(contextMenuButtonFinder, findsNothing);
expect(renderEditable.selectionStartInViewport.value, false);
expect(renderEditable.selectionEndInViewport.value, false);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.iOS }),
);
testWidgets(
'Toolbar can re-appear after parent scrollable scrolls selection out of view on Android and iOS',
(WidgetTester tester) async {
final TextEditingController controller = _textEditingController(
text: 'Atwater Peel Sherbrooke Bonaventure',
);
final ScrollController scrollController = ScrollController();
addTearDown(scrollController.dispose);
final Key key1 = UniqueKey();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Center(
child: ListView(
controller: scrollController,
children: <Widget>[
TextField(controller: controller),
Container(
height: 1500.0,
key: key1,
),
],
),
),
),
),
);
final EditableTextState state =
tester.state<EditableTextState>(find.byType(EditableText));
final RenderEditable renderEditable = state.renderEditable;
final Offset textfieldStart = tester.getTopLeft(find.byType(TextField));
await tester.longPressAt(textfieldStart + const Offset(50.0, 9.0));
await tester.pumpAndSettle();
// Long press should select word at position and show toolbar.
expect(
controller.selection,
const TextSelection(baseOffset: 0, extentOffset: 7),
);
final bool targetPlatformIsiOS = defaultTargetPlatform == TargetPlatform.iOS;
final Finder contextMenuButtonFinder = targetPlatformIsiOS ? find.byType(CupertinoButton) : find.byType(TextButton);
// Context menu shows 5 buttons: cut, copy, paste, select all, share on Android.
// Context menu shows 6 buttons: cut, copy, paste, select all, lookup, share on iOS.
final int numberOfContextMenuButtons = targetPlatformIsiOS ? 6 : 5;
expect(
contextMenuButtonFinder,
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons),
);
// Scroll down, the TextField should no longer be in the viewport.
scrollController.animateTo(
scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 100),
curve: Curves.linear,
);
await tester.pumpAndSettle();
expect(find.byType(TextField), findsNothing);
expect(contextMenuButtonFinder, findsNothing);
// Scroll back up so the TextField is inside the viewport.
scrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 100),
curve: Curves.linear,
);
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
expect(
contextMenuButtonFinder,
isContextMenuProvidedByPlatform ? findsNothing : findsNWidgets(numberOfContextMenuButtons),
);
expect(renderEditable.selectionStartInViewport.value, true);
expect(renderEditable.selectionEndInViewport.value, true);
},
variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.iOS }),
);
testWidgets(
'long press tap cannot initiate a double tap',
(WidgetTester tester) async {
@ -17168,7 +17527,7 @@ void main() {
);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.android, TargetPlatform.iOS }));
testWidgets('text selection toolbar is hidden on tap down', (WidgetTester tester) async {
testWidgets('text selection toolbar is hidden on tap down on desktop platforms', (WidgetTester tester) async {
final TextEditingController controller = _textEditingController(
text: 'blah1 blah2',
);
@ -17212,7 +17571,7 @@ void main() {
expect(find.byType(AdaptiveTextSelectionToolbar), findsNothing);
},
skip: isContextMenuProvidedByPlatform, // [intended] only applies to platforms where we supply the context menu.
variant: TargetPlatformVariant.all(excluding: <TargetPlatform>{ TargetPlatform.iOS }),
variant: TargetPlatformVariant.all(excluding: TargetPlatformVariant.mobile().values),
);
testWidgets('Text processing actions are added to the toolbar', (WidgetTester tester) async {

View file

@ -6149,17 +6149,16 @@ void main() {
scrollable.controller!.jumpTo(50.0);
await tester.pumpAndSettle();
// Find the toolbar fade transition after the toolbar has been hidden.
// Try to find the toolbar fade transition after the toolbar has been hidden
// as a result of a scroll. This removes the toolbar overlay entry so no fade
// transition should be found.
final List<FadeTransition> transitionsAfter = find.descendant(
of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarWrapper'),
matching: find.byType(FadeTransition),
).evaluate().map((Element e) => e.widget).cast<FadeTransition>().toList();
expect(transitionsAfter.length, 1);
final FadeTransition toolbarAfter = transitionsAfter[0];
expect(toolbarAfter.opacity.value, 0.0);
expect(transitionsAfter.length, 0);
expect(state.selectionOverlay, isNotNull);
expect(state.selectionOverlay!.toolbarIsVisible, false);
// On web, we don't show the Flutter toolbar and instead rely on the browser
// toolbar. Until we change that, this test should remain skipped.
@ -9564,8 +9563,8 @@ void main() {
),
);
expect(scrollController1.attached, isTrue);
expect(scrollController2.attached, isFalse);
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
expect(state.widget.scrollController, scrollController1);
// Change scrollController to controller 2.
await tester.pumpWidget(
@ -9581,8 +9580,8 @@ void main() {
),
);
expect(scrollController1.attached, isFalse);
expect(scrollController2.attached, isTrue);
expect(state.widget.scrollController, scrollController2);
// Changing scrollController to null.
await tester.pumpWidget(
@ -9597,8 +9596,7 @@ void main() {
),
);
expect(scrollController1.attached, isFalse);
expect(scrollController2.attached, isFalse);
expect(state.widget.scrollController, isNull);
// Change scrollController to back controller 2.
await tester.pumpWidget(
@ -9614,8 +9612,7 @@ void main() {
),
);
expect(scrollController1.attached, isFalse);
expect(scrollController2.attached, isTrue);
expect(state.widget.scrollController, scrollController2);
});
testWidgets('getLocalRectForCaret does not throw when it sees an infinite point', (WidgetTester tester) async {