diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index 2f84c6ba1f..3e8d91e813 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -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: /// /// * diff --git a/packages/flutter/lib/src/material/selectable_text.dart b/packages/flutter/lib/src/material/selectable_text.dart index f19c264787..a77f12e553 100644 --- a/packages/flutter/lib/src/material/selectable_text.dart +++ b/packages/flutter/lib/src/material/selectable_text.dart @@ -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. diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index b81a0e4f3e..5c7989827b 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -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. diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 7ac1a21068..eb90b3eee8 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -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 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 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 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 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 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 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 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(); + 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(); + if (notificationScrollableState == null) { + return false; + } + while (currentContext != null) { + final ScrollableState? scrollableState = currentContext.findAncestorStateOfType(); + 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 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 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 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 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 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 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( + 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, + ), ), ), ), - ), - ); - }, + ); + }, + ), ), ), ), diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 4b5cff0733..6b60c5ce7d 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -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; diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index c036e945df..41b76b5587 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -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.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 { diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index d6259d0d6d..0299f3676b 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -11842,6 +11842,365 @@ void main() { variant: const TargetPlatformVariant({ 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(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.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: [ + Container( + height: 400, + key: key1, + ), + TextField(controller: controller), + Container( + height: 1000, + key: key2, + ), + ], + ), + ), + ), + ), + ); + + final EditableTextState state = + tester.state(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.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(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.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: [ + TextField(controller: controller), + Container( + height: 1500.0, + key: key1, + ), + ], + ), + ), + ), + ), + ); + + final EditableTextState state = + tester.state(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.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.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.iOS }), + variant: TargetPlatformVariant.all(excluding: TargetPlatformVariant.mobile().values), ); testWidgets('Text processing actions are added to the toolbar', (WidgetTester tester) async { diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 21702eaca2..f649be5255 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -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 transitionsAfter = find.descendant( of: find.byWidgetPredicate((Widget w) => '${w.runtimeType}' == '_SelectionToolbarWrapper'), matching: find.byType(FadeTransition), ).evaluate().map((Element e) => e.widget).cast().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(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 {