mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
[TextInput] minor fixes (#87973)
This commit is contained in:
parent
af4faf48dd
commit
c49eba6c3f
@ -94,27 +94,40 @@ class TextSelection extends TextRange {
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '${objectRuntimeType(this, 'TextSelection')}(baseOffset: $baseOffset, extentOffset: $extentOffset, affinity: $affinity, isDirectional: $isDirectional)';
|
||||
final String typeName = objectRuntimeType(this, 'TextSelection');
|
||||
if (!isValid) {
|
||||
return '$typeName.invalid';
|
||||
}
|
||||
return isCollapsed
|
||||
? '$typeName.collapsed(offset: $baseOffset, affinity: $affinity, isDirectional: $isDirectional)'
|
||||
: '$typeName(baseOffset: $baseOffset, extentOffset: $extentOffset, isDirectional: $isDirectional)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other))
|
||||
return true;
|
||||
return other is TextSelection
|
||||
&& other.baseOffset == baseOffset
|
||||
if (other is! TextSelection)
|
||||
return false;
|
||||
if (!isValid) {
|
||||
return !other.isValid;
|
||||
}
|
||||
return other.baseOffset == baseOffset
|
||||
&& other.extentOffset == extentOffset
|
||||
&& other.affinity == affinity
|
||||
&& (!isCollapsed || other.affinity == affinity)
|
||||
&& other.isDirectional == isDirectional;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(
|
||||
baseOffset.hashCode,
|
||||
extentOffset.hashCode,
|
||||
affinity.hashCode,
|
||||
isDirectional.hashCode,
|
||||
);
|
||||
int get hashCode {
|
||||
if (!isValid) {
|
||||
return hashValues(-1.hashCode, -1.hashCode, TextAffinity.downstream.hashCode);
|
||||
}
|
||||
|
||||
final int affinityHash = isCollapsed ? affinity.hashCode : TextAffinity.downstream.hashCode;
|
||||
return hashValues(baseOffset.hashCode, extentOffset.hashCode, affinityHash, isDirectional.hashCode);
|
||||
}
|
||||
|
||||
|
||||
/// Creates a new [TextSelection] based on the current selection, with the
|
||||
/// provided parameters overridden.
|
||||
|
@ -1535,9 +1535,13 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
TextInputConnection? _textInputConnection;
|
||||
TextSelectionOverlay? _selectionOverlay;
|
||||
|
||||
ScrollController? _scrollController;
|
||||
ScrollController? _internalScrollController;
|
||||
ScrollController get _scrollController => widget.scrollController ?? (_internalScrollController ??= ScrollController());
|
||||
|
||||
late AnimationController _cursorBlinkOpacityController;
|
||||
late final AnimationController _cursorBlinkOpacityController = AnimationController(
|
||||
vsync: this,
|
||||
duration: _fadeDuration,
|
||||
)..addListener(_onCursorColorTick);
|
||||
|
||||
final LayerLink _toolbarLayerLink = LayerLink();
|
||||
final LayerLink _startHandleLayerLink = LayerLink();
|
||||
@ -1576,7 +1580,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
// cursor position after the user has finished placing it.
|
||||
static const Duration _floatingCursorResetTime = Duration(milliseconds: 125);
|
||||
|
||||
late AnimationController _floatingCursorResetController;
|
||||
late final AnimationController _floatingCursorResetController = AnimationController(
|
||||
vsync: this,
|
||||
)..addListener(_onFloatingCursorResetTick);
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => widget.focusNode.hasFocus;
|
||||
@ -1610,12 +1616,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
widget.controller.addListener(_didChangeTextEditingValue);
|
||||
_focusAttachment = widget.focusNode.attach(context);
|
||||
widget.focusNode.addListener(_handleFocusChanged);
|
||||
_scrollController = widget.scrollController ?? ScrollController();
|
||||
_scrollController!.addListener(() { _selectionOverlay?.updateForScroll(); });
|
||||
_cursorBlinkOpacityController = AnimationController(vsync: this, duration: _fadeDuration);
|
||||
_cursorBlinkOpacityController.addListener(_onCursorColorTick);
|
||||
_floatingCursorResetController = AnimationController(vsync: this);
|
||||
_floatingCursorResetController.addListener(_onFloatingCursorResetTick);
|
||||
_scrollController.addListener(_updateSelectionOverlayForScroll);
|
||||
_cursorVisibilityNotifier.value = widget.showCursor;
|
||||
}
|
||||
|
||||
@ -1663,6 +1664,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
updateKeepAlive();
|
||||
}
|
||||
|
||||
if (widget.scrollController != oldWidget.scrollController) {
|
||||
(oldWidget.scrollController ?? _internalScrollController)?.removeListener(_updateSelectionOverlayForScroll);
|
||||
_scrollController.addListener(_updateSelectionOverlayForScroll);
|
||||
}
|
||||
|
||||
if (!_shouldCreateInputConnection) {
|
||||
_closeInputConnectionIfNeeded();
|
||||
} else if (oldWidget.readOnly && _hasFocus) {
|
||||
@ -1696,14 +1702,15 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_internalScrollController?.dispose();
|
||||
_currentAutofillScope?.unregister(autofillId);
|
||||
widget.controller.removeListener(_didChangeTextEditingValue);
|
||||
_cursorBlinkOpacityController.removeListener(_onCursorColorTick);
|
||||
_floatingCursorResetController.removeListener(_onFloatingCursorResetTick);
|
||||
_floatingCursorResetController.dispose();
|
||||
_closeInputConnectionIfNeeded();
|
||||
assert(!_hasInputConnection);
|
||||
_stopCursorTimer();
|
||||
assert(_cursorTimer == null);
|
||||
_cursorTimer?.cancel();
|
||||
_cursorTimer = null;
|
||||
_cursorBlinkOpacityController.dispose();
|
||||
_selectionOverlay?.dispose();
|
||||
_selectionOverlay = null;
|
||||
_focusAttachment!.detach();
|
||||
@ -2009,8 +2016,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
// `renderEditable.preferredLineHeight`, before the target scroll offset is
|
||||
// calculated.
|
||||
RevealedOffset _getOffsetToRevealCaret(Rect rect) {
|
||||
if (!_scrollController!.position.allowImplicitScrolling)
|
||||
return RevealedOffset(offset: _scrollController!.offset, rect: rect);
|
||||
if (!_scrollController.position.allowImplicitScrolling)
|
||||
return RevealedOffset(offset: _scrollController.offset, rect: rect);
|
||||
|
||||
final Size editableSize = renderEditable.size;
|
||||
final double additionalOffset;
|
||||
@ -2042,13 +2049,13 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
|
||||
// No overscrolling when encountering tall fonts/scripts that extend past
|
||||
// the ascent.
|
||||
final double targetOffset = (additionalOffset + _scrollController!.offset)
|
||||
final double targetOffset = (additionalOffset + _scrollController.offset)
|
||||
.clamp(
|
||||
_scrollController!.position.minScrollExtent,
|
||||
_scrollController!.position.maxScrollExtent,
|
||||
_scrollController.position.minScrollExtent,
|
||||
_scrollController.position.maxScrollExtent,
|
||||
);
|
||||
|
||||
final double offsetDelta = _scrollController!.offset - targetOffset;
|
||||
final double offsetDelta = _scrollController.offset - targetOffset;
|
||||
return RevealedOffset(rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset);
|
||||
}
|
||||
|
||||
@ -2152,6 +2159,10 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
}
|
||||
}
|
||||
|
||||
void _updateSelectionOverlayForScroll() {
|
||||
_selectionOverlay?.updateForScroll();
|
||||
}
|
||||
|
||||
@pragma('vm:notify-debugger-on-exception')
|
||||
void _handleSelectionChanged(TextSelection selection, SelectionChangedCause? cause) {
|
||||
// We return early if the selection is not valid. This can happen when the
|
||||
@ -2229,7 +2240,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
_showCaretOnScreenScheduled = true;
|
||||
SchedulerBinding.instance!.addPostFrameCallback((Duration _) {
|
||||
_showCaretOnScreenScheduled = false;
|
||||
if (_currentCaretRect == null || !_scrollController!.hasClients) {
|
||||
if (_currentCaretRect == null || !_scrollController.hasClients) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -2262,7 +2273,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
|
||||
final RevealedOffset targetOffset = _getOffsetToRevealCaret(_currentCaretRect!);
|
||||
|
||||
_scrollController!.animateTo(
|
||||
_scrollController.animateTo(
|
||||
targetOffset.offset,
|
||||
duration: _caretAnimationDuration,
|
||||
curve: _caretAnimationCurve,
|
||||
@ -2543,7 +2554,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
final Rect localRect = renderEditable.getLocalRectForCaret(position);
|
||||
final RevealedOffset targetOffset = _getOffsetToRevealCaret(localRect);
|
||||
|
||||
_scrollController!.jumpTo(targetOffset.offset);
|
||||
_scrollController.jumpTo(targetOffset.offset);
|
||||
renderEditable.showOnScreen(rect: targetOffset.rect);
|
||||
}
|
||||
|
||||
|
@ -3969,9 +3969,8 @@ void main() {
|
||||
error.toStringDeep(),
|
||||
equalsIgnoringHashCodes(
|
||||
'FlutterError\n'
|
||||
' invalid text selection: TextSelection(baseOffset: 10,\n'
|
||||
' extentOffset: 10, affinity: TextAffinity.downstream,\n'
|
||||
' isDirectional: false)\n',
|
||||
' invalid text selection: TextSelection.collapsed(offset: 10,\n'
|
||||
' affinity: TextAffinity.downstream, isDirectional: false)\n',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -12,6 +12,39 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('TextSelection', () {
|
||||
test('The invalid selection is a singleton', () {
|
||||
const TextSelection invalidSelection1 = TextSelection(
|
||||
baseOffset: -1,
|
||||
extentOffset: 0,
|
||||
affinity: TextAffinity.downstream,
|
||||
isDirectional: true,
|
||||
);
|
||||
const TextSelection invalidSelection2 = TextSelection(baseOffset: 123,
|
||||
extentOffset: -1,
|
||||
affinity: TextAffinity.upstream,
|
||||
isDirectional: false,
|
||||
);
|
||||
expect(invalidSelection1, invalidSelection2);
|
||||
expect(invalidSelection1.hashCode, invalidSelection2.hashCode);
|
||||
});
|
||||
|
||||
test('TextAffinity does not affect equivalence when the selection is not collapsed', () {
|
||||
const TextSelection selection1 = TextSelection(
|
||||
baseOffset: 1,
|
||||
extentOffset: 2,
|
||||
affinity: TextAffinity.downstream,
|
||||
);
|
||||
const TextSelection selection2 = TextSelection(
|
||||
baseOffset: 1,
|
||||
extentOffset: 2,
|
||||
affinity: TextAffinity.upstream,
|
||||
);
|
||||
expect(selection1, selection2);
|
||||
expect(selection1.hashCode, selection2.hashCode);
|
||||
});
|
||||
});
|
||||
|
||||
group('TextInput message channels', () {
|
||||
late FakeTextChannel fakeTextChannel;
|
||||
|
||||
|
@ -5717,6 +5717,81 @@ void main() {
|
||||
expect(scrollController.offset, 0);
|
||||
});
|
||||
|
||||
testWidgets('can change scroll controller', (WidgetTester tester) async {
|
||||
final _TestScrollController scrollController1 = _TestScrollController();
|
||||
final _TestScrollController scrollController2 = _TestScrollController();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: EditableText(
|
||||
controller: TextEditingController(text: 'A' * 1000),
|
||||
maxLines: 1,
|
||||
focusNode: FocusNode(),
|
||||
style: textStyle,
|
||||
cursorColor: Colors.blue,
|
||||
backgroundCursorColor: Colors.grey,
|
||||
scrollController: scrollController1,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(scrollController1.attached, isTrue);
|
||||
expect(scrollController2.attached, isFalse);
|
||||
|
||||
// Change scrollController to controller 2.
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: EditableText(
|
||||
controller: TextEditingController(text: 'A' * 1000),
|
||||
maxLines: 1,
|
||||
focusNode: FocusNode(),
|
||||
style: textStyle,
|
||||
cursorColor: Colors.blue,
|
||||
backgroundCursorColor: Colors.grey,
|
||||
scrollController: scrollController2,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(scrollController1.attached, isFalse);
|
||||
expect(scrollController2.attached, isTrue);
|
||||
|
||||
// Changing scrollController to null.
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: EditableText(
|
||||
controller: TextEditingController(text: 'A' * 1000),
|
||||
maxLines: 1,
|
||||
focusNode: FocusNode(),
|
||||
style: textStyle,
|
||||
cursorColor: Colors.blue,
|
||||
backgroundCursorColor: Colors.grey,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(scrollController1.attached, isFalse);
|
||||
expect(scrollController2.attached, isFalse);
|
||||
|
||||
// Change scrollController to back controller 2.
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: EditableText(
|
||||
controller: TextEditingController(text: 'A' * 1000),
|
||||
maxLines: 1,
|
||||
focusNode: FocusNode(),
|
||||
style: textStyle,
|
||||
cursorColor: Colors.blue,
|
||||
backgroundCursorColor: Colors.grey,
|
||||
scrollController: scrollController2,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(scrollController1.attached, isFalse);
|
||||
expect(scrollController2.attached, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('getLocalRectForCaret does not throw when it sees an infinite point', (WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
@ -8110,6 +8185,43 @@ void main() {
|
||||
// On web, using keyboard for selection is handled by the browser.
|
||||
}, skip: kIsWeb); // [intended]
|
||||
|
||||
testWidgets('EditableText does not leak animation controllers', (WidgetTester tester) async {
|
||||
final FocusNode focusNode = FocusNode();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: EditableText(
|
||||
autofocus: true,
|
||||
controller: TextEditingController(text: 'A'),
|
||||
maxLines: 1,
|
||||
focusNode: focusNode,
|
||||
style: textStyle,
|
||||
cursorColor: Colors.blue,
|
||||
backgroundCursorColor: Colors.grey,
|
||||
cursorOpacityAnimates: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(focusNode.hasPrimaryFocus, isTrue);
|
||||
final EditableTextState state = tester.state(find.byType(EditableText));
|
||||
|
||||
state.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.Start, offset: Offset.zero));
|
||||
|
||||
// Start the cursor blink opacity animation controller.
|
||||
// _kCursorBlinkWaitForStart
|
||||
await tester.pump(const Duration(milliseconds: 150));
|
||||
// _kCursorBlinkHalfPeriod
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
// Start the floating cursor reset animation controller.
|
||||
state.updateFloatingCursor(RawFloatingCursorPoint(state: FloatingCursorDragState.End, offset: Offset.zero));
|
||||
|
||||
expect(tester.binding.transientCallbackCount, 2);
|
||||
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
expect(tester.hasRunningAnimations, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('Selection will be scrolled into view with SelectionChangedCause', (WidgetTester tester) async {
|
||||
final GlobalKey<EditableTextState> key = GlobalKey<EditableTextState>();
|
||||
final String text = List<int>.generate(64, (int index) => index).join('\n');
|
||||
@ -8439,3 +8551,7 @@ class _MyMoveSelectionRightTextAction extends TextEditingAction<Intent> {
|
||||
onInvoke();
|
||||
}
|
||||
}
|
||||
|
||||
class _TestScrollController extends ScrollController {
|
||||
bool get attached => hasListeners;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user