Repository navigation
[TextInput] minor fixes #87973
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[TextInput] minor fixes #87973
Changes from all commits
4696d1e
26baedc
5084d29
cdf6d12
7fb1b6b
754ca2b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like this, I think it helps alleviate the confusion of -1,-1 selection. |
||
| } | ||
| return other.baseOffset == baseOffset | ||
| && other.extentOffset == extentOffset | ||
| && other.affinity == affinity | ||
| && (!isCollapsed || other.affinity == affinity) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
So it appears we don't have separate text affinities for each endpoint (I wonder why?) and the affinity is only meaningful when the selection is collapsed.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I just noticed this same thing! I'm worried it may be an oversight. I'm pretty sure we have code that does check the affinity when it's not collapsed, too. I'm going to create an issue for this.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| && 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. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah since the animation controller needs to reference |
||
| 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); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Making sure I understand what's happening in this test. By pumping a new app, the old EditableText has its
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No it's just replacing |
||
| 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; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍 Yours is easier to read in my opinion.