diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index adbbc63f793..9ad43f05b23 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -255,6 +255,50 @@ class RenderEditable extends RenderBox { Rect _lastCaretRect; + /// Track whether position of the start of the selected text is within the viewport. + /// + /// For example, if the text contains "Hello World", and the user selects + /// "Hello", then scrolls so only "World" is visible, this will become false. + /// If the user scrolls back so that the "H" is visible again, this will + /// become true. + /// + /// This bool indicates whether the text is scrolled so that the handle is + /// inside the text field viewport, as opposed to whether it is actually + /// visible on the screen. + ValueListenable get selectionStartInViewport => _selectionStartInViewport; + final ValueNotifier _selectionStartInViewport = ValueNotifier(true); + + /// Track whether position of the end of the selected text is within the viewport. + /// + /// For example, if the text contains "Hello World", and the user selects + /// "World", then scrolls so only "Hello" is visible, this will become + /// 'false'. If the user scrolls back so that the "d" is visible again, this + /// will become 'true'. + /// + /// This bool indicates whether the text is scrolled so that the handle is + /// inside the text field viewport, as opposed to whether it is actually + /// visible on the screen. + ValueListenable get selectionEndInViewport => _selectionEndInViewport; + final ValueNotifier _selectionEndInViewport = ValueNotifier(true); + + void _updateSelectionExtentsVisibility(Offset effectiveOffset) { + final Rect visibleRegion = Offset.zero & size; + + final Offset startOffset = _textPainter.getOffsetForCaret( + TextPosition(offset: _selection.start, affinity: _selection.affinity), + Rect.zero + ); + + _selectionStartInViewport.value = visibleRegion.contains(startOffset + effectiveOffset); + + final Offset endOffset = _textPainter.getOffsetForCaret( + TextPosition(offset: _selection.end, affinity: _selection.affinity), + Rect.zero + ); + + _selectionEndInViewport.value = visibleRegion.contains(endOffset + effectiveOffset); + } + static const int _kLeftArrowCode = 21; static const int _kRightArrowCode = 22; static const int _kUpArrowCode = 19; @@ -1570,6 +1614,7 @@ class RenderEditable extends RenderBox { showCaret = true; else if (!_selection.isCollapsed && _selectionColor != null) showSelection = true; + _updateSelectionExtentsVisibility(effectiveOffset); } if (showSelection) { diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 081d3fda0dc..d735280241c 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -4,11 +4,12 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart' show kDoubleTapTimeout, kDoubleTapSlop; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/scheduler.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; import 'basic.dart'; import 'container.dart'; @@ -16,6 +17,7 @@ import 'editable_text.dart'; import 'framework.dart'; import 'gesture_detector.dart'; import 'overlay.dart'; +import 'ticker_provider.dart'; import 'transitions.dart'; export 'package:flutter/services.dart' show TextSelectionDelegate; @@ -253,8 +255,7 @@ class TextSelectionOverlay { _value = value { final OverlayState overlay = Overlay.of(context); assert(overlay != null); - _handleController = AnimationController(duration: _fadeDuration, vsync: overlay); - _toolbarController = AnimationController(duration: _fadeDuration, vsync: overlay); + _toolbarController = AnimationController(duration: fadeDuration, vsync: overlay); } /// The context in which the selection handles should appear. @@ -299,11 +300,10 @@ class TextSelectionOverlay { /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for the different behaviors. final DragStartBehavior dragStartBehavior; - /// Controls the fade-in animations. - static const Duration _fadeDuration = Duration(milliseconds: 150); - AnimationController _handleController; + /// Controls the fade-in and fade-out animations for the toolbar and handles. + static const Duration fadeDuration = Duration(milliseconds: 150); + AnimationController _toolbarController; - Animation get _handleOpacity => _handleController.view; Animation get _toolbarOpacity => _toolbarController.view; TextEditingValue _value; @@ -325,7 +325,6 @@ class TextSelectionOverlay { OverlayEntry(builder: (BuildContext context) => _buildHandle(context, _TextSelectionHandlePosition.end)), ]; Overlay.of(context, debugRequiredFor: debugRequiredFor).insertAll(_handles); - _handleController.forward(from: 0.0); } /// Shows the toolbar by inserting it into the [context]'s overlay. @@ -388,14 +387,12 @@ class TextSelectionOverlay { _toolbar?.remove(); _toolbar = null; - _handleController.stop(); _toolbarController.stop(); } /// Final cleanup. void dispose() { hide(); - _handleController.dispose(); _toolbarController.dispose(); } @@ -403,18 +400,15 @@ class TextSelectionOverlay { if ((_selection.isCollapsed && position == _TextSelectionHandlePosition.end) || selectionControls == null) return Container(); // hide the second handle when collapsed - return FadeTransition( - opacity: _handleOpacity, - child: _TextSelectionHandleOverlay( - onSelectionHandleChanged: (TextSelection newSelection) { _handleSelectionHandleChanged(newSelection, position); }, - onSelectionHandleTapped: _handleSelectionHandleTapped, - layerLink: layerLink, - renderObject: renderObject, - selection: _selection, - selectionControls: selectionControls, - position: position, - dragStartBehavior: dragStartBehavior, - ), + return _TextSelectionHandleOverlay( + onSelectionHandleChanged: (TextSelection newSelection) { _handleSelectionHandleChanged(newSelection, position); }, + onSelectionHandleTapped: _handleSelectionHandleTapped, + layerLink: layerLink, + renderObject: renderObject, + selection: _selection, + selectionControls: selectionControls, + position: position, + dragStartBehavior: dragStartBehavior, ); } @@ -498,11 +492,58 @@ class _TextSelectionHandleOverlay extends StatefulWidget { @override _TextSelectionHandleOverlayState createState() => _TextSelectionHandleOverlayState(); + + ValueListenable get _visibility { + switch (position) { + case _TextSelectionHandlePosition.start: + return renderObject.selectionStartInViewport; + case _TextSelectionHandlePosition.end: + return renderObject.selectionEndInViewport; + } + return null; + } } -class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay> { +class _TextSelectionHandleOverlayState + extends State<_TextSelectionHandleOverlay> with SingleTickerProviderStateMixin { Offset _dragPosition; + AnimationController _controller; + Animation get _opacity => _controller.view; + + @override + void initState() { + super.initState(); + + _controller = AnimationController(duration: TextSelectionOverlay.fadeDuration, vsync: this); + + _handleVisibilityChanged(); + widget._visibility.addListener(_handleVisibilityChanged); + } + + void _handleVisibilityChanged() { + if (widget._visibility.value) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + + @override + void didUpdateWidget(_TextSelectionHandleOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + oldWidget._visibility.removeListener(_handleVisibilityChanged); + _handleVisibilityChanged(); + widget._visibility.addListener(_handleVisibilityChanged); + } + + @override + void dispose() { + widget._visibility.removeListener(_handleVisibilityChanged); + _controller.dispose(); + super.dispose(); + } + void _handleDragStart(DragStartDetails details) { _dragPosition = details.globalPosition + Offset(0.0, -widget.selectionControls.handleSize.height); } @@ -562,31 +603,41 @@ class _TextSelectionHandleOverlayState extends State<_TextSelectionHandleOverlay break; } + final Size viewport = widget.renderObject.size; + point = Offset( + point.dx.clamp(0.0, viewport.width), + point.dy.clamp(0.0, viewport.height), + ); + + return CompositedTransformFollower( link: widget.layerLink, showWhenUnlinked: false, - child: GestureDetector( - dragStartBehavior: widget.dragStartBehavior, - onPanStart: _handleDragStart, - onPanUpdate: _handleDragUpdate, - onTap: _handleTap, - child: Stack( - // Always let the selection handles draw outside of the conceptual - // box where (0,0) is the top left corner of the RenderEditable. - overflow: Overflow.visible, - children: [ - Positioned( - left: point.dx, - top: point.dy, - child: widget.selectionControls.buildHandle( - context, - type, - widget.renderObject.preferredLineHeight, + child: FadeTransition( + opacity: _opacity, + child: GestureDetector( + dragStartBehavior: widget.dragStartBehavior, + onPanStart: _handleDragStart, + onPanUpdate: _handleDragUpdate, + onTap: _handleTap, + child: Stack( + // Always let the selection handles draw outside of the conceptual + // box where (0,0) is the top left corner of the RenderEditable. + overflow: Overflow.visible, + children: [ + Positioned( + left: point.dx, + top: point.dy, + child: widget.selectionControls.buildHandle( + context, + type, + widget.renderObject.preferredLineHeight, + ), ), - ), - ], + ], + ), ), - ), + ) ); } diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 11d0be495f2..f12f1145e1b 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -1941,6 +1941,145 @@ void main() { expect(renderEditable.text.text, 'text composing text'); expect(renderEditable.text.style.decoration, isNull); }); + + testWidgets('text selection handle visibility', (WidgetTester tester) async { + final GlobalKey editableTextKey = + GlobalKey(); + + const String testText = 'XXXXX XXXXX'; + final TextEditingController controller = TextEditingController(text: testText); + + final Widget widget = MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 100, + child: EditableText( + key: editableTextKey, + controller: controller, + focusNode: FocusNode(), + style: Typography(platform: TargetPlatform.android).black.subhead, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + ), + ), + ), + ); + + await tester.pumpWidget(widget); + + final EditableTextState state = + tester.state(find.byType(EditableText)); + final RenderEditable renderEditable = state.renderEditable; + final Scrollable scrollable = tester.widget(find.byType(Scrollable)); + + bool leftVisibleBefore = false; + bool rightVisibleBefore = false; + + Future verifyVisibility( + bool leftVisible, + Symbol leftPosition, + bool rightVisible, + Symbol rightPosition, + ) async { + await tester.pump(); + + // Check the signal from RenderEditable about whether they're within the + // viewport. + + expect(renderEditable.selectionStartInViewport.value, equals(leftVisible)); + expect(renderEditable.selectionEndInViewport.value, equals(rightVisible)); + + // Check that the animations are functional and going in the right + // direction. + + final List transitions = + find.byType(FadeTransition).evaluate().map((Element e) => e.widget).toList(); + final FadeTransition left = transitions[1]; + final FadeTransition right = transitions[2]; + + if (leftVisibleBefore) + expect(left.opacity.value, equals(1.0)); + if (rightVisibleBefore) + expect(right.opacity.value, equals(1.0)); + + await tester.pump(TextSelectionOverlay.fadeDuration ~/ 2); + + if (leftVisible != leftVisibleBefore) + expect(left.opacity.value, equals(0.5)); + if (rightVisible != rightVisibleBefore) + expect(right.opacity.value, equals(0.5)); + + await tester.pump(TextSelectionOverlay.fadeDuration ~/ 2); + + if (leftVisible) + expect(left.opacity.value, equals(1.0)); + if (rightVisible) + expect(right.opacity.value, equals(1.0)); + + leftVisibleBefore = leftVisible; + rightVisibleBefore = rightVisible; + + // Check that the handles' positions are correct (clamped within the + // viewport but not stuck). + + final List positioned = + find.byType(Positioned).evaluate().map((Element e) => e.widget).cast().toList(); + + final Size viewport = renderEditable.size; + + void testPosition(double pos, Symbol expected) { + if (expected == #left) + expect(pos, equals(0.0)); + if (expected == #right) + expect(pos, equals(viewport.width)); + if (expected == #middle) + expect(pos, inExclusiveRange(0.0, viewport.width)); + } + + testPosition(positioned[0].left, leftPosition); + testPosition(positioned[1].left, rightPosition); + } + + // Select the first word. Both handles should be visible. + await tester.tapAt(const Offset(20, 10)); + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + await tester.pump(); + await verifyVisibility(true, #left, true, #middle); + + // Drag the text slightly so the first word is partially visible. Only the + // right handle should be visible. + scrollable.controller.jumpTo(20.0); + await verifyVisibility(false, #left, true, #middle); + + // Drag the text all the way to the left so the first word is not visible at + // all (and the second word is fully visible). Both handles should be + // invisible now. + scrollable.controller.jumpTo(200.0); + await verifyVisibility(false, #left, false, #left); + + // Tap to unselect. + await tester.tap(find.byKey(editableTextKey)); + await tester.pump(); + + // Now that the second word has been dragged fully into view, select it. + await tester.tapAt(const Offset(80, 10)); + renderEditable.selectWord(cause: SelectionChangedCause.longPress); + await tester.pump(); + await verifyVisibility(true, #middle, true, #middle); + + // Drag the text slightly to the right. Only the left handle should be + // visible. + scrollable.controller.jumpTo(150); + await verifyVisibility(true, #middle, false, #right); + + // Drag the text all the way to the right, so the second word is not visible + // at all. Again, both handles should be invisible. + scrollable.controller.jumpTo(0); + await verifyVisibility(false, #right, false, #right); + }); } class MockTextSelectionControls extends Mock implements TextSelectionControls {}