mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Fix text selection handles showing outside the visible text region (#24476)
Don't show handles outside the text field's boundary.
This commit is contained in:
parent
89a51272d5
commit
88a477cace
@ -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/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<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 {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user