Unverified Commit 88a477ca authored by sjindel-google's avatar sjindel-google Committed by GitHub

Fix text selection handles showing outside the visible text region (#24476)

Don't show handles outside the text field's boundary.
parent 89a51272
......@@ -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<bool> get selectionStartInViewport => _selectionStartInViewport;
final ValueNotifier<bool> _selectionStartInViewport = ValueNotifier<bool>(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<bool> get selectionEndInViewport => _selectionEndInViewport;
final ValueNotifier<bool> _selectionEndInViewport = ValueNotifier<bool>(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) {
......
......@@ -4,11 +4,12 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show kDoubleTapTimeout, kDoubleTapSlop;
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/gestures.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<double> get _handleOpacity => _handleController.view;
Animation<double> 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<bool> 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<double> 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: <Widget>[
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: <Widget>[
Positioned(
left: point.dx,
top: point.dy,
child: widget.selectionControls.buildHandle(
context,
type,
widget.renderObject.preferredLineHeight,
),
),
),
],
],
),
),
),
)
);
}
......
......@@ -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<EditableTextState> editableTextKey =
GlobalKey<EditableTextState>();
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<EditableTextState>(find.byType(EditableText));
final RenderEditable renderEditable = state.renderEditable;
final Scrollable scrollable = tester.widget<Scrollable>(find.byType(Scrollable));
bool leftVisibleBefore = false;
bool rightVisibleBefore = false;
Future<void> 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<Widget> 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> positioned =
find.byType(Positioned).evaluate().map((Element e) => e.widget).cast<Positioned>().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 {}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment