This commit is contained in:
LongCatIsLooong 2021-02-24 11:01:03 -08:00 committed by GitHub
parent e07c248366
commit ac4d5099d8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 345 additions and 27 deletions

View file

@ -179,6 +179,8 @@ class _CupertinoTextFieldSelectionGestureDetectorBuilder extends TextSelectionGe
/// Remember to call [TextEditingController.dispose] when it is no longer /// Remember to call [TextEditingController.dispose] when it is no longer
/// needed. This will ensure we discard any resources used by the object. /// needed. This will ensure we discard any resources used by the object.
/// ///
/// {@macro flutter.widgets.editableText.showCaretOnScreen}
///
/// See also: /// See also:
/// ///
/// * <https://developer.apple.com/documentation/uikit/uitextfield> /// * <https://developer.apple.com/documentation/uikit/uitextfield>

View file

@ -256,7 +256,7 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete
/// Keep in mind you can also always read the current string from a TextField's /// Keep in mind you can also always read the current string from a TextField's
/// [TextEditingController] using [TextEditingController.text]. /// [TextEditingController] using [TextEditingController.text].
/// ///
/// ## Handling emojis and other complex characters /// ## Handling emojis and other complex characters
/// {@macro flutter.widgets.EditableText.onChanged} /// {@macro flutter.widgets.EditableText.onChanged}
/// ///
/// In the live Dartpad example above, try typing the emoji 👨👩👦 /// In the live Dartpad example above, try typing the emoji 👨👩👦
@ -264,6 +264,8 @@ class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDete
/// with `value.characters.length`, the emoji is correctly counted as a single /// with `value.characters.length`, the emoji is correctly counted as a single
/// character. /// character.
/// ///
/// {@macro flutter.widgets.editableText.showCaretOnScreen}
///
/// See also: /// See also:
/// ///
/// * [TextFormField], which integrates with the [Form] widget. /// * [TextFormField], which integrates with the [Form] widget.

View file

@ -2710,21 +2710,22 @@ class _FloatingCursorPainter extends RenderEditablePainter {
caretRect = caretRect.shift(renderEditable._paintOffset); caretRect = caretRect.shift(renderEditable._paintOffset);
final Rect integralRect = caretRect.shift(renderEditable._snapToPhysicalPixel(caretRect.topLeft)); final Rect integralRect = caretRect.shift(renderEditable._snapToPhysicalPixel(caretRect.topLeft));
final Radius? radius = cursorRadius; if (shouldPaint) {
caretPaint.color = caretColor; final Radius? radius = cursorRadius;
if (radius == null) { caretPaint.color = caretColor;
canvas.drawRect(integralRect, caretPaint); if (radius == null) {
} else { canvas.drawRect(integralRect, caretPaint);
final RRect caretRRect = RRect.fromRectAndRadius(integralRect, radius); } else {
canvas.drawRRect(caretRRect, caretPaint); final RRect caretRRect = RRect.fromRectAndRadius(integralRect, radius);
canvas.drawRRect(caretRRect, caretPaint);
}
} }
caretPaintCallback(integralRect); caretPaintCallback(integralRect);
} }
@override @override
void paint(Canvas canvas, Size size, RenderEditable renderEditable) { void paint(Canvas canvas, Size size, RenderEditable renderEditable) {
if (!shouldPaint) // Compute the caret location even when `shouldPaint` is false.
return;
assert(renderEditable != null); assert(renderEditable != null);
final TextSelection? selection = renderEditable.selection; final TextSelection? selection = renderEditable.selection;
@ -2749,7 +2750,7 @@ class _FloatingCursorPainter extends RenderEditablePainter {
final Color? floatingCursorColor = this.caretColor?.withOpacity(0.75); final Color? floatingCursorColor = this.caretColor?.withOpacity(0.75);
// Floating Cursor. // Floating Cursor.
if (floatingCursorRect == null || floatingCursorColor == null) if (floatingCursorRect == null || floatingCursorColor == null || !shouldPaint)
return; return;
canvas.drawRRect( canvas.drawRRect(

View file

@ -398,6 +398,18 @@ class ToolbarOptions {
/// methods such as [RenderEditable.selectPosition], /// methods such as [RenderEditable.selectPosition],
/// [RenderEditable.selectWord], etc. programmatically. /// [RenderEditable.selectWord], etc. programmatically.
/// ///
/// {@template flutter.widgets.editableText.showCaretOnScreen}
/// ## Keep the caret visisble when focused
///
/// When focused, this widget will make attempts to keep the text area and its
/// caret (even when [showCursor] is `false`) visible, on these occasions:
///
/// * When the user focuses this text field and it is not [readOnly].
/// * When the user changes the selection of the text field, or changes the
/// text when the text field is not [readOnly].
/// * When the virtual keyboard pops up.
/// {@endtemplate}
///
/// See also: /// See also:
/// ///
/// * [TextField], which is a full-featured, material-design text input field /// * [TextField], which is a full-featured, material-design text input field
@ -1721,7 +1733,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_currentPromptRectRange = null; _currentPromptRectRange = null;
if (_hasInputConnection) { if (_hasInputConnection) {
_showCaretOnScreen();
if (widget.obscureText && value.text.length == _value.text.length + 1) { if (widget.obscureText && value.text.length == _value.text.length + 1) {
_obscureShowCharTicksPending = _kObscureShowLatestCharCursorTicks; _obscureShowCharTicksPending = _kObscureShowLatestCharCursorTicks;
_obscureLatestCharIndex = _value.selection.baseOffset; _obscureLatestCharIndex = _value.selection.baseOffset;
@ -1731,6 +1742,11 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_formatAndSetValue(value); _formatAndSetValue(value);
} }
// Wherever the value is changed by the user, schedule a showCaretOnScreen
// to make sure the user can see the changes they just made. Programmatical
// changes to `textEditingValue` do not trigger the behavior even if the
// text field is focused.
_scheduleShowCaretOnScreen();
if (_hasInputConnection) { if (_hasInputConnection) {
// To keep the cursor from blinking while typing, we want to restart the // To keep the cursor from blinking while typing, we want to restart the
// cursor timer every time a new character is typed. // cursor timer every time a new character is typed.
@ -2154,17 +2170,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
} }
} }
bool _textChangedSinceLastCaretUpdate = false;
Rect? _currentCaretRect; Rect? _currentCaretRect;
void _handleCaretChanged(Rect caretRect) { void _handleCaretChanged(Rect caretRect) {
_currentCaretRect = caretRect; _currentCaretRect = caretRect;
// If the caret location has changed due to an update to the text or
// selection, then scroll the caret into view.
if (_textChangedSinceLastCaretUpdate) {
_textChangedSinceLastCaretUpdate = false;
_showCaretOnScreen();
}
} }
// Animation configuration for scrolling the caret back on screen. // Animation configuration for scrolling the caret back on screen.
@ -2173,7 +2181,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
bool _showCaretOnScreenScheduled = false; bool _showCaretOnScreenScheduled = false;
void _showCaretOnScreen() { void _scheduleShowCaretOnScreen() {
if (_showCaretOnScreenScheduled) { if (_showCaretOnScreenScheduled) {
return; return;
} }
@ -2232,7 +2240,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override @override
void didChangeMetrics() { void didChangeMetrics() {
if (_lastBottomViewInset < WidgetsBinding.instance!.window.viewInsets.bottom) { if (_lastBottomViewInset < WidgetsBinding.instance!.window.viewInsets.bottom) {
_showCaretOnScreen(); _scheduleShowCaretOnScreen();
} }
_lastBottomViewInset = WidgetsBinding.instance!.window.viewInsets.bottom; _lastBottomViewInset = WidgetsBinding.instance!.window.viewInsets.bottom;
} }
@ -2390,7 +2398,6 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_updateRemoteEditingValueIfNeeded(); _updateRemoteEditingValueIfNeeded();
_startOrStopCursorTimerIfNeeded(); _startOrStopCursorTimerIfNeeded();
_updateOrDisposeSelectionOverlayIfNeeded(); _updateOrDisposeSelectionOverlayIfNeeded();
_textChangedSinceLastCaretUpdate = true;
// TODO(abarth): Teach RenderEditable about ValueNotifier<TextEditingValue> // TODO(abarth): Teach RenderEditable about ValueNotifier<TextEditingValue>
// to avoid this setState(). // to avoid this setState().
setState(() { /* We use widget.controller.value in build(). */ }); setState(() { /* We use widget.controller.value in build(). */ });
@ -2404,7 +2411,9 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
// Listen for changing viewInsets, which indicates keyboard showing up. // Listen for changing viewInsets, which indicates keyboard showing up.
WidgetsBinding.instance!.addObserver(this); WidgetsBinding.instance!.addObserver(this);
_lastBottomViewInset = WidgetsBinding.instance!.window.viewInsets.bottom; _lastBottomViewInset = WidgetsBinding.instance!.window.viewInsets.bottom;
_showCaretOnScreen(); if (!widget.readOnly) {
_scheduleShowCaretOnScreen();
}
if (!_value.selection.isValid) { if (!_value.selection.isValid) {
// Place cursor at the end if the selection is invalid when we receive focus. // Place cursor at the end if the selection is invalid when we receive focus.
_handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), renderEditable, null); _handleSelectionChanged(TextSelection.collapsed(offset: _value.text.length), renderEditable, null);
@ -2471,6 +2480,14 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
@override @override
set textEditingValue(TextEditingValue value) { set textEditingValue(TextEditingValue value) {
_selectionOverlay?.update(value); _selectionOverlay?.update(value);
// Compare the current TextEditingValue with the pre-format new
// TextEditingValue value, in case the formatter would reject the change.
final bool shouldShowCaret = widget.readOnly
? _value.selection != value.selection
: _value != value;
if (shouldShowCaret) {
_scheduleShowCaretOnScreen();
}
_formatAndSetValue(value); _formatAndSetValue(value);
} }

View file

@ -3495,7 +3495,12 @@ void main() {
// Move the caret to the end of the text and check that the text field // Move the caret to the end of the text and check that the text field
// scrolls to make the caret visible. // scrolls to make the caret visible.
controller.selection = TextSelection.collapsed(offset: longText.length); scrollableState = tester.firstState(find.byType(Scrollable));
final EditableTextState editableTextState = tester.firstState(find.byType(EditableText));
editableTextState.textEditingValue = editableTextState.textEditingValue.copyWith(
selection: TextSelection.collapsed(offset: longText.length),
);
await tester.pump(); // TODO(ianh): Figure out why this extra pump is needed. await tester.pump(); // TODO(ianh): Figure out why this extra pump is needed.
await skipPastScrollingAnimation(tester); await skipPastScrollingAnimation(tester);
@ -3527,7 +3532,10 @@ void main() {
// Move the caret to the end of the text and check that the text field // Move the caret to the end of the text and check that the text field
// scrolls to make the caret visible. // scrolls to make the caret visible.
controller.selection = const TextSelection.collapsed(offset: tallText.length); final EditableTextState editableTextState = tester.firstState(find.byType(EditableText));
editableTextState.textEditingValue = editableTextState.textEditingValue.copyWith(
selection: const TextSelection.collapsed(offset: tallText.length),
);
await tester.pump(); await tester.pump();
await skipPastScrollingAnimation(tester); await skipPastScrollingAnimation(tester);
@ -8614,6 +8622,51 @@ void main() {
expect(scrollController.offset, 48.0); expect(scrollController.offset, 48.0);
}); });
// Regression test for https://github.com/flutter/flutter/issues/74566
testWidgets('TextField and last input character are visible on the screen when the cursor is not shown', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController();
final ScrollController textFieldScrollController = ScrollController();
await tester.pumpWidget(MaterialApp(
theme: ThemeData(),
home: Scaffold(
body: Center(
child: ListView(
controller: scrollController,
children: <Widget>[
Container(height: 579), // Push field almost off screen.
TextField(
scrollController: textFieldScrollController,
showCursor: false,
),
Container(height: 1000),
],
),
),
),
));
// Tap the TextField to bring it into view.
expect(scrollController.offset, 0.0);
await tester.tapAt(tester.getTopLeft(find.byType(TextField)));
await tester.pumpAndSettle();
// The ListView has scrolled to keep the TextField visible.
expect(scrollController.offset, 48.0);
expect(textFieldScrollController.offset, 0.0);
// After entering some long text, the last input character remains on the screen.
final String testValue = 'I love Flutter!' * 10;
tester.testTextInput.updateEditingValue(TextEditingValue(
text: testValue,
selection: TextSelection.collapsed(offset: testValue.length),
));
await tester.pump();
await tester.pumpAndSettle(); // Text scroll animation.
expect(textFieldScrollController.offset, 1602.0);
});
group('height', () { group('height', () {
testWidgets('By default, TextField is at least kMinInteractiveDimension high', (WidgetTester tester) async { testWidgets('By default, TextField is at least kMinInteractiveDimension high', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(

View file

@ -3,6 +3,8 @@
// found in the LICENSE file. // found in the LICENSE file.
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter/src/foundation/constants.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -39,11 +41,11 @@ class _TestSliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate
void main() { void main() {
const TextStyle textStyle = TextStyle(); const TextStyle textStyle = TextStyle();
const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00); const Color cursorColor = Color.fromARGB(0xFF, 0xFF, 0x00, 0x00);
final FocusNode focusNode = FocusNode();
testWidgets('tapping on a partly visible editable brings it fully on screen', (WidgetTester tester) async { testWidgets('tapping on a partly visible editable brings it fully on screen', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController(); final ScrollController scrollController = ScrollController();
final TextEditingController controller = TextEditingController(); final TextEditingController controller = TextEditingController();
final FocusNode focusNode = FocusNode();
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
home: Center( home: Center(
@ -489,6 +491,247 @@ void main() {
// The scroll offset should remain the same. // The scroll offset should remain the same.
expect(controller.offset, 100.0 * 15); expect(controller.offset, 100.0 * 15);
}); });
void testShowCaretOnScreen({ required bool readOnly }) {
group('EditableText._showCaretOnScreen, readOnly=$readOnly', () {
final TextEditingController textEditingController = TextEditingController();
final TextInputFormatter rejectEverythingFormatter = TextInputFormatter.withFunction((TextEditingValue old, TextEditingValue value) => old);
bool isCaretOnScreen(WidgetTester tester) {
final EditableTextState state = tester.state<EditableTextState>(
find.byType(EditableText, skipOffstage: false),
);
final RenderEditable renderEditable = state.renderEditable;
final Rect localRect = renderEditable.getLocalRectForCaret(state.textEditingValue.selection.base);
final Offset caretOrigin = renderEditable.localToGlobal(localRect.topLeft);
final Rect caretRect = caretOrigin & localRect.size;
return const Rect.fromLTWH(0, 0, 800, 600).intersect(caretRect) == caretRect;
}
Widget buildEditableText({
required bool rejectUserInputs,
ScrollController? scrollController,
ScrollController? editableScrollController,
}) {
return MaterialApp(
home: Scaffold(
body: ListView(
controller: scrollController,
cacheExtent: 1000,
children: <Widget>[
// The text field is not fully visible.
const SizedBox(height: 599),
EditableText(
backgroundCursorColor: Colors.grey,
controller: textEditingController,
scrollController: editableScrollController,
inputFormatters: <TextInputFormatter>[if (rejectUserInputs) rejectEverythingFormatter],
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
readOnly: readOnly,
),
],
),
),
);
}
testWidgets('focus-triggered showCaretOnScreen', (WidgetTester tester) async {
textEditingController.text = 'a' * 100;
textEditingController.selection = const TextSelection.collapsed(offset: 100);
final ScrollController scrollController = ScrollController();
final ScrollController editableScrollController = ScrollController();
await tester.pumpWidget(
buildEditableText(
rejectUserInputs: false,
scrollController: scrollController,
editableScrollController: editableScrollController,
),
);
focusNode.requestFocus();
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), !readOnly);
expect(scrollController.offset, readOnly ? 0.0 : greaterThan(0.0));
expect(editableScrollController.offset, readOnly ? 0.0 : greaterThan(0.0));
});
testWidgets('selection-triggered showCaretOnScreen: virtual keyboard', (WidgetTester tester) async {
textEditingController.text = 'a' * 100;
textEditingController.selection = const TextSelection.collapsed(offset: 80);
final ScrollController scrollController = ScrollController();
final ScrollController editableScrollController = ScrollController();
await tester.pumpWidget(
buildEditableText(
rejectUserInputs: false,
scrollController: scrollController,
editableScrollController: editableScrollController,
),
);
focusNode.requestFocus();
await tester.pumpAndSettle();
// Ensure the caret is not fully visible and the text field is focused.
scrollController.jumpTo(0);
editableScrollController.jumpTo(0);
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), isFalse);
final EditableTextState state = tester.state<EditableTextState>(
find.byType(EditableText, skipOffstage: false),
);
// Change the selection. Show caret on screen when readyOnly is true,
// as a read-only text field rejects everything from the software
// keyboard (except for web).
state.updateEditingValue(state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 90)));
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), !readOnly || kIsWeb);
expect(scrollController.offset, readOnly && !kIsWeb ? 0.0 : greaterThan(0.0));
expect(editableScrollController.offset, readOnly && !kIsWeb ? 0.0 : greaterThan(0.0));
// Reject user input.
await tester.pumpWidget(
buildEditableText(
rejectUserInputs: true,
scrollController: scrollController,
editableScrollController: editableScrollController,
),
);
// Ensure the caret is not fully visible and the text field is focused.
scrollController.jumpTo(0);
editableScrollController.jumpTo(0);
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), isFalse);
state.updateEditingValue(state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 100)));
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), !readOnly || kIsWeb);
expect(scrollController.offset, readOnly && !kIsWeb ? 0.0 : greaterThan(0.0));
expect(editableScrollController.offset, readOnly && !kIsWeb ? 0.0 : greaterThan(0.0));
});
testWidgets('selection-triggered showCaretOnScreen: text selection delegate', (WidgetTester tester) async {
textEditingController.text = 'a' * 100;
textEditingController.selection = const TextSelection.collapsed(offset: 80);
final ScrollController scrollController = ScrollController();
final ScrollController editableScrollController = ScrollController();
await tester.pumpWidget(
buildEditableText(
rejectUserInputs: false,
scrollController: scrollController,
editableScrollController: editableScrollController,
),
);
focusNode.requestFocus();
await tester.pumpAndSettle();
// Ensure the caret is not fully visible and the text field is focused.
scrollController.jumpTo(0);
editableScrollController.jumpTo(0);
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), isFalse);
final EditableTextState state = tester.state<EditableTextState>(
find.byType(EditableText, skipOffstage: false),
);
// Change the selection. Show caret on screen even when readyOnly is
// false.
state.textEditingValue = state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 90));
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), isTrue);
expect(scrollController.offset, greaterThan(0.0));
expect(editableScrollController.offset, greaterThan(0.0));
// Rejects user input.
await tester.pumpWidget(
buildEditableText(
rejectUserInputs: true,
scrollController: scrollController,
editableScrollController: editableScrollController,
),
);
// Ensure the caret is not fully visible and the text field is focused.
scrollController.jumpTo(0);
editableScrollController.jumpTo(0);
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), isFalse);
state.textEditingValue = state.textEditingValue.copyWith(selection: const TextSelection.collapsed(offset: 100));
await tester.pumpAndSettle();
expect(isCaretOnScreen(tester), isTrue);
expect(scrollController.offset, greaterThan(0.0));
expect(editableScrollController.offset, greaterThan(0.0));
});
// Regression text for https://github.com/flutter/flutter/pull/74722.
testWidgets('does NOT randomly trigger when cursor blinks', (WidgetTester tester) async {
textEditingController.text = 'a' * 100;
textEditingController.selection = const TextSelection.collapsed(offset: 0);
final ScrollController editableScrollController = ScrollController();
final bool deterministicCursor = EditableText.debugDeterministicCursor;
EditableText.debugDeterministicCursor = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: EditableText(
backgroundCursorColor: Colors.grey,
controller: textEditingController,
scrollController: editableScrollController,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
),
),
);
final EditableTextState state = tester.state<EditableTextState>(
find.byType(EditableText, skipOffstage: false),
);
// Ensure the text was initially visible.
expect(isCaretOnScreen(tester), true);
expect(editableScrollController.offset, 0.0);
// Change the text but keep the cursor location.
state.updateEditingValue(textEditingController.value.copyWith(
text: 'a' * 101,
));
await tester.pumpAndSettle();
// The caret should stay where it was, since the selection didn't change.
expect(isCaretOnScreen(tester), true);
expect(editableScrollController.offset, 0.0);
// Now move to hide the cursor.
editableScrollController.jumpTo(100.0);
// Does not trigger showCaretOnScreen.
await tester.pump();
await tester.pumpAndSettle();
expect(editableScrollController.offset, 100.0);
expect(isCaretOnScreen(tester), isFalse);
EditableText.debugDeterministicCursor = deterministicCursor;
});
});
}
testShowCaretOnScreen(readOnly: true);
testShowCaretOnScreen(readOnly: false);
} }
class NoImplicitScrollPhysics extends AlwaysScrollableScrollPhysics { class NoImplicitScrollPhysics extends AlwaysScrollableScrollPhysics {