Fix iOS safari keyboard issue when semantics is enabled (#38822)
* Update branch with changes in main - Fix iOS safari keyboard issue when semantics is enabled
* Update branch with main - small enhancements
* set offset to -9999px instead of -999
* Add editing state tests to ios
* replace editableElement with the null checked one
diff --git a/lib/web_ui/lib/src/engine/semantics/text_field.dart b/lib/web_ui/lib/src/engine/semantics/text_field.dart
index 289248e..fa3ba97 100644
--- a/lib/web_ui/lib/src/engine/semantics/text_field.dart
+++ b/lib/web_ui/lib/src/engine/semantics/text_field.dart
@@ -2,10 +2,12 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
+import 'dart:async';
import 'package:ui/ui.dart' as ui;
import '../browser_detection.dart';
import '../dom.dart';
+import '../embedder.dart';
import '../platform_dispatcher.dart';
import '../safe_browser_api.dart';
import '../text_editing/text_editing.dart';
@@ -29,7 +31,8 @@
/// Initializes the [SemanticsTextEditingStrategy] singleton.
///
/// This method must be called prior to accessing [instance].
- static SemanticsTextEditingStrategy ensureInitialized(HybridTextEditing owner) {
+ static SemanticsTextEditingStrategy ensureInitialized(
+ HybridTextEditing owner) {
if (_instance != null && instance.owner == owner) {
return instance;
}
@@ -205,35 +208,62 @@
/// This role is implemented via a content-editable HTML element. This role does
/// not proactively switch modes depending on the current
/// [EngineSemanticsOwner.gestureMode]. However, in Chrome on Android it ignores
-/// browser gestures when in pointer mode. In Safari on iOS touch events are
+/// browser gestures when in pointer mode. In Safari on iOS pointer events are
/// used to detect text box invocation. This is because Safari issues touch
/// events even when Voiceover is enabled.
class TextField extends RoleManager {
TextField(SemanticsObject semanticsObject)
: super(Role.textField, semanticsObject) {
- editableElement =
- semanticsObject.hasFlag(ui.SemanticsFlag.isMultiline)
- ? createDomHTMLTextAreaElement()
- : createDomHTMLInputElement();
_setupDomElement();
}
/// The element used for editing, e.g. `<input>`, `<textarea>`.
- late final DomHTMLElement editableElement;
+ DomHTMLElement? editableElement;
- void _setupDomElement() {
+ /// Same as [editableElement] but null-checked.
+ DomHTMLElement get activeEditableElement {
+ assert(
+ editableElement != null,
+ 'The textField does not have an active editable element',
+ );
+ return editableElement!;
+ }
+
+ /// Timer that times when to set the location of the input text.
+ ///
+ /// This is only used for iOS. In iOS, virtual keyboard shifts the screen.
+ /// There is no callback to know if the keyboard is up and how much the screen
+ /// has shifted. Therefore instead of listening to the shift and passing this
+ /// information to Flutter Framework, we are trying to stop the shift.
+ ///
+ /// In iOS, the virtual keyboard shifts the screen up if the focused input
+ /// element is under the keyboard or very close to the keyboard. Before the
+ /// focus is called we are positioning it offscreen. The location of the input
+ /// in iOS is set to correct place, 100ms after focus. We use this timer for
+ /// timing this delay.
+ Timer? _positionInputElementTimer;
+ static const Duration _delayBeforePlacement = Duration(milliseconds: 100);
+
+ void _initializeEditableElement() {
+ assert(editableElement == null,
+ 'Editable element has already been initialized');
+
+ editableElement = semanticsObject.hasFlag(ui.SemanticsFlag.isMultiline)
+ ? createDomHTMLTextAreaElement()
+ : createDomHTMLInputElement();
+
// On iOS, even though the semantic text field is transparent, the cursor
// and text highlighting are still visible. The cursor and text selection
// are made invisible by CSS in [FlutterViewEmbedder.reset].
// But there's one more case where iOS highlights text. That's when there's
// and autocorrect suggestion. To disable that, we have to do the following:
- editableElement
+ activeEditableElement
..spellcheck = false
..setAttribute('autocorrect', 'off')
..setAttribute('autocomplete', 'off')
..setAttribute('data-semantics-role', 'text-field');
- editableElement.style
+ activeEditableElement.style
..position = 'absolute'
// `top` and `left` are intentionally set to zero here.
//
@@ -248,8 +278,10 @@
..left = '0'
..width = '${semanticsObject.rect!.width}px'
..height = '${semanticsObject.rect!.height}px';
- semanticsObject.element.append(editableElement);
+ semanticsObject.element.append(activeEditableElement);
+ }
+ void _setupDomElement() {
switch (browserEngine) {
case BrowserEngine.blink:
case BrowserEngine.firefox:
@@ -266,8 +298,9 @@
/// When in browser gesture mode, the focus is forwarded to the framework as
/// a tap to initialize editing.
void _initializeForBlink() {
- editableElement.addEventListener(
- 'focus', allowInterop((DomEvent event) {
+ _initializeEditableElement();
+ activeEditableElement.addEventListener('focus',
+ allowInterop((DomEvent event) {
if (semanticsObject.owner.gestureMode != GestureMode.browserGestures) {
return;
}
@@ -277,29 +310,45 @@
}));
}
- /// Safari on iOS reports text field activation via touch events.
+ /// Safari on iOS reports text field activation via pointer events.
///
- /// This emulates a tap recognizer to detect the activation. Because touch
+ /// This emulates a tap recognizer to detect the activation. Because pointer
/// events are present regardless of whether accessibility is enabled or not,
/// this mode is always enabled.
+ ///
+ /// In iOS, the virtual keyboard shifts the screen up if the focused input
+ /// element is under the keyboard or very close to the keyboard. To avoid the shift,
+ /// the creation of the editable element is delayed until a tap is detected.
+ ///
+ /// In the absence of an editable DOM element, role of 'textbox' is assigned to the
+ /// semanticsObject.element to communicate to the assistive technologies that
+ /// the user can start editing by tapping on the element. Once a tap is detected,
+ /// the editable element gets created and the role of textbox is removed from
+ /// semanicsObject.element to avoid confusing VoiceOver.
void _initializeForWebkit() {
// Safari for desktop is also initialized as the other browsers.
if (operatingSystem == OperatingSystem.macOs) {
_initializeForBlink();
return;
}
+
+ semanticsObject.element
+ ..setAttribute('role', 'textbox')
+ ..setAttribute('contenteditable', 'false')
+ ..setAttribute('tabindex', '0');
+
num? lastPointerDownOffsetX;
num? lastPointerDownOffsetY;
- editableElement.addEventListener('pointerdown',
+ semanticsObject.element.addEventListener('pointerdown',
allowInterop((DomEvent event) {
final DomPointerEvent pointerEvent = event as DomPointerEvent;
lastPointerDownOffsetX = pointerEvent.clientX;
lastPointerDownOffsetY = pointerEvent.clientY;
}), true);
- editableElement.addEventListener(
- 'pointerup', allowInterop((DomEvent event) {
+ semanticsObject.element.addEventListener('pointerup',
+ allowInterop((DomEvent event) {
final DomPointerEvent pointerEvent = event as DomPointerEvent;
if (lastPointerDownOffsetX != null) {
@@ -318,19 +367,7 @@
// Recognize it as a tap that requires a keyboard.
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
semanticsObject.id, ui.SemanticsAction.tap, null);
-
- // We need to call focus for the following scenario:
- // 1. The virtial keyboard in iOS gets dismissed by the 'Done' button
- // located at the top right of the keyboard.
- // 2. The user tries to focus on the input field again, either by
- // VoiceOver or manually, but the keyboard does not show up.
- //
- // In this scenario, the Flutter framework does not send a semantic update,
- // so we need to call focus after detecting a tap to make sure that the
- // virtual keyboard will show.
- if (semanticsObject.hasFocus) {
- editableElement.focus();
- }
+ _invokeIosWorkaround();
}
} else {
assert(lastPointerDownOffsetY == null);
@@ -341,66 +378,88 @@
}), true);
}
- bool _hasFocused = false;
+ void _invokeIosWorkaround() {
+ if (editableElement != null) {
+ return;
+ }
+
+ _initializeEditableElement();
+ activeEditableElement.style.transform = 'translate(${offScreenOffset}px, ${offScreenOffset}px)';
+ _positionInputElementTimer?.cancel();
+ _positionInputElementTimer = Timer(_delayBeforePlacement, () {
+ editableElement?.style.transform = '';
+ _positionInputElementTimer = null;
+ });
+
+ // Can not have both activeEditableElement and semanticsObject.element
+ // represent the same text field. It will confuse VoiceOver, so `role` needs to
+ // be assigned and removed, based on whether or not editableElement exists.
+ activeEditableElement.focus();
+ semanticsObject.element.removeAttribute('role');
+
+ activeEditableElement.addEventListener('blur',
+ allowInterop((DomEvent event) {
+ semanticsObject.element.setAttribute('role', 'textbox');
+ activeEditableElement.remove();
+ SemanticsTextEditingStrategy.instance.deactivate(this);
+
+ // Focus on semantics element before removing the editable element, so that
+ // the user can continue navigating the page with the assistive technology.
+ semanticsObject.element.focus();
+ editableElement = null;
+ }));
+ }
@override
void update() {
- // The user is editing the semantic text field directly, so there's no need
- // to do any update here.
+ // Ignore the update if editableElement has not been created yet.
+ // On iOS Safari, when the user dismisses the keyboard using the 'done' button,
+ // we recieve a `blur` event from the browswer and a semantic update with
+ // [hasFocus] set to true from the framework. In this case, we ignore the update
+ // and wait for a tap event before invoking the iOS workaround and creating
+ // the editable element.
+ if (editableElement != null) {
+ activeEditableElement.style
+ ..width = '${semanticsObject.rect!.width}px'
+ ..height = '${semanticsObject.rect!.height}px';
+
+ if (semanticsObject.hasFocus) {
+ if (flutterViewEmbedder.glassPaneShadow!.activeElement !=
+ activeEditableElement) {
+ semanticsObject.owner.addOneTimePostUpdateCallback(() {
+ activeEditableElement.focus();
+ });
+ }
+ SemanticsTextEditingStrategy.instance.activate(this);
+ } else if (flutterViewEmbedder.glassPaneShadow!.activeElement ==
+ activeEditableElement) {
+ if (!isIosSafari) {
+ SemanticsTextEditingStrategy.instance.deactivate(this);
+ // Only apply text, because this node is not focused.
+ }
+ activeEditableElement.blur();
+ }
+ }
+
+ final DomElement element = editableElement ?? semanticsObject.element;
if (semanticsObject.hasLabel) {
- editableElement.setAttribute(
+ element.setAttribute(
'aria-label',
semanticsObject.label!,
);
} else {
- editableElement.removeAttribute('aria-label');
- }
-
- editableElement.style
- ..width = '${semanticsObject.rect!.width}px'
- ..height = '${semanticsObject.rect!.height}px';
-
- // Whether we should request that the browser shift focus to the editable
- // element, so that both the framework and the browser agree on what's
- // currently focused.
- bool needsDomFocusRequest = false;
-
- if (semanticsObject.hasFocus) {
- if (!_hasFocused) {
- _hasFocused = true;
- SemanticsTextEditingStrategy.instance.activate(this);
- needsDomFocusRequest = true;
- }
- if (domDocument.activeElement != editableElement) {
- needsDomFocusRequest = true;
- }
- } else if (_hasFocused) {
- SemanticsTextEditingStrategy.instance.deactivate(this);
-
- if (_hasFocused && domDocument.activeElement == editableElement) {
- // Unlike `editableElement.focus()` we don't need to schedule `blur`
- // post-update because `document.activeElement` implies that the
- // element is already attached to the DOM. If it's not, it can't
- // possibly be focused and therefore there's no need to blur.
- editableElement.blur();
- }
- _hasFocused = false;
- }
-
- if (needsDomFocusRequest) {
- // Schedule focus post-update to make sure the element is attached to
- // the document. Otherwise focus() has no effect.
- semanticsObject.owner.addOneTimePostUpdateCallback(() {
- if (domDocument.activeElement != editableElement) {
- editableElement.focus();
- }
- });
+ element.removeAttribute('aria-label');
}
}
@override
void dispose() {
- editableElement.remove();
+ _positionInputElementTimer?.cancel();
+ _positionInputElementTimer = null;
+ // on iOS, the `blur` event listener callback will remove the element.
+ if (!isIosSafari) {
+ editableElement?.remove();
+ }
SemanticsTextEditingStrategy.instance.deactivate(this);
}
}
diff --git a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart
index 76b5b45..b2cf2ba 100644
--- a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart
+++ b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart
@@ -34,6 +34,9 @@
/// The `keyCode` of the "Enter" key.
const int _kReturnKeyCode = 13;
+/// Offset in pixels to place an element outside of the screen.
+const int offScreenOffset = -9999;
+
/// Blink and Webkit engines, bring an overlay on top of the text field when it
/// is autofilled.
bool browserHasAutofillOverlay() =>
@@ -119,8 +122,8 @@
if (isOffScreen) {
elementStyle
- ..top = '-9999px'
- ..left = '-9999px';
+ ..top = '${offScreenOffset}px'
+ ..left = '${offScreenOffset}px';
}
if (browserHasAutofillOverlay()) {
@@ -1509,7 +1512,7 @@
/// Position the element outside of the page before focusing on it. This is
/// useful for not triggering a scroll when iOS virtual keyboard is
/// coming up.
- activeDomElement.style.transform = 'translate(-9999px, -9999px)';
+ activeDomElement.style.transform = 'translate(${offScreenOffset}px, ${offScreenOffset}px)';
_canPosition = false;
}
diff --git a/lib/web_ui/test/engine/semantics/text_field_test.dart b/lib/web_ui/test/engine/semantics/text_field_test.dart
index fb87725..1835204 100644
--- a/lib/web_ui/test/engine/semantics/text_field_test.dart
+++ b/lib/web_ui/test/engine/semantics/text_field_test.dart
@@ -3,7 +3,6 @@
// found in the LICENSE file.
@TestOn('chrome || safari || firefox')
-
import 'dart:typed_data';
import 'package:test/bootstrap/browser.dart';
@@ -48,73 +47,28 @@
strategy = SemanticsTextEditingStrategy.instance;
testTextEditing.debugTextEditingStrategyOverride = strategy;
testTextEditing.configuration = singlelineConfig;
+ semantics()
+ ..debugOverrideTimestampFunction(() => _testTime)
+ ..semanticsEnabled = true;
});
- /// Emulates sending of a message by the framework to the engine.
- void sendFrameworkMessage(ByteData? message) {
- testTextEditing.channel.handleTextInput(message, (ByteData? data) {});
- }
+ tearDown(() {
+ semantics().semanticsEnabled = false;
+ });
- test('renders a text field', () async {
- semantics()
- ..debugOverrideTimestampFunction(() => _testTime)
- ..semanticsEnabled = true;
-
+ test('renders a text field', () {
createTextFieldSemantics(value: 'hello');
expectSemanticsTree('''
<sem style="$rootSemanticStyle">
<input value="hello" />
</sem>''');
-
- semantics().semanticsEnabled = false;
- });
-
- test('tap detection works', () async {
- debugBrowserEngineOverride = BrowserEngine.webkit;
- debugOperatingSystemOverride = OperatingSystem.iOs;
-
- final SemanticsActionLogger logger = SemanticsActionLogger();
- semantics()
- ..debugOverrideTimestampFunction(() => _testTime)
- ..semanticsEnabled = true;
-
- createTextFieldSemantics(value: 'hello');
-
- final DomElement textField = appHostNode
- .querySelector('input[data-semantics-role="text-field"]')!;
-
- textField.dispatchEvent(createDomPointerEvent(
- 'pointerdown',
- <Object?, Object?>{
- 'clientX': 25,
- 'clientY': 48,
- },
- ));
- textField.dispatchEvent(createDomPointerEvent(
- 'pointerup',
- <Object?, Object?>{
- 'clientX': 26,
- 'clientY': 48,
- },
- ));
-
- expect(await logger.idLog.first, 0);
- expect(await logger.actionLog.first, ui.SemanticsAction.tap);
-
- semantics().semanticsEnabled = false;
- debugBrowserEngineOverride = null;
- debugOperatingSystemOverride = null;
});
// TODO(yjbanov): this test will need to be adjusted for Safari when we add
// Safari testing.
test('sends a tap action when browser requests focus', () async {
final SemanticsActionLogger logger = SemanticsActionLogger();
- semantics()
- ..debugOverrideTimestampFunction(() => _testTime)
- ..semanticsEnabled = true;
-
createTextFieldSemantics(value: 'hello');
final DomElement textField = appHostNode
@@ -127,18 +81,12 @@
expect(appHostNode.activeElement, textField);
expect(await logger.idLog.first, 0);
expect(await logger.actionLog.first, ui.SemanticsAction.tap);
-
- semantics().semanticsEnabled = false;
- }, // TODO(yjbanov): https://github.com/flutter/flutter/issues/46638
+ }, // TODO(yjbanov): https://github.com/flutter/flutter/issues/46638
// TODO(yjbanov): https://github.com/flutter/flutter/issues/50590
// TODO(yjbanov): https://github.com/flutter/flutter/issues/50754
skip: browserEngine != BrowserEngine.blink);
- test('Syncs semantic state from framework', () async {
- semantics()
- ..debugOverrideTimestampFunction(() => _testTime)
- ..semanticsEnabled = true;
-
+ test('Syncs semantic state from framework', () {
expect(domDocument.activeElement, domDocument.body);
expect(appHostNode.activeElement, null);
@@ -162,13 +110,14 @@
rect: const ui.Rect.fromLTWH(0, 0, 10, 15),
);
- final TextField textField = textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
+ final TextField textField =
+ textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
expect(domDocument.activeElement, flutterViewEmbedder.glassPaneElement);
expect(appHostNode.activeElement, strategy.domElement);
expect(textField.editableElement, strategy.domElement);
- expect(textField.editableElement.getAttribute('aria-label'), 'greeting');
- expect(textField.editableElement.style.width, '10px');
- expect(textField.editableElement.style.height, '15px');
+ expect(textField.activeEditableElement.getAttribute('aria-label'), 'greeting');
+ expect(textField.activeEditableElement.style.width, '10px');
+ expect(textField.activeEditableElement.style.height, '15px');
// Update
createTextFieldSemantics(
@@ -180,12 +129,11 @@
expect(domDocument.activeElement, domDocument.body);
expect(appHostNode.activeElement, null);
expect(strategy.domElement, null);
- expect(textField.editableElement.getAttribute('aria-label'), 'farewell');
- expect(textField.editableElement.style.width, '12px');
- expect(textField.editableElement.style.height, '17px');
+ expect(textField.activeEditableElement.getAttribute('aria-label'), 'farewell');
+ expect(textField.activeEditableElement.style.width, '12px');
+ expect(textField.activeEditableElement.style.height, '17px');
strategy.disable();
- semantics().semanticsEnabled = false;
// There was no user interaction with the <input> element,
// so we should expect no engine-to-framework feedback.
@@ -195,11 +143,7 @@
test(
'Does not overwrite text value and selection editing state on semantic updates',
- () async {
- semantics()
- ..debugOverrideTimestampFunction(() => _testTime)
- ..semanticsEnabled = true;
-
+ () {
strategy.enable(
singlelineConfig,
onChange: (_, __) {},
@@ -216,7 +160,7 @@
final TextField textField =
textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
final DomHTMLInputElement editableElement =
- textField.editableElement as DomHTMLInputElement;
+ textField.activeEditableElement as DomHTMLInputElement;
expect(editableElement, strategy.domElement);
expect(editableElement.value, '');
@@ -224,16 +168,11 @@
expect(editableElement.selectionEnd, 0);
strategy.disable();
- semantics().semanticsEnabled = false;
});
test(
'Updates editing state when receiving framework messages from the text input channel',
- () async {
- semantics()
- ..debugOverrideTimestampFunction(() => _testTime)
- ..semanticsEnabled = true;
-
+ () {
expect(domDocument.activeElement, domDocument.body);
expect(appHostNode.activeElement, null);
@@ -253,7 +192,7 @@
final TextField textField =
textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
final DomHTMLInputElement editableElement =
- textField.editableElement as DomHTMLInputElement;
+ textField.activeEditableElement as DomHTMLInputElement;
// No updates expected on semantic updates
expect(editableElement, strategy.domElement);
@@ -268,7 +207,7 @@
'selectionBase': 2,
'selectionExtent': 3,
});
- sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
+ sendFrameworkMessage(codec.encodeMethodCall(setEditingState), testTextEditing);
// Editing state should now be updated
expect(editableElement.value, 'updated');
@@ -276,14 +215,9 @@
expect(editableElement.selectionEnd, 3);
strategy.disable();
- semantics().semanticsEnabled = false;
});
- test('Gives up focus after DOM blur', () async {
- semantics()
- ..debugOverrideTimestampFunction(() => _testTime)
- ..semanticsEnabled = true;
-
+ test('Gives up focus after DOM blur', () {
expect(domDocument.activeElement, domDocument.body);
expect(appHostNode.activeElement, null);
@@ -297,24 +231,20 @@
isFocused: true,
);
- final TextField textField = textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
+ final TextField textField =
+ textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
expect(textField.editableElement, strategy.domElement);
expect(domDocument.activeElement, flutterViewEmbedder.glassPaneElement);
expect(appHostNode.activeElement, strategy.domElement);
// The input should not refocus after blur.
- textField.editableElement.blur();
+ textField.activeEditableElement.blur();
expect(domDocument.activeElement, domDocument.body);
expect(appHostNode.activeElement, null);
strategy.disable();
- semantics().semanticsEnabled = false;
});
test('Does not dispose and recreate dom elements in persistent mode', () {
- semantics()
- ..debugOverrideTimestampFunction(() => _testTime)
- ..semanticsEnabled = true;
-
strategy.enable(
singlelineConfig,
onChange: (_, __) {},
@@ -337,20 +267,16 @@
expect(strategy.domElement, isNull);
// It doesn't remove the DOM element.
- final TextField textField = textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
+ final TextField textField =
+ textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
expect(appHostNode.contains(textField.editableElement), isTrue);
// Editing element is not enabled.
expect(strategy.isEnabled, isFalse);
expect(domDocument.activeElement, domDocument.body);
expect(appHostNode.activeElement, null);
- semantics().semanticsEnabled = false;
});
test('Refocuses when setting editing state', () {
- semantics()
- ..debugOverrideTimestampFunction(() => _testTime)
- ..semanticsEnabled = true;
-
strategy.enable(
singlelineConfig,
onChange: (_, __) {},
@@ -390,14 +316,9 @@
expect(appHostNode.activeElement, strategy.domElement);
strategy.disable();
- semantics().semanticsEnabled = false;
});
test('Works in multi-line mode', () {
- semantics()
- ..debugOverrideTimestampFunction(() => _testTime)
- ..semanticsEnabled = true;
-
strategy.enable(
multilineConfig,
onChange: (_, __) {},
@@ -409,7 +330,8 @@
isMultiline: true,
);
- final DomHTMLTextAreaElement textArea = strategy.domElement! as DomHTMLTextAreaElement;
+ final DomHTMLTextAreaElement textArea =
+ strategy.domElement! as DomHTMLTextAreaElement;
expect(domDocument.activeElement, flutterViewEmbedder.glassPaneElement);
expect(appHostNode.activeElement, strategy.domElement);
@@ -429,14 +351,9 @@
expect(appHostNode.contains(textArea), isTrue);
// Editing element is not enabled.
expect(strategy.isEnabled, isFalse);
- semantics().semanticsEnabled = false;
});
test('Does not position or size its DOM element', () {
- semantics()
- ..debugOverrideTimestampFunction(() => _testTime)
- ..semanticsEnabled = true;
-
strategy.enable(
singlelineConfig,
onChange: (_, __) {},
@@ -473,10 +390,10 @@
checkPlacementIsSetBySemantics();
strategy.placeElement();
checkPlacementIsSetBySemantics();
- semantics().semanticsEnabled = false;
});
- Map<int, SemanticsObject> createTwoFieldSemantics(SemanticsTester builder, { int? focusFieldId }) {
+ Map<int, SemanticsObject> createTwoFieldSemantics(SemanticsTester builder,
+ {int? focusFieldId}) {
builder.updateNode(
id: 0,
children: <SemanticsNodeUpdate>[
@@ -499,11 +416,7 @@
return builder.apply();
}
- test('Changes focus from one text field to another through a semantics update', () async {
- semantics()
- ..debugOverrideTimestampFunction(() => _testTime)
- ..semanticsEnabled = true;
-
+ test('Changes focus from one text field to another through a semantics update', () {
strategy.enable(
singlelineConfig,
onChange: (_, __) {},
@@ -525,10 +438,435 @@
expect(appHostNode.activeElement, tester.getTextField(2).editableElement);
expect(strategy.domElement, tester.getTextField(2).editableElement);
}
+ });
+ }, skip: isIosSafari);
+ group('$SemanticsTextEditingStrategy in iOS', () {
+ late HybridTextEditing testTextEditing;
+ late SemanticsTextEditingStrategy strategy;
+
+ setUp(() {
+ testTextEditing = HybridTextEditing();
+ SemanticsTextEditingStrategy.ensureInitialized(testTextEditing);
+ strategy = SemanticsTextEditingStrategy.instance;
+ testTextEditing.debugTextEditingStrategyOverride = strategy;
+ testTextEditing.configuration = singlelineConfig;
+ debugBrowserEngineOverride = BrowserEngine.webkit;
+ debugOperatingSystemOverride = OperatingSystem.iOs;
+ semantics()
+ ..debugOverrideTimestampFunction(() => _testTime)
+ ..semanticsEnabled = true;
+ });
+
+ tearDown(() {
+ debugBrowserEngineOverride = null;
+ debugOperatingSystemOverride = null;
semantics().semanticsEnabled = false;
});
- });
+
+ test('does not render a text field', () {
+ expect(appHostNode.querySelector('flt-semantics[role="textbox"]'), isNull);
+ createTextFieldSemanticsForIos(value: 'hello');
+ expect(appHostNode.querySelector('flt-semantics[role="textbox"]'), isNotNull);
+ });
+
+ test('tap detection works', () async {
+ final SemanticsActionLogger logger = SemanticsActionLogger();
+ createTextFieldSemanticsForIos(value: 'hello');
+
+ final DomElement textField = appHostNode
+ .querySelector('flt-semantics[role="textbox"]')!;
+
+ simulateTap(textField);
+ expect(await logger.idLog.first, 0);
+ expect(await logger.actionLog.first, ui.SemanticsAction.tap);
+ });
+
+ test('Syncs semantic state from framework', () {
+ expect(domDocument.activeElement, domDocument.body);
+ expect(appHostNode.activeElement, null);
+
+ int changeCount = 0;
+ int actionCount = 0;
+ strategy.enable(
+ singlelineConfig,
+ onChange: (_, __) {
+ changeCount++;
+ },
+ onAction: (_) {
+ actionCount++;
+ },
+ );
+
+ // Create
+ final SemanticsObject textFieldSemantics = createTextFieldSemanticsForIos(
+ value: 'hello',
+ label: 'greeting',
+ isFocused: true,
+ rect: const ui.Rect.fromLTWH(0, 0, 10, 15),
+ );
+ final TextField textField =
+ textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
+
+ expect(domDocument.activeElement, flutterViewEmbedder.glassPaneElement);
+ expect(appHostNode.activeElement, strategy.domElement);
+ expect(textField.editableElement, strategy.domElement);
+ expect(textField.activeEditableElement.getAttribute('aria-label'), 'greeting');
+ expect(textField.activeEditableElement.style.width, '10px');
+ expect(textField.activeEditableElement.style.height, '15px');
+
+ // Update
+ createTextFieldSemanticsForIos(
+ value: 'bye',
+ label: 'farewell',
+ rect: const ui.Rect.fromLTWH(0, 0, 12, 17),
+ );
+ final DomElement textBox =
+ appHostNode.querySelector('flt-semantics[role="textbox"]')!;
+
+ expect(strategy.domElement, null);
+ expect(domDocument.activeElement, flutterViewEmbedder.glassPaneElement);
+ expect(appHostNode.activeElement, textBox);
+ expect(textBox.getAttribute('aria-label'), 'farewell');
+
+ strategy.disable();
+
+ // There was no user interaction with the <input> element,
+ // so we should expect no engine-to-framework feedback.
+ expect(changeCount, 0);
+ expect(actionCount, 0);
+ });
+
+ test(
+ 'Does not overwrite text value and selection editing state on semantic updates',
+ () {
+ strategy.enable(
+ singlelineConfig,
+ onChange: (_, __) {},
+ onAction: (_) {},
+ );
+
+ final SemanticsObject textFieldSemantics = createTextFieldSemanticsForIos(
+ value: 'hello',
+ textSelectionBase: 1,
+ textSelectionExtent: 3,
+ isFocused: true,
+ rect: const ui.Rect.fromLTWH(0, 0, 10, 15));
+
+ final TextField textField =
+ textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
+ final DomHTMLInputElement editableElement =
+ textField.activeEditableElement as DomHTMLInputElement;
+
+ expect(editableElement, strategy.domElement);
+ expect(editableElement.value, '');
+ expect(editableElement.selectionStart, 0);
+ expect(editableElement.selectionEnd, 0);
+
+ strategy.disable();
+ });
+
+ test(
+ 'Updates editing state when receiving framework messages from the text input channel',
+ () {
+ expect(domDocument.activeElement, domDocument.body);
+ expect(appHostNode.activeElement, null);
+
+ strategy.enable(
+ singlelineConfig,
+ onChange: (_, __) {},
+ onAction: (_) {},
+ );
+
+ final SemanticsObject textFieldSemantics = createTextFieldSemanticsForIos(
+ value: 'hello',
+ textSelectionBase: 1,
+ textSelectionExtent: 3,
+ isFocused: true,
+ rect: const ui.Rect.fromLTWH(0, 0, 10, 15));
+
+ final TextField textField =
+ textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
+ final DomHTMLInputElement editableElement =
+ textField.activeEditableElement as DomHTMLInputElement;
+
+ // No updates expected on semantic updates
+ expect(editableElement, strategy.domElement);
+ expect(editableElement.value, '');
+ expect(editableElement.selectionStart, 0);
+ expect(editableElement.selectionEnd, 0);
+
+ // Update from framework
+ const MethodCall setEditingState =
+ MethodCall('TextInput.setEditingState', <String, dynamic>{
+ 'text': 'updated',
+ 'selectionBase': 2,
+ 'selectionExtent': 3,
+ });
+ sendFrameworkMessage(codec.encodeMethodCall(setEditingState), testTextEditing);
+
+ // Editing state should now be updated
+ // expect(editableElement.value, 'updated');
+ expect(editableElement.selectionStart, 2);
+ expect(editableElement.selectionEnd, 3);
+
+ strategy.disable();
+ });
+
+ test('Gives up focus after DOM blur', () {
+ expect(domDocument.activeElement, domDocument.body);
+ expect(appHostNode.activeElement, null);
+
+ strategy.enable(
+ singlelineConfig,
+ onChange: (_, __) {},
+ onAction: (_) {},
+ );
+ final SemanticsObject textFieldSemantics = createTextFieldSemanticsForIos(
+ value: 'hello',
+ isFocused: true,
+ );
+ final TextField textField =
+ textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
+
+ expect(textField.editableElement, strategy.domElement);
+ expect(domDocument.activeElement, flutterViewEmbedder.glassPaneElement);
+ expect(appHostNode.activeElement, strategy.domElement);
+
+ // The input should not refocus after blur.
+ textField.activeEditableElement.blur();
+ final DomElement textBox =
+ appHostNode.querySelector('flt-semantics[role="textbox"]')!;
+ expect(domDocument.activeElement, flutterViewEmbedder.glassPaneElement);
+ expect(appHostNode.activeElement, textBox);
+
+ strategy.disable();
+ });
+
+ test('Disposes and recreates dom elements in persistent mode', () {
+ strategy.enable(
+ singlelineConfig,
+ onChange: (_, __) {},
+ onAction: (_) {},
+ );
+
+ // It doesn't create a new DOM element.
+ expect(strategy.domElement, isNull);
+
+ // During the semantics update the DOM element is created and is focused on.
+ final SemanticsObject textFieldSemantics = createTextFieldSemanticsForIos(
+ value: 'hello',
+ isFocused: true,
+ );
+ expect(strategy.domElement, isNotNull);
+ expect(domDocument.activeElement, flutterViewEmbedder.glassPaneElement);
+ expect(appHostNode.activeElement, strategy.domElement);
+
+ strategy.disable();
+ expect(strategy.domElement, isNull);
+
+ // It removes the DOM element.
+ final TextField textField = textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
+ expect(appHostNode.contains(textField.editableElement), isFalse);
+ // Editing element is not enabled.
+ expect(strategy.isEnabled, isFalse);
+ // Focus is on the semantic object
+ final DomElement textBox =
+ appHostNode.querySelector('flt-semantics[role="textbox"]')!;
+ expect(domDocument.activeElement, flutterViewEmbedder.glassPaneElement);
+ expect(appHostNode.activeElement, textBox);
+ });
+
+ test('Refocuses when setting editing state', () {
+ strategy.enable(
+ singlelineConfig,
+ onChange: (_, __) {},
+ onAction: (_) {},
+ );
+
+ createTextFieldSemanticsForIos(
+ value: 'hello',
+ isFocused: true,
+ );
+ expect(strategy.domElement, isNotNull);
+ expect(domDocument.activeElement, flutterViewEmbedder.glassPaneElement);
+ expect(appHostNode.activeElement, strategy.domElement);
+
+ // Blur the element without telling the framework.
+ strategy.activeDomElement.blur();
+ final DomElement textBox =
+ appHostNode.querySelector('flt-semantics[role="textbox"]')!;
+ expect(domDocument.activeElement, flutterViewEmbedder.glassPaneElement);
+ expect(appHostNode.activeElement, textBox);
+
+ // The input will have focus after editing state is set and semantics updated.
+ strategy.setEditingState(EditingState(text: 'foo'));
+
+ // NOTE: at this point some browsers, e.g. some versions of Safari will
+ // have set the focus on the editing element as a result of setting
+ // the test selection range. Other browsers require an explicit call
+ // to `element.focus()` for the element to acquire focus. So far,
+ // this discrepancy hasn't caused issues, so we're not checking for
+ // any particular focus state between setEditingState and
+ // createTextFieldSemantics. However, this is something for us to
+ // keep in mind in case this causes issues in the future.
+
+ createTextFieldSemanticsForIos(
+ value: 'hello',
+ isFocused: true,
+ );
+ expect(domDocument.activeElement, flutterViewEmbedder.glassPaneElement);
+ expect(appHostNode.activeElement, strategy.domElement);
+
+ strategy.disable();
+ });
+
+ test('Works in multi-line mode', () {
+ strategy.enable(
+ multilineConfig,
+ onChange: (_, __) {},
+ onAction: (_) {},
+ );
+ createTextFieldSemanticsForIos(
+ value: 'hello',
+ isFocused: true,
+ isMultiline: true,
+ );
+
+ final DomHTMLTextAreaElement textArea = strategy.domElement! as DomHTMLTextAreaElement;
+ expect(domDocument.activeElement, flutterViewEmbedder.glassPaneElement);
+ expect(appHostNode.activeElement, strategy.domElement);
+
+ strategy.enable(
+ singlelineConfig,
+ onChange: (_, __) {},
+ onAction: (_) {},
+ );
+
+ expect(appHostNode.contains(textArea), isTrue);
+
+ textArea.blur();
+ final DomElement textBox =
+ appHostNode.querySelector('flt-semantics[role="textbox"]')!;
+
+ expect(domDocument.activeElement, flutterViewEmbedder.glassPaneElement);
+ expect(appHostNode.activeElement, textBox);
+
+ strategy.disable();
+ // It removes the textarea from the DOM.
+ expect(appHostNode.contains(textArea), isFalse);
+ // Editing element is not enabled.
+ expect(strategy.isEnabled, isFalse);
+ });
+
+ test('Does not position or size its DOM element', () {
+ strategy.enable(
+ singlelineConfig,
+ onChange: (_, __) {},
+ onAction: (_) {},
+ );
+
+ // Send width and height that are different from semantics values on
+ // purpose.
+ final Matrix4 transform = Matrix4.translationValues(14, 15, 0);
+ final EditableTextGeometry geometry = EditableTextGeometry(
+ height: 12,
+ width: 13,
+ globalTransform: transform.storage,
+ );
+ const ui.Rect semanticsRect = ui.Rect.fromLTRB(0, 0, 100, 50);
+
+ testTextEditing.acceptCommand(
+ TextInputSetEditableSizeAndTransform(geometry: geometry),
+ () {},
+ );
+
+ createTextFieldSemanticsForIos(
+ value: 'hello',
+ isFocused: true,
+ );
+
+ // Checks that the placement attributes come from semantics and not from
+ // EditableTextGeometry.
+ void checkPlacementIsSetBySemantics() {
+ expect(strategy.activeDomElement.style.transform,
+ isNot(equals(transform.toString())));
+ expect(strategy.activeDomElement.style.width, '${semanticsRect.width}px');
+ expect(strategy.activeDomElement.style.height, '${semanticsRect.height}px');
+ }
+
+ checkPlacementIsSetBySemantics();
+ strategy.placeElement();
+ checkPlacementIsSetBySemantics();
+ });
+
+ test('Changes focus from one text field to another through a semantics update', () {
+ strategy.enable(
+ singlelineConfig,
+ onChange: (_, __) {},
+ onAction: (_) {},
+ );
+
+ // Switch between the two fields a few times.
+ for (int i = 0; i < 1; i++) {
+ final SemanticsTester tester = SemanticsTester(semantics());
+ createTwoFieldSemanticsForIos(tester, focusFieldId: 1);
+
+ expect(tester.apply().length, 3);
+ expect(domDocument.activeElement, flutterViewEmbedder.glassPaneElement);
+ expect(appHostNode.activeElement, tester.getTextField(1).editableElement);
+ expect(strategy.domElement, tester.getTextField(1).editableElement);
+
+ createTwoFieldSemanticsForIos(tester, focusFieldId: 2);
+ expect(tester.apply().length, 3);
+ expect(appHostNode.activeElement, tester.getTextField(2).editableElement);
+ expect(strategy.domElement, tester.getTextField(2).editableElement);
+ }
+ });
+
+ test('input transform is correct', () async {
+ strategy.enable(
+ singlelineConfig,
+ onChange: (_, __) {},
+ onAction: (_) {},
+ );
+ createTextFieldSemanticsForIos(
+ value: 'hello',
+ isFocused: true,
+ );
+ expect(strategy.activeDomElement.style.transform, 'translate(${offScreenOffset}px, ${offScreenOffset}px)');
+ // See [_delayBeforePlacement].
+ await Future<void>.delayed(const Duration(milliseconds: 120) , (){});
+ expect(strategy.activeDomElement.style.transform, '');
+ });
+
+ test('disposes the editable element, if there is one', () {
+ strategy.enable(
+ singlelineConfig,
+ onChange: (_, __) {},
+ onAction: (_) {},
+ );
+ SemanticsObject textFieldSemantics = createTextFieldSemanticsForIos(
+ value: 'hello',
+ );
+ TextField textField =
+ textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
+ expect(textField.editableElement, isNull);
+ textField.dispose();
+ expect(textField.editableElement, isNull);
+
+ textFieldSemantics = createTextFieldSemanticsForIos(
+ value: 'hi',
+ isFocused: true,
+ );
+ textField =
+ textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
+
+ expect(textField.editableElement, isNotNull);
+ textField.dispose();
+ expect(textField.editableElement, isNull);
+ });
+ }, skip: !isSafari);
}
SemanticsObject createTextFieldSemantics({
@@ -557,3 +895,121 @@
tester.apply();
return tester.getSemanticsObject(0);
}
+
+void simulateTap(DomElement element) {
+ element.dispatchEvent(createDomPointerEvent(
+ 'pointerdown',
+ <Object?, Object?>{
+ 'clientX': 125,
+ 'clientY': 248,
+ },
+ ));
+ element.dispatchEvent(createDomPointerEvent(
+ 'pointerup',
+ <Object?, Object?>{
+ 'clientX': 126,
+ 'clientY': 248,
+ },
+ ));
+}
+
+/// An editable DOM element won't be created on iOS unless a tap is detected.
+/// This function mimics the workflow by simulating a tap and sending a second
+/// semantic update.
+SemanticsObject createTextFieldSemanticsForIos({
+ required String value,
+ String label = '',
+ bool isFocused = false,
+ bool isMultiline = false,
+ ui.Rect rect = const ui.Rect.fromLTRB(0, 0, 100, 50),
+ int textSelectionBase = 0,
+ int textSelectionExtent = 0,
+}) {
+ final SemanticsObject textFieldSemantics = createTextFieldSemantics(
+ value: value,
+ isFocused: isFocused,
+ label: label,
+ isMultiline: isMultiline,
+ rect: rect,
+ textSelectionBase: textSelectionBase,
+ textSelectionExtent: textSelectionExtent,
+ );
+
+ if (isFocused) {
+ final TextField textField =
+ textFieldSemantics.debugRoleManagerFor(Role.textField)! as TextField;
+
+ simulateTap(textField.semanticsObject.element);
+
+ return createTextFieldSemantics(
+ value: value,
+ isFocused: isFocused,
+ label: label,
+ isMultiline: isMultiline,
+ rect: rect,
+ textSelectionBase: textSelectionBase,
+ textSelectionExtent: textSelectionExtent,
+ );
+ }
+ return textFieldSemantics;
+}
+
+/// See [createTextFieldSemanticsForIos].
+Map<int, SemanticsObject> createTwoFieldSemanticsForIos(SemanticsTester builder,
+ {int? focusFieldId}) {
+ builder.updateNode(
+ id: 0,
+ children: <SemanticsNodeUpdate>[
+ builder.updateNode(
+ id: 1,
+ isTextField: true,
+ value: 'Hello',
+ label: 'Hello',
+ isFocused: false,
+ rect: const ui.Rect.fromLTWH(0, 0, 10, 10),
+ ),
+ builder.updateNode(
+ id: 2,
+ isTextField: true,
+ value: 'World',
+ label: 'World',
+ isFocused: false,
+ rect: const ui.Rect.fromLTWH(20, 20, 10, 10),
+ ),
+ ],
+ );
+ builder.apply();
+ final String label = focusFieldId == 1 ? 'Hello' : 'World';
+ final DomElement textBox =
+ appHostNode.querySelector('flt-semantics[aria-label="$label"]')!;
+
+ simulateTap(textBox);
+
+ builder.updateNode(
+ id: 0,
+ children: <SemanticsNodeUpdate>[
+ builder.updateNode(
+ id: 1,
+ isTextField: true,
+ value: 'Hello',
+ label: 'Hello',
+ isFocused: focusFieldId == 1,
+ rect: const ui.Rect.fromLTWH(0, 0, 10, 10),
+ ),
+ builder.updateNode(
+ id: 2,
+ isTextField: true,
+ value: 'World',
+ label: 'World',
+ isFocused: focusFieldId == 2,
+ rect: const ui.Rect.fromLTWH(20, 20, 10, 10),
+ ),
+ ],
+ );
+ return builder.apply();
+}
+
+/// Emulates sending of a message by the framework to the engine.
+void sendFrameworkMessage(ByteData? message, HybridTextEditing testTextEditing) {
+ testTextEditing.channel.handleTextInput(message, (ByteData? data) {});
+}