Unverified Commit a2957c57 authored by Mouad Debbar's avatar Mouad Debbar Committed by GitHub

Upstream changes necessary for text editing in flutter web (#39344)

parent bb1d139c
......@@ -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<void>(
'TextInput.setEditableSizeAndTransform',
<String, dynamic>{
'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<void>(
'TextInput.setStyle',
<String, dynamic>{
'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
......
......@@ -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<EditableText> 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<EditableText> 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<EditableText> 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<EditableText> 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;
}
......
......@@ -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<MethodCall> log = <MethodCall>[];
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 <String, dynamic>{
......@@ -1027,6 +1035,17 @@ void main() {
},
),
);
expect(
log.lastWhere((MethodCall m) => m.method == 'TextInput.setEditableSizeAndTransform'),
isMethodCall(
'TextInput.setEditableSizeAndTransform',
arguments: <String, dynamic>{
'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<MethodCall> log = <MethodCall>[];
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: <String, dynamic>{
'width': 800,
'height': 600,
'transform': Matrix4.identity().storage.toList(),
}),
);
});
testWidgets('size and transform are sent when they change', (WidgetTester tester) async {
final List<MethodCall> log = <MethodCall>[];
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: <String, dynamic>{
'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: <String, dynamic>{
'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<MethodCall> log = <MethodCall>[];
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: <String, dynamic>{
'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<EditableTextState> editableTextKey = GlobalKey<EditableTextState>();
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<MethodCall> log = <MethodCall>[];
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: <String, dynamic>{
'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<TransformedEditableText> {
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: <Widget>[
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'),
),
],
),
),
);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment