From 0f8c0da0a9ded2a9b7aa3bd01fcc29db80bbab0a Mon Sep 17 00:00:00 2001 From: LongCatIsLooong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Tue, 17 Dec 2019 16:22:28 -0800 Subject: [PATCH] iOS UITextInput autocorrection prompt (#45354) --- .../flutter/lib/src/cupertino/text_field.dart | 5 +- .../flutter/lib/src/material/text_field.dart | 3 + .../flutter/lib/src/rendering/editable.dart | 61 +++++++++++++- .../flutter/lib/src/services/text_input.dart | 9 +++ .../lib/src/widgets/editable_text.dart | 41 +++++++++- .../flutter/test/rendering/editable_test.dart | 38 +++++++++ .../test/services/text_input_test.dart | 31 +++++++ .../test/widgets/editable_text_test.dart | 81 +++++++++++++++++++ 8 files changed, 266 insertions(+), 3 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index 805f4a12f6..dabb121610 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -876,6 +876,8 @@ class _CupertinoTextFieldState extends State with AutomaticK color: enabled ? decorationColor : (decorationColor ?? disabledColor), ); + final Color selectionColor = CupertinoTheme.of(context).primaryColor.withOpacity(0.2); + final Widget paddedEditable = Padding( padding: widget.padding, child: RepaintBoundary( @@ -902,7 +904,7 @@ class _CupertinoTextFieldState extends State with AutomaticK maxLines: widget.maxLines, minLines: widget.minLines, expands: widget.expands, - selectionColor: CupertinoTheme.of(context).primaryColor.withOpacity(0.2), + selectionColor: selectionColor, selectionControls: widget.selectionEnabled ? cupertinoTextSelectionControls : null, onChanged: widget.onChanged, @@ -917,6 +919,7 @@ class _CupertinoTextFieldState extends State with AutomaticK cursorOpacityAnimates: true, cursorOffset: cursorOffset, paintCursorAboveText: true, + autocorrectionTextRectColor: selectionColor, backgroundCursorColor: CupertinoDynamicColor.resolve(CupertinoColors.inactiveGray, context), scrollPadding: widget.scrollPadding, keyboardAppearance: keyboardAppearance, diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index db616b2f67..101f30fac4 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -935,6 +935,7 @@ class _TextFieldState extends State implements TextSelectionGestureDe bool cursorOpacityAnimates; Offset cursorOffset; Color cursorColor = widget.cursorColor; + Color autocorrectionTextRectColor; Radius cursorRadius = widget.cursorRadius; switch (themeData.platform) { @@ -947,6 +948,7 @@ class _TextFieldState extends State implements TextSelectionGestureDe cursorColor ??= CupertinoTheme.of(context).primaryColor; cursorRadius ??= const Radius.circular(2.0); cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0); + autocorrectionTextRectColor = themeData.textSelectionColor; break; case TargetPlatform.android: @@ -1006,6 +1008,7 @@ class _TextFieldState extends State implements TextSelectionGestureDe dragStartBehavior: widget.dragStartBehavior, scrollController: widget.scrollController, scrollPhysics: widget.scrollPhysics, + autocorrectionTextRectColor: autocorrectionTextRectColor, ), ); diff --git a/packages/flutter/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index dfef98c15c..8a5769275b 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -210,6 +210,8 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { double devicePixelRatio = 1.0, bool enableInteractiveSelection, EdgeInsets floatingCursorAddedMargin = const EdgeInsets.fromLTRB(4, 4, 4, 5), + TextRange promptRectRange, + Color promptRectColor, @required this.textSelectionDelegate, }) : assert(textAlign != null), assert(textDirection != null, 'RenderEditable created without a textDirection.'), @@ -266,10 +268,13 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _endHandleLayerLink = endHandleLayerLink, _obscureText = obscureText, _readOnly = readOnly, - _forceLine = forceLine { + _forceLine = forceLine, + _promptRectRange = promptRectRange { assert(_showCursor != null); assert(!_showCursor.value || cursorColor != null); this.hasFocus = hasFocus ?? false; + if (promptRectColor != null) + _promptRectPaint.color = promptRectColor; } /// Character used to obscure text if [obscureText] is true. @@ -1113,6 +1118,40 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { return enableInteractiveSelection ?? !obscureText; } + /// The color used to paint the prompt rectangle. + /// + /// The prompt rectangle will only be requested on non-web iOS applications. + Color get promptRectColor => _promptRectPaint.color; + set promptRectColor(Color newValue) { + // Painter.color can not be null. + if (newValue == null) { + setPromptRectRange(null); + return; + } + + if (promptRectColor == newValue) + return; + + _promptRectPaint.color = newValue; + if (_promptRectRange != null) + markNeedsPaint(); + } + + TextRange _promptRectRange; + /// Dismisses the currently displayed prompt rectangle and displays a new prompt rectangle + /// over [newRange] in the given color [promptRectColor]. + /// + /// The prompt rectangle will only be requested on non-web iOS applications. + /// + /// When set to null, the currently displayed prompt rectangle (if any) will be dismissed. + void setPromptRectRange(TextRange newRange) { + if (_promptRectRange == newRange) + return; + + _promptRectRange = newRange; + markNeedsPaint(); + } + /// The maximum amount the text is allowed to scroll. /// /// This value is only valid after layout and can change as additional @@ -1912,6 +1951,24 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { canvas.drawRect(box.toRect().shift(effectiveOffset), paint); } + final Paint _promptRectPaint = Paint(); + void _paintPromptRectIfNeeded(Canvas canvas, Offset effectiveOffset) { + if (_promptRectRange == null || promptRectColor == null) { + return; + } + + final List boxes = _textPainter.getBoxesForSelection( + TextSelection( + baseOffset: _promptRectRange.start, + extentOffset: _promptRectRange.end, + ), + ); + + for (TextBox box in boxes) { + canvas.drawRect(box.toRect().shift(effectiveOffset), _promptRectPaint); + } + } + void _paintContents(PaintingContext context, Offset offset) { assert(_textLayoutLastMaxWidth == constraints.maxWidth && _textLayoutLastMinWidth == constraints.minWidth, @@ -1934,6 +1991,8 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _paintSelection(context.canvas, effectiveOffset); } + _paintPromptRectIfNeeded(context.canvas, effectiveOffset); + // On iOS, the cursor is painted over the text, on Android, it's painted // under it. if (paintCursorAboveText) diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index a276897a1b..ab9bf3b531 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -753,6 +753,12 @@ abstract class TextInputClient { /// Updates the floating cursor position and state. void updateFloatingCursor(RawFloatingCursorPoint point); + /// Requests that this client display a prompt rectangle for the given text range, + /// to indicate the range of text that will be changed by a pending autocorrection. + /// + /// This method will only be called on iOS. + void showAutocorrectionPromptRect(int start, int end); + /// Platform notified framework of closed connection. /// /// [TextInputClient] should cleanup its connection and finalize editing. @@ -1076,6 +1082,9 @@ class TextInput { case 'TextInputClient.onConnectionClosed': _currentConnection._client.connectionClosed(); break; + case 'TextInputClient.showAutocorrectionPromptRect': + _currentConnection._client.showAutocorrectionPromptRect(args[1], args[2]); + break; default: throw MissingPluginException(); } diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index db503c4d33..5272642768 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -395,6 +395,7 @@ class EditableText extends StatefulWidget { this.enableInteractiveSelection = true, this.scrollController, this.scrollPhysics, + this.autocorrectionTextRectColor, this.toolbarOptions = const ToolbarOptions( copy: true, cut: true, @@ -634,6 +635,18 @@ class EditableText extends StatefulWidget { /// Cannot be null. final Color cursorColor; + /// The color to use when painting the autocorrection Rect. + /// + /// For [CupertinoTextField]s, the value is set to the ambient + /// [CupertinoThemeData.primaryColor] with 20% opacity. For [TextField]s, the + /// value is null on non-iOS platforms and the same color used in [CupertinoTextField] + /// on iOS. + /// + /// Currently the autocorrection Rect only appears on iOS. + /// + /// Defaults to null, which disables autocorrection Rect painting. + final Color autocorrectionTextRectColor; + /// The color to use when painting the background cursor aligned with the text /// while rendering the floating cursor. /// @@ -737,6 +750,10 @@ class EditableText extends StatefulWidget { final bool autofocus; /// The color to use when painting the selection. + /// + /// For [CupertinoTextField]s, the value is set to the ambient + /// [CupertinoThemeData.primaryColor] with 20% opacity. For [TextField]s, the + /// value is set to the ambient [ThemeData.textSelectionColor]. final Color selectionColor; /// Optional delegate for building the text selection handles and toolbar. @@ -1212,6 +1229,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (value.text != _value.text) { hideToolbar(); _showCaretOnScreen(); + _currentPromptRectRange = null; if (widget.obscureText && value.text.length == _value.text.length + 1) { _obscureShowCharTicksPending = _kObscureShowLatestCharCursorTicks; _obscureLatestCharIndex = _value.selection.baseOffset; @@ -1734,6 +1752,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien WidgetsBinding.instance.removeObserver(this); // Clear the selection and composition state if this widget lost focus. _value = TextEditingValue(text: _value.text); + _currentPromptRectRange = null; } updateKeepAlive(); } @@ -1812,6 +1831,16 @@ class EditableTextState extends State with AutomaticKeepAliveClien } } + // null if no promptRect should be shown. + TextRange _currentPromptRectRange; + + @override + void showAutocorrectionPromptRect(int start, int end) { + setState(() { + _currentPromptRectRange = TextRange(start: start, end: end); + }); + } + VoidCallback _semanticsOnCopy(TextSelectionControls controls) { return widget.selectionEnabled && copyEnabled && _hasFocus && controls?.canCopy(this) == true ? () => controls.handleCopy(this) @@ -1890,6 +1919,8 @@ class EditableTextState extends State with AutomaticKeepAliveClien enableInteractiveSelection: widget.enableInteractiveSelection, textSelectionDelegate: this, devicePixelRatio: _devicePixelRatio, + promptRectRange: _currentPromptRectRange, + promptRectColor: widget.autocorrectionTextRectColor, ), ), ); @@ -1958,6 +1989,8 @@ class _Editable extends LeafRenderObjectWidget { this.textSelectionDelegate, this.paintCursorAboveText, this.devicePixelRatio, + this.promptRectRange, + this.promptRectColor, }) : assert(textDirection != null), assert(rendererIgnoresPointer != null), super(key: key); @@ -1998,6 +2031,8 @@ class _Editable extends LeafRenderObjectWidget { final TextSelectionDelegate textSelectionDelegate; final double devicePixelRatio; final bool paintCursorAboveText; + final TextRange promptRectRange; + final Color promptRectColor; @override RenderEditable createRenderObject(BuildContext context) { @@ -2034,6 +2069,8 @@ class _Editable extends LeafRenderObjectWidget { enableInteractiveSelection: enableInteractiveSelection, textSelectionDelegate: textSelectionDelegate, devicePixelRatio: devicePixelRatio, + promptRectRange: promptRectRange, + promptRectColor: promptRectColor, ); } @@ -2069,6 +2106,8 @@ class _Editable extends LeafRenderObjectWidget { ..cursorOffset = cursorOffset ..textSelectionDelegate = textSelectionDelegate ..devicePixelRatio = devicePixelRatio - ..paintCursorAboveText = paintCursorAboveText; + ..paintCursorAboveText = paintCursorAboveText + ..promptRectColor = promptRectColor + ..setPromptRectRange(promptRectRange); } } diff --git a/packages/flutter/test/rendering/editable_test.dart b/packages/flutter/test/rendering/editable_test.dart index c25d40298b..39251aace9 100644 --- a/packages/flutter/test/rendering/editable_test.dart +++ b/packages/flutter/test/rendering/editable_test.dart @@ -546,6 +546,44 @@ void main() { expect(selectionChangedCount, 1); }, skip: isBrowser); + test('promptRect disappears when promptRectColor is set to null', () { + const Color promptRectColor = Color(0x12345678); + final TextSelectionDelegate delegate = FakeEditableTextState(); + final RenderEditable editable = RenderEditable( + text: const TextSpan( + style: TextStyle(height: 1.0, fontSize: 10.0, fontFamily: 'Ahem'), + text: 'ABCDEFG', + ), + startHandleLayerLink: LayerLink(), + endHandleLayerLink: LayerLink(), + textAlign: TextAlign.start, + textDirection: TextDirection.ltr, + locale: const Locale('en', 'US'), + offset: ViewportOffset.fixed(10.0), + textSelectionDelegate: delegate, + selection: const TextSelection.collapsed(offset: 0), + promptRectColor: promptRectColor, + promptRectRange: const TextRange(start: 0, end: 1), + ); + editable.layout(BoxConstraints.loose(const Size(1000.0, 1000.0))); + + expect( + (Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero), + paints..rect(color: promptRectColor), + ); + + editable.promptRectColor = null; + + editable.layout(BoxConstraints.loose(const Size(1000.0, 1000.0))); + pumpFrame(); + + expect(editable.promptRectColor, promptRectColor); + expect( + (Canvas canvas) => editable.paint(TestRecordingPaintingContext(canvas), Offset.zero), + isNot(paints..rect(color: promptRectColor)), + ); + }); + test('editable hasFocus correctly initialized', () { // Regression test for https://github.com/flutter/flutter/issues/21640 final TextSelectionDelegate delegate = FakeEditableTextState(); diff --git a/packages/flutter/test/services/text_input_test.dart b/packages/flutter/test/services/text_input_test.dart index 5de8e1b57f..b357792045 100644 --- a/packages/flutter/test/services/text_input_test.dart +++ b/packages/flutter/test/services/text_input_test.dart @@ -72,6 +72,10 @@ void main() { }); group('TextInputConfiguration', () { + tearDown(() { + TextInputConnection.debugResetId(); + }); + test('sets expected defaults', () { const TextInputConfiguration configuration = TextInputConfiguration(); expect(configuration.inputType, TextInputType.text); @@ -172,6 +176,28 @@ void main() { expect(client.latestMethodCall, 'connectionClosed'); }); + + test('TextInputClient showAutocorrectionPromptRect method is called', () async { + // Assemble a TextInputConnection so we can verify its change in state. + final FakeTextInputClient client = FakeTextInputClient(); + const TextInputConfiguration configuration = TextInputConfiguration(); + TextInput.attach(client, configuration); + + expect(client.latestMethodCall, isEmpty); + + // Send onConnectionClosed message. + final ByteData messageBytes = const JSONMessageCodec().encodeMessage({ + 'args': [1, 0, 1], + 'method': 'TextInputClient.showAutocorrectionPromptRect', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/textinput', + messageBytes, + (ByteData _) {}, + ); + + expect(client.latestMethodCall, 'showAutocorrectionPromptRect'); + }); }); } @@ -198,6 +224,11 @@ class FakeTextInputClient implements TextInputClient { latestMethodCall = 'connectionClosed'; } + @override + void showAutocorrectionPromptRect(int start, int end) { + latestMethodCall = 'showAutocorrectionPromptRect'; + } + TextInputConfiguration get configuration => const TextInputConfiguration(); } diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 3c1a3129a5..7094ca64b1 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -13,6 +13,7 @@ import 'package:flutter/services.dart'; import 'package:mockito/mockito.dart'; import 'package:flutter/foundation.dart'; +import '../rendering/mock_canvas.dart'; import 'editable_text_utils.dart'; import 'semantics_tester.dart'; @@ -1338,6 +1339,86 @@ void main() { assert(!onEditingCompleteCalled); }); + testWidgets( + 'iOS autocorrection rectangle should appear on demand' + 'and dismiss when the text changes or when focus is lost', + (WidgetTester tester) async { + const Color rectColor = Color(0xFFFF0000); + + void verifyAutocorrectionRectVisibility({ bool expectVisible }) { + PaintPattern evaluate() { + if (expectVisible) { + return paints..something(((Symbol method, List arguments) { + if (method != #drawRect) + return false; + final Paint paint = arguments[1]; + return paint.color == rectColor; + })); + } else { + return paints..everything(((Symbol method, List arguments) { + if (method != #drawRect) + return true; + final Paint paint = arguments[1]; + if (paint.color != rectColor) + return true; + throw 'Expected: autocorrection rect not visible, found: ${arguments[0]}'; + })); + } + } + + expect(findRenderEditable(tester), evaluate()); + } + + final FocusNode focusNode = FocusNode(); + final TextEditingController controller = TextEditingController(text: 'ABCDEFG'); + + final Widget widget = MaterialApp( + home: EditableText( + backgroundCursorColor: Colors.grey, + controller: controller, + focusNode: focusNode, + style: Typography(platform: TargetPlatform.android).black.subhead, + cursorColor: Colors.blue, + autocorrect: true, + autocorrectionTextRectColor: rectColor, + showCursor: false, + onEditingComplete: () { }, + ), + ); + + await tester.pumpWidget(widget); + + await tester.tap(find.byType(EditableText)); + await tester.pump(); + final EditableTextState state = tester.state(find.byType(EditableText)); + + assert(focusNode.hasFocus); + + // The prompt rect should be invisible initially. + verifyAutocorrectionRectVisibility(expectVisible: false); + + state.showAutocorrectionPromptRect(0, 1); + await tester.pump(); + + // Show prompt rect when told to. + verifyAutocorrectionRectVisibility(expectVisible: true); + + // Text changed, prompt rect goes away. + controller.text = '12345'; + await tester.pump(); + verifyAutocorrectionRectVisibility(expectVisible: false); + + state.showAutocorrectionPromptRect(0, 1); + await tester.pump(); + + verifyAutocorrectionRectVisibility(expectVisible: true); + + // Unfocus, prompt rect should go away. + focusNode.unfocus(); + await tester.pump(); + verifyAutocorrectionRectVisibility(expectVisible: false); + }); + testWidgets('Changing controller updates EditableText', (WidgetTester tester) async { final TextEditingController controller1 = TextEditingController(text: 'Wibble');