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 {
@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;
}
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