diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 0a95f75877e..2d79171e785 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -4,9 +4,17 @@ import 'dart:async'; import 'dart:io' show Platform; -import 'dart:ui' show TextAffinity, hashValues, Offset; +import 'dart:ui' show + FontWeight, + Offset, + Size, + TextAffinity, + TextAlign, + TextDirection, + hashValues; import 'package:flutter/foundation.dart'; +import 'package:vector_math/vector_math_64.dart' show Matrix4; import 'message_codec.dart'; import 'system_channels.dart'; @@ -660,6 +668,52 @@ class TextInputConnection { ); } + /// Send the size and transform of the editable text to engine. + /// + /// The values are sent as platform messages so they can be used on web for + /// example to correctly position and size the html input field. + /// + /// 1. [editableBoxSize]: size of the render editable box. + /// + /// 2. [transform]: a matrix that maps the local paint coordinate system + /// to the [PipelineOwner.rootNode]. + void setEditableSizeAndTransform(Size editableBoxSize, Matrix4 transform) { + SystemChannels.textInput.invokeMethod( + 'TextInput.setEditableSizeAndTransform', + { + 'width': editableBoxSize.width, + 'height': editableBoxSize.height, + 'transform': transform.storage, + }, + ); + } + + /// Send text styling information. + /// + /// This information is used by the Flutter Web Engine to change the style + /// of the hidden native input's content. Hence, the content size will match + /// to the size of the editable widget's content. + void setStyle({ + @required String fontFamily, + @required double fontSize, + @required FontWeight fontWeight, + @required TextDirection textDirection, + @required TextAlign textAlign, + }) { + assert(attached); + + SystemChannels.textInput.invokeMethod( + 'TextInput.setStyle', + { + 'fontFamily': fontFamily, + 'fontSize': fontSize, + 'fontWeightIndex': fontWeight?.index, + 'textAlignIndex': textAlign.index, + 'textDirectionIndex': textDirection.index, + }, + ); + } + /// Stop interacting with the text input control. /// /// After calling this method, the text input control might disappear if no diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index de825b6c6fb..d84f45b575a 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -4,7 +4,7 @@ import 'dart:async'; import 'dart:math' as math; -import 'dart:ui' as ui; +import 'dart:ui' as ui hide TextStyle; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; @@ -1095,6 +1095,16 @@ class EditableTextState extends State with AutomaticKeepAliveClien if (oldWidget.readOnly && _hasFocus) _openInputConnection(); } + if (widget.style != oldWidget.style) { + final TextStyle style = widget.style; + _textInputConnection?.setStyle( + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + textDirection: _textDirection, + textAlign: widget.textAlign, + ); + } } @override @@ -1335,7 +1345,19 @@ class EditableTextState extends State with AutomaticKeepAliveClien textCapitalization: widget.textCapitalization, keyboardAppearance: widget.keyboardAppearance, ), - )..setEditingState(localValue); + ); + + _updateSizeAndTransform(); + final TextStyle style = widget.style; + _textInputConnection + ..setStyle( + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + textDirection: _textDirection, + textAlign: widget.textAlign, + ) + ..setEditingState(localValue); } _textInputConnection.show(); } @@ -1626,6 +1648,23 @@ class EditableTextState extends State with AutomaticKeepAliveClien updateKeepAlive(); } + Size _lastSize; + Matrix4 _lastTransform; + + void _updateSizeAndTransform() { + if (_hasInputConnection) { + final Size size = renderEditable.size; + final Matrix4 transform = renderEditable.getTransformTo(null); + if (size != _lastSize || transform != _lastTransform) { + _lastSize = size; + _lastTransform = transform; + _textInputConnection.setEditableSizeAndTransform(size, transform); + } + SchedulerBinding.instance + .addPostFrameCallback((Duration _) => _updateSizeAndTransform()); + } + } + TextDirection get _textDirection { final TextDirection result = widget.textDirection ?? Directionality.of(context); assert(result != null, '$runtimeType created without a textDirection and with no ambient Directionality.'); @@ -1659,6 +1698,14 @@ class EditableTextState extends State with AutomaticKeepAliveClien /// Returns `false` if a toolbar couldn't be shown, such as when the toolbar /// is already shown, or when no text selection currently exists. bool showToolbar() { + // Web is using native dom elements to enable clipboard functionality of the + // toolbar: copy, paste, select, cut. It might also provide additional + // functionality depending on the browser (such as translate). Due to this + // we should not show a Flutter toolbar for the editable text elements. + if (kIsWeb) { + return false; + } + if (_selectionOverlay == null || _selectionOverlay.toolbarIsVisible) { return false; } diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 8211e7b08d6..24ade7ecd29 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -534,6 +534,13 @@ void main() { equals('TextInputAction.done')); }); + /// Toolbar is not used in Flutter Web. Skip this check. + /// + /// Web is using native dom elements (it is also used as platform input) + /// to enable clipboard functionality of the toolbar: copy, paste, select, + /// cut. It might also provide additional functionality depending on the + /// browser (such as translation). Due to this, in browsers, we should not + /// show a Flutter toolbar for the editable text elements. testWidgets('can show toolbar when there is text and a selection', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -577,7 +584,7 @@ void main() { expect(state.showToolbar(), true); await tester.pump(); expect(find.text('PASTE'), findsOneWidget); - }); + }, skip: isBrowser); testWidgets('can show the toolbar after clearing all text', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/35998. @@ -999,21 +1006,22 @@ void main() { await tester.pumpWidget(builder()); await tester.pump(); // An extra pump to allow focus request to go through. - await tester.showKeyboard(find.byType(EditableText)); - - // Verify TextInput.setEditingState is fired with updated text when controller is replaced. final List log = []; SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async { log.add(methodCall); }); + + await tester.showKeyboard(find.byType(EditableText)); + + // Verify TextInput.setEditingState and TextInput.setEditableSizeAndTransform are + // both fired with updated text when controller is replaced. setState(() { currentController = controller2; }); await tester.pump(); - expect(log, hasLength(1)); expect( - log.single, + log.lastWhere((MethodCall m) => m.method == 'TextInput.setEditingState'), isMethodCall( 'TextInput.setEditingState', arguments: const { @@ -1027,6 +1035,17 @@ void main() { }, ), ); + expect( + log.lastWhere((MethodCall m) => m.method == 'TextInput.setEditableSizeAndTransform'), + isMethodCall( + 'TextInput.setEditableSizeAndTransform', + arguments: { + 'width': 800, + 'height': 14, + 'transform': Matrix4.translationValues(0.0, 293.0, 0.0).storage.toList(), + }, + ), + ); }); testWidgets('EditableText identifies as text field (w/ focus) in semantics', (WidgetTester tester) async { @@ -2093,6 +2112,196 @@ void main() { expect(setClient.arguments.last['keyboardAppearance'], 'Brightness.light'); }); + testWidgets('location of widget is sent on show keyboard', (WidgetTester tester) async { + final List log = []; + SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + }); + + final TextEditingController controller = TextEditingController(); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData( + devicePixelRatio: 1.0 + ), + child: Directionality( + textDirection: TextDirection.ltr, + child: EditableText( + controller: controller, + focusNode: FocusNode(), + style: Typography(platform: TargetPlatform.android).black.subhead, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + ), + ), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + final MethodCall methodCall = log.firstWhere((MethodCall m) => m.method == 'TextInput.setEditableSizeAndTransform'); + expect( + methodCall, + isMethodCall('TextInput.setEditableSizeAndTransform', arguments: { + 'width': 800, + 'height': 600, + 'transform': Matrix4.identity().storage.toList(), + }), + ); + }); + + testWidgets('size and transform are sent when they change', (WidgetTester tester) async { + final List log = []; + SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + }); + + const Offset offset = Offset(10.0, 20.0); + const Key transformButtonKey = Key('transformButton'); + await tester.pumpWidget( + const TransformedEditableText( + offset: offset, + transformButtonKey: transformButtonKey, + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + MethodCall methodCall = log.firstWhere((MethodCall m) => m.method == 'TextInput.setEditableSizeAndTransform'); + expect( + methodCall, + isMethodCall('TextInput.setEditableSizeAndTransform', arguments: { + 'width': 800, + 'height': 14, + 'transform': Matrix4.identity().storage.toList(), + }), + ); + + log.clear(); + await tester.tap(find.byKey(transformButtonKey)); + await tester.pumpAndSettle(); + + // There should be a new platform message updating the transform. + methodCall = log.firstWhere((MethodCall m) => m.method == 'TextInput.setEditableSizeAndTransform'); + expect( + methodCall, + isMethodCall('TextInput.setEditableSizeAndTransform', arguments: { + 'width': 800, + 'height': 14, + 'transform': Matrix4.translationValues(offset.dx, offset.dy, 0.0).storage.toList(), + }), + ); + }); + + testWidgets('text styling info is sent on show keyboard', (WidgetTester tester) async { + final List log = []; + SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + }); + + final TextEditingController controller = TextEditingController(); + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: EditableText( + textDirection: TextDirection.rtl, + controller: controller, + focusNode: FocusNode(), + style: const TextStyle( + fontSize: 20.0, + fontFamily: 'Roboto', + fontWeight: FontWeight.w600, + ), + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + ), + ), + ); + + await tester.showKeyboard(find.byType(EditableText)); + final MethodCall setStyle = log.firstWhere((MethodCall m) => m.method == 'TextInput.setStyle'); + expect( + setStyle, + isMethodCall('TextInput.setStyle', arguments: { + 'fontSize': 20.0, + 'fontFamily': 'Roboto', + 'fontWeightIndex': 5, + 'textAlignIndex': 4, + 'textDirectionIndex': 0, + }) + ); + }); + + testWidgets('text styling info is sent on style update', (WidgetTester tester) async { + final GlobalKey editableTextKey = GlobalKey(); + StateSetter setState; + const TextStyle textStyle1 = TextStyle( + fontSize: 20.0, + fontFamily: 'RobotoMono', + fontWeight: FontWeight.w600, + ); + const TextStyle textStyle2 = TextStyle( + fontSize: 20.0, + fontFamily: 'Raleway', + fontWeight: FontWeight.w700, + ); + TextStyle currentTextStyle = textStyle1; + + Widget builder() { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setter) { + setState = setter; + return MaterialApp( + home: MediaQuery( + data: const MediaQueryData(devicePixelRatio: 1.0), + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Material( + child: EditableText( + backgroundCursorColor: Colors.grey, + key: editableTextKey, + controller: controller, + focusNode: FocusNode(), + style: currentTextStyle, + cursorColor: Colors.blue, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + onChanged: (String value) {}, + ), + ), + ), + ), + ), + ); + }, + ); + } + + await tester.pumpWidget(builder()); + await tester.showKeyboard(find.byType(EditableText)); + + final List log = []; + SystemChannels.textInput.setMockMethodCallHandler((MethodCall methodCall) async { + log.add(methodCall); + }); + setState(() { + currentTextStyle = textStyle2; + }); + await tester.pump(); + + // Updated styling information should be sent via TextInput.setStyle method. + final MethodCall setStyle = log.firstWhere((MethodCall m) => m.method == 'TextInput.setStyle'); + expect( + setStyle, + isMethodCall('TextInput.setStyle', arguments: { + 'fontSize': 20.0, + 'fontFamily': 'Raleway', + 'fontWeightIndex': 6, + 'textAlignIndex': 4, + 'textDirectionIndex': 1, + }), + ); + }); + testWidgets('custom keyboardAppearance is respected', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/issues/22212. @@ -2659,3 +2868,54 @@ class CustomStyleEditableTextState extends EditableTextState { ); } } + +class TransformedEditableText extends StatefulWidget { + const TransformedEditableText({ this.offset, this.transformButtonKey }); + + final Offset offset; + final Key transformButtonKey; + + @override + _TransformedEditableTextState createState() => _TransformedEditableTextState(); +} + +class _TransformedEditableTextState extends State { + bool _isTransformed = false; + + @override + Widget build(BuildContext context) { + return MediaQuery( + data: const MediaQueryData( + devicePixelRatio: 1.0 + ), + child: Directionality( + textDirection: TextDirection.ltr, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Transform.translate( + offset: _isTransformed ? widget.offset : Offset.zero, + child: EditableText( + controller: TextEditingController(), + focusNode: FocusNode(), + style: Typography(platform: TargetPlatform.android).black.subhead, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + ), + ), + RaisedButton( + key: widget.transformButtonKey, + onPressed: () { + setState(() { + _isTransformed = !_isTransformed; + }); + }, + child: const Text('Toggle Transform'), + ), + ], + ), + ), + ); + } +}