Unverified Commit c49eba6c authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

[TextInput] minor fixes (#87973)

parent af4faf48
...@@ -94,27 +94,40 @@ class TextSelection extends TextRange { ...@@ -94,27 +94,40 @@ class TextSelection extends TextRange {
@override @override
String toString() { 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 @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) if (identical(this, other))
return true; return true;
return other is TextSelection if (other is! TextSelection)
&& other.baseOffset == baseOffset return false;
if (!isValid) {
return !other.isValid;
}
return other.baseOffset == baseOffset
&& other.extentOffset == extentOffset && other.extentOffset == extentOffset
&& other.affinity == affinity && (!isCollapsed || other.affinity == affinity)
&& other.isDirectional == isDirectional; && other.isDirectional == isDirectional;
} }
@override @override
int get hashCode => hashValues( int get hashCode {
baseOffset.hashCode, if (!isValid) {
extentOffset.hashCode, return hashValues(-1.hashCode, -1.hashCode, TextAffinity.downstream.hashCode);
affinity.hashCode, }
isDirectional.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 /// Creates a new [TextSelection] based on the current selection, with the
/// provided parameters overridden. /// provided parameters overridden.
......
...@@ -1535,9 +1535,13 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1535,9 +1535,13 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
TextInputConnection? _textInputConnection; TextInputConnection? _textInputConnection;
TextSelectionOverlay? _selectionOverlay; 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 _toolbarLayerLink = LayerLink();
final LayerLink _startHandleLayerLink = LayerLink(); final LayerLink _startHandleLayerLink = LayerLink();
...@@ -1576,7 +1580,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1576,7 +1580,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// cursor position after the user has finished placing it. // cursor position after the user has finished placing it.
static const Duration _floatingCursorResetTime = Duration(milliseconds: 125); static const Duration _floatingCursorResetTime = Duration(milliseconds: 125);
late AnimationController _floatingCursorResetController; late final AnimationController _floatingCursorResetController = AnimationController(
vsync: this,
)..addListener(_onFloatingCursorResetTick);
@override @override
bool get wantKeepAlive => widget.focusNode.hasFocus; bool get wantKeepAlive => widget.focusNode.hasFocus;
...@@ -1610,12 +1616,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1610,12 +1616,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
widget.controller.addListener(_didChangeTextEditingValue); widget.controller.addListener(_didChangeTextEditingValue);
_focusAttachment = widget.focusNode.attach(context); _focusAttachment = widget.focusNode.attach(context);
widget.focusNode.addListener(_handleFocusChanged); widget.focusNode.addListener(_handleFocusChanged);
_scrollController = widget.scrollController ?? ScrollController(); _scrollController.addListener(_updateSelectionOverlayForScroll);
_scrollController!.addListener(() { _selectionOverlay?.updateForScroll(); });
_cursorBlinkOpacityController = AnimationController(vsync: this, duration: _fadeDuration);
_cursorBlinkOpacityController.addListener(_onCursorColorTick);
_floatingCursorResetController = AnimationController(vsync: this);
_floatingCursorResetController.addListener(_onFloatingCursorResetTick);
_cursorVisibilityNotifier.value = widget.showCursor; _cursorVisibilityNotifier.value = widget.showCursor;
} }
...@@ -1663,6 +1664,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1663,6 +1664,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
updateKeepAlive(); updateKeepAlive();
} }
if (widget.scrollController != oldWidget.scrollController) {
(oldWidget.scrollController ?? _internalScrollController)?.removeListener(_updateSelectionOverlayForScroll);
_scrollController.addListener(_updateSelectionOverlayForScroll);
}
if (!_shouldCreateInputConnection) { if (!_shouldCreateInputConnection) {
_closeInputConnectionIfNeeded(); _closeInputConnectionIfNeeded();
} else if (oldWidget.readOnly && _hasFocus) { } else if (oldWidget.readOnly && _hasFocus) {
...@@ -1696,14 +1702,15 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -1696,14 +1702,15 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override @override
void dispose() { void dispose() {
_internalScrollController?.dispose();
_currentAutofillScope?.unregister(autofillId); _currentAutofillScope?.unregister(autofillId);
widget.controller.removeListener(_didChangeTextEditingValue); widget.controller.removeListener(_didChangeTextEditingValue);
_cursorBlinkOpacityController.removeListener(_onCursorColorTick); _floatingCursorResetController.dispose();
_floatingCursorResetController.removeListener(_onFloatingCursorResetTick);
_closeInputConnectionIfNeeded(); _closeInputConnectionIfNeeded();
assert(!_hasInputConnection); assert(!_hasInputConnection);
_stopCursorTimer(); _cursorTimer?.cancel();
assert(_cursorTimer == null); _cursorTimer = null;
_cursorBlinkOpacityController.dispose();
_selectionOverlay?.dispose(); _selectionOverlay?.dispose();
_selectionOverlay = null; _selectionOverlay = null;
_focusAttachment!.detach(); _focusAttachment!.detach();
...@@ -2009,8 +2016,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2009,8 +2016,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// `renderEditable.preferredLineHeight`, before the target scroll offset is // `renderEditable.preferredLineHeight`, before the target scroll offset is
// calculated. // calculated.
RevealedOffset _getOffsetToRevealCaret(Rect rect) { RevealedOffset _getOffsetToRevealCaret(Rect rect) {
if (!_scrollController!.position.allowImplicitScrolling) if (!_scrollController.position.allowImplicitScrolling)
return RevealedOffset(offset: _scrollController!.offset, rect: rect); return RevealedOffset(offset: _scrollController.offset, rect: rect);
final Size editableSize = renderEditable.size; final Size editableSize = renderEditable.size;
final double additionalOffset; final double additionalOffset;
...@@ -2042,13 +2049,13 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2042,13 +2049,13 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// No overscrolling when encountering tall fonts/scripts that extend past // No overscrolling when encountering tall fonts/scripts that extend past
// the ascent. // the ascent.
final double targetOffset = (additionalOffset + _scrollController!.offset) final double targetOffset = (additionalOffset + _scrollController.offset)
.clamp( .clamp(
_scrollController!.position.minScrollExtent, _scrollController.position.minScrollExtent,
_scrollController!.position.maxScrollExtent, _scrollController.position.maxScrollExtent,
); );
final double offsetDelta = _scrollController!.offset - targetOffset; final double offsetDelta = _scrollController.offset - targetOffset;
return RevealedOffset(rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset); return RevealedOffset(rect: rect.shift(unitOffset * offsetDelta), offset: targetOffset);
} }
...@@ -2152,6 +2159,10 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2152,6 +2159,10 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
} }
} }
void _updateSelectionOverlayForScroll() {
_selectionOverlay?.updateForScroll();
}
@pragma('vm:notify-debugger-on-exception') @pragma('vm:notify-debugger-on-exception')
void _handleSelectionChanged(TextSelection selection, SelectionChangedCause? cause) { void _handleSelectionChanged(TextSelection selection, SelectionChangedCause? cause) {
// We return early if the selection is not valid. This can happen when the // 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 ...@@ -2229,7 +2240,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_showCaretOnScreenScheduled = true; _showCaretOnScreenScheduled = true;
SchedulerBinding.instance!.addPostFrameCallback((Duration _) { SchedulerBinding.instance!.addPostFrameCallback((Duration _) {
_showCaretOnScreenScheduled = false; _showCaretOnScreenScheduled = false;
if (_currentCaretRect == null || !_scrollController!.hasClients) { if (_currentCaretRect == null || !_scrollController.hasClients) {
return; return;
} }
...@@ -2262,7 +2273,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2262,7 +2273,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
final RevealedOffset targetOffset = _getOffsetToRevealCaret(_currentCaretRect!); final RevealedOffset targetOffset = _getOffsetToRevealCaret(_currentCaretRect!);
_scrollController!.animateTo( _scrollController.animateTo(
targetOffset.offset, targetOffset.offset,
duration: _caretAnimationDuration, duration: _caretAnimationDuration,
curve: _caretAnimationCurve, curve: _caretAnimationCurve,
...@@ -2543,7 +2554,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -2543,7 +2554,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
final Rect localRect = renderEditable.getLocalRectForCaret(position); final Rect localRect = renderEditable.getLocalRectForCaret(position);
final RevealedOffset targetOffset = _getOffsetToRevealCaret(localRect); final RevealedOffset targetOffset = _getOffsetToRevealCaret(localRect);
_scrollController!.jumpTo(targetOffset.offset); _scrollController.jumpTo(targetOffset.offset);
renderEditable.showOnScreen(rect: targetOffset.rect); renderEditable.showOnScreen(rect: targetOffset.rect);
} }
......
...@@ -3969,9 +3969,8 @@ void main() { ...@@ -3969,9 +3969,8 @@ void main() {
error.toStringDeep(), error.toStringDeep(),
equalsIgnoringHashCodes( equalsIgnoringHashCodes(
'FlutterError\n' 'FlutterError\n'
' invalid text selection: TextSelection(baseOffset: 10,\n' ' invalid text selection: TextSelection.collapsed(offset: 10,\n'
' extentOffset: 10, affinity: TextAffinity.downstream,\n' ' affinity: TextAffinity.downstream, isDirectional: false)\n',
' isDirectional: false)\n',
), ),
); );
} }
......
...@@ -12,6 +12,39 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -12,6 +12,39 @@ import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); 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', () { group('TextInput message channels', () {
late FakeTextChannel fakeTextChannel; late FakeTextChannel fakeTextChannel;
......
...@@ -5717,6 +5717,81 @@ void main() { ...@@ -5717,6 +5717,81 @@ void main() {
expect(scrollController.offset, 0); 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 { testWidgets('getLocalRectForCaret does not throw when it sees an infinite point', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
...@@ -8110,6 +8185,43 @@ void main() { ...@@ -8110,6 +8185,43 @@ void main() {
// On web, using keyboard for selection is handled by the browser. // On web, using keyboard for selection is handled by the browser.
}, skip: kIsWeb); // [intended] }, 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 { testWidgets('Selection will be scrolled into view with SelectionChangedCause', (WidgetTester tester) async {
final GlobalKey<EditableTextState> key = GlobalKey<EditableTextState>(); final GlobalKey<EditableTextState> key = GlobalKey<EditableTextState>();
final String text = List<int>.generate(64, (int index) => index).join('\n'); final String text = List<int>.generate(64, (int index) => index).join('\n');
...@@ -8439,3 +8551,7 @@ class _MyMoveSelectionRightTextAction extends TextEditingAction<Intent> { ...@@ -8439,3 +8551,7 @@ class _MyMoveSelectionRightTextAction extends TextEditingAction<Intent> {
onInvoke(); onInvoke();
} }
} }
class _TestScrollController extends ScrollController {
bool get attached => hasListeners;
}
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