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
|
@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
|
|||||||
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
|
|||||||
// 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
|
|||||||
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
|
|||||||
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
|
|||||||
|
|
||||||
@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
|
|||||||
// `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
|
|||||||
|
|
||||||
// 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
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
|||||||
_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
|
|||||||
|
|
||||||
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
|
|||||||
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() {
|
|||||||
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';
|
|||||||
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() {
|
|||||||
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() {
|
|||||||
// 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> {
|
|||||||
onInvoke();
|
onInvoke();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _TestScrollController extends ScrollController {
|
||||||
|
bool get attached => hasListeners;
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user