Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions packages/flutter/lib/src/services/text_editing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -94,27 +94,40 @@ class TextSelection extends TextRange {

@override
String toString() {
return '${objectRuntimeType(this, 'TextSelection')}(baseOffset: $baseOffset, extentOffset: $extentOffset, affinity: $affinity, isDirectional: $isDirectional)';

Copy link
Copy Markdown
Contributor

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.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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)

@LongCatIsLooong LongCatIsLooong Aug 10, 2021 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://master-api.flutter.dev/flutter/services/TextSelection/affinity.html:

If the text range is collapsed and has more than one visual location (e.g., occurs at a line break), which of the two locations to use when painting the caret.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.
Expand Down
55 changes: 33 additions & 22 deletions packages/flutter/lib/src/widgets/editable_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this late unnecessary now since it's immediately assigned?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah since the animation controller needs to reference this it has to be lazy.

vsync: this,
)..addListener(_onFloatingCursorResetTick);

@override
bool get wantKeepAlive => widget.focusNode.hasFocus;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}

Expand Down
5 changes: 2 additions & 3 deletions packages/flutter/test/material/text_field_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
),
);
}
Expand Down
33 changes: 33 additions & 0 deletions packages/flutter/test/services/text_input_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
116 changes: 116 additions & 0 deletions packages/flutter/test/widgets/editable_text_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 dispose called and the new one gets instantiated. didUpdateWidget is not called. Is that right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No it's just replacing scrollController1 with scrollController2 so didUpdateWidget is called. Currently we're not properly removing/adding listeners when the scroll controller changes

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(
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -8439,3 +8551,7 @@ class _MyMoveSelectionRightTextAction extends TextEditingAction<Intent> {
onInvoke();
}
}

class _TestScrollController extends ScrollController {
bool get attached => hasListeners;
}