diff --git a/packages/flutter/lib/src/services/text_editing.dart b/packages/flutter/lib/src/services/text_editing.dart index a562debbecd..43bc0e1b45e 100644 --- a/packages/flutter/lib/src/services/text_editing.dart +++ b/packages/flutter/lib/src/services/text_editing.dart @@ -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. diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 607f329c898..726514a285d 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -1535,9 +1535,13 @@ class EditableTextState extends State 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 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 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 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 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 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 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 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 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 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 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); } diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 21899d447f4..98dfb0483f2 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -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', ), ); } diff --git a/packages/flutter/test/services/text_input_test.dart b/packages/flutter/test/services/text_input_test.dart index ca5b626c613..93e4e3e7648 100644 --- a/packages/flutter/test/services/text_input_test.dart +++ b/packages/flutter/test/services/text_input_test.dart @@ -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; diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 500e23fabf5..cfba8448034 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -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 key = GlobalKey(); final String text = List.generate(64, (int index) => index).join('\n'); @@ -8439,3 +8551,7 @@ class _MyMoveSelectionRightTextAction extends TextEditingAction { onInvoke(); } } + +class _TestScrollController extends ScrollController { + bool get attached => hasListeners; +}