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) {});
+}