Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit eb18ac0

Browse files
authored
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
1 parent 19fe86c commit eb18ac0

File tree

3 files changed

+720
-202
lines changed

3 files changed

+720
-202
lines changed

lib/web_ui/lib/src/engine/semantics/text_field.dart

Lines changed: 137 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import 'dart:async';
56
import 'package:ui/ui.dart' as ui;
67

78
import '../browser_detection.dart';
89
import '../dom.dart';
10+
import '../embedder.dart';
911
import '../platform_dispatcher.dart';
1012
import '../safe_browser_api.dart';
1113
import '../text_editing/text_editing.dart';
@@ -29,7 +31,8 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy {
2931
/// Initializes the [SemanticsTextEditingStrategy] singleton.
3032
///
3133
/// This method must be called prior to accessing [instance].
32-
static SemanticsTextEditingStrategy ensureInitialized(HybridTextEditing owner) {
34+
static SemanticsTextEditingStrategy ensureInitialized(
35+
HybridTextEditing owner) {
3336
if (_instance != null && instance.owner == owner) {
3437
return instance;
3538
}
@@ -205,35 +208,62 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy {
205208
/// This role is implemented via a content-editable HTML element. This role does
206209
/// not proactively switch modes depending on the current
207210
/// [EngineSemanticsOwner.gestureMode]. However, in Chrome on Android it ignores
208-
/// browser gestures when in pointer mode. In Safari on iOS touch events are
211+
/// browser gestures when in pointer mode. In Safari on iOS pointer events are
209212
/// used to detect text box invocation. This is because Safari issues touch
210213
/// events even when Voiceover is enabled.
211214
class TextField extends RoleManager {
212215
TextField(SemanticsObject semanticsObject)
213216
: super(Role.textField, semanticsObject) {
214-
editableElement =
215-
semanticsObject.hasFlag(ui.SemanticsFlag.isMultiline)
216-
? createDomHTMLTextAreaElement()
217-
: createDomHTMLInputElement();
218217
_setupDomElement();
219218
}
220219

221220
/// The element used for editing, e.g. `<input>`, `<textarea>`.
222-
late final DomHTMLElement editableElement;
221+
DomHTMLElement? editableElement;
222+
223+
/// Same as [editableElement] but null-checked.
224+
DomHTMLElement get activeEditableElement {
225+
assert(
226+
editableElement != null,
227+
'The textField does not have an active editable element',
228+
);
229+
return editableElement!;
230+
}
231+
232+
/// Timer that times when to set the location of the input text.
233+
///
234+
/// This is only used for iOS. In iOS, virtual keyboard shifts the screen.
235+
/// There is no callback to know if the keyboard is up and how much the screen
236+
/// has shifted. Therefore instead of listening to the shift and passing this
237+
/// information to Flutter Framework, we are trying to stop the shift.
238+
///
239+
/// In iOS, the virtual keyboard shifts the screen up if the focused input
240+
/// element is under the keyboard or very close to the keyboard. Before the
241+
/// focus is called we are positioning it offscreen. The location of the input
242+
/// in iOS is set to correct place, 100ms after focus. We use this timer for
243+
/// timing this delay.
244+
Timer? _positionInputElementTimer;
245+
static const Duration _delayBeforePlacement = Duration(milliseconds: 100);
246+
247+
void _initializeEditableElement() {
248+
assert(editableElement == null,
249+
'Editable element has already been initialized');
250+
251+
editableElement = semanticsObject.hasFlag(ui.SemanticsFlag.isMultiline)
252+
? createDomHTMLTextAreaElement()
253+
: createDomHTMLInputElement();
223254

224-
void _setupDomElement() {
225255
// On iOS, even though the semantic text field is transparent, the cursor
226256
// and text highlighting are still visible. The cursor and text selection
227257
// are made invisible by CSS in [FlutterViewEmbedder.reset].
228258
// But there's one more case where iOS highlights text. That's when there's
229259
// and autocorrect suggestion. To disable that, we have to do the following:
230-
editableElement
260+
activeEditableElement
231261
..spellcheck = false
232262
..setAttribute('autocorrect', 'off')
233263
..setAttribute('autocomplete', 'off')
234264
..setAttribute('data-semantics-role', 'text-field');
235265

236-
editableElement.style
266+
activeEditableElement.style
237267
..position = 'absolute'
238268
// `top` and `left` are intentionally set to zero here.
239269
//
@@ -248,8 +278,10 @@ class TextField extends RoleManager {
248278
..left = '0'
249279
..width = '${semanticsObject.rect!.width}px'
250280
..height = '${semanticsObject.rect!.height}px';
251-
semanticsObject.element.append(editableElement);
281+
semanticsObject.element.append(activeEditableElement);
282+
}
252283

284+
void _setupDomElement() {
253285
switch (browserEngine) {
254286
case BrowserEngine.blink:
255287
case BrowserEngine.firefox:
@@ -266,8 +298,9 @@ class TextField extends RoleManager {
266298
/// When in browser gesture mode, the focus is forwarded to the framework as
267299
/// a tap to initialize editing.
268300
void _initializeForBlink() {
269-
editableElement.addEventListener(
270-
'focus', allowInterop((DomEvent event) {
301+
_initializeEditableElement();
302+
activeEditableElement.addEventListener('focus',
303+
allowInterop((DomEvent event) {
271304
if (semanticsObject.owner.gestureMode != GestureMode.browserGestures) {
272305
return;
273306
}
@@ -277,29 +310,45 @@ class TextField extends RoleManager {
277310
}));
278311
}
279312

280-
/// Safari on iOS reports text field activation via touch events.
313+
/// Safari on iOS reports text field activation via pointer events.
281314
///
282-
/// This emulates a tap recognizer to detect the activation. Because touch
315+
/// This emulates a tap recognizer to detect the activation. Because pointer
283316
/// events are present regardless of whether accessibility is enabled or not,
284317
/// this mode is always enabled.
318+
///
319+
/// In iOS, the virtual keyboard shifts the screen up if the focused input
320+
/// element is under the keyboard or very close to the keyboard. To avoid the shift,
321+
/// the creation of the editable element is delayed until a tap is detected.
322+
///
323+
/// In the absence of an editable DOM element, role of 'textbox' is assigned to the
324+
/// semanticsObject.element to communicate to the assistive technologies that
325+
/// the user can start editing by tapping on the element. Once a tap is detected,
326+
/// the editable element gets created and the role of textbox is removed from
327+
/// semanicsObject.element to avoid confusing VoiceOver.
285328
void _initializeForWebkit() {
286329
// Safari for desktop is also initialized as the other browsers.
287330
if (operatingSystem == OperatingSystem.macOs) {
288331
_initializeForBlink();
289332
return;
290333
}
334+
335+
semanticsObject.element
336+
..setAttribute('role', 'textbox')
337+
..setAttribute('contenteditable', 'false')
338+
..setAttribute('tabindex', '0');
339+
291340
num? lastPointerDownOffsetX;
292341
num? lastPointerDownOffsetY;
293342

294-
editableElement.addEventListener('pointerdown',
343+
semanticsObject.element.addEventListener('pointerdown',
295344
allowInterop((DomEvent event) {
296345
final DomPointerEvent pointerEvent = event as DomPointerEvent;
297346
lastPointerDownOffsetX = pointerEvent.clientX;
298347
lastPointerDownOffsetY = pointerEvent.clientY;
299348
}), true);
300349

301-
editableElement.addEventListener(
302-
'pointerup', allowInterop((DomEvent event) {
350+
semanticsObject.element.addEventListener('pointerup',
351+
allowInterop((DomEvent event) {
303352
final DomPointerEvent pointerEvent = event as DomPointerEvent;
304353

305354
if (lastPointerDownOffsetX != null) {
@@ -318,19 +367,7 @@ class TextField extends RoleManager {
318367
// Recognize it as a tap that requires a keyboard.
319368
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
320369
semanticsObject.id, ui.SemanticsAction.tap, null);
321-
322-
// We need to call focus for the following scenario:
323-
// 1. The virtial keyboard in iOS gets dismissed by the 'Done' button
324-
// located at the top right of the keyboard.
325-
// 2. The user tries to focus on the input field again, either by
326-
// VoiceOver or manually, but the keyboard does not show up.
327-
//
328-
// In this scenario, the Flutter framework does not send a semantic update,
329-
// so we need to call focus after detecting a tap to make sure that the
330-
// virtual keyboard will show.
331-
if (semanticsObject.hasFocus) {
332-
editableElement.focus();
333-
}
370+
_invokeIosWorkaround();
334371
}
335372
} else {
336373
assert(lastPointerDownOffsetY == null);
@@ -341,66 +378,88 @@ class TextField extends RoleManager {
341378
}), true);
342379
}
343380

344-
bool _hasFocused = false;
345-
346-
@override
347-
void update() {
348-
// The user is editing the semantic text field directly, so there's no need
349-
// to do any update here.
350-
if (semanticsObject.hasLabel) {
351-
editableElement.setAttribute(
352-
'aria-label',
353-
semanticsObject.label!,
354-
);
355-
} else {
356-
editableElement.removeAttribute('aria-label');
381+
void _invokeIosWorkaround() {
382+
if (editableElement != null) {
383+
return;
357384
}
358385

359-
editableElement.style
360-
..width = '${semanticsObject.rect!.width}px'
361-
..height = '${semanticsObject.rect!.height}px';
386+
_initializeEditableElement();
387+
activeEditableElement.style.transform = 'translate(${offScreenOffset}px, ${offScreenOffset}px)';
388+
_positionInputElementTimer?.cancel();
389+
_positionInputElementTimer = Timer(_delayBeforePlacement, () {
390+
editableElement?.style.transform = '';
391+
_positionInputElementTimer = null;
392+
});
393+
394+
// Can not have both activeEditableElement and semanticsObject.element
395+
// represent the same text field. It will confuse VoiceOver, so `role` needs to
396+
// be assigned and removed, based on whether or not editableElement exists.
397+
activeEditableElement.focus();
398+
semanticsObject.element.removeAttribute('role');
399+
400+
activeEditableElement.addEventListener('blur',
401+
allowInterop((DomEvent event) {
402+
semanticsObject.element.setAttribute('role', 'textbox');
403+
activeEditableElement.remove();
404+
SemanticsTextEditingStrategy.instance.deactivate(this);
362405

363-
// Whether we should request that the browser shift focus to the editable
364-
// element, so that both the framework and the browser agree on what's
365-
// currently focused.
366-
bool needsDomFocusRequest = false;
406+
// Focus on semantics element before removing the editable element, so that
407+
// the user can continue navigating the page with the assistive technology.
408+
semanticsObject.element.focus();
409+
editableElement = null;
410+
}));
411+
}
367412

368-
if (semanticsObject.hasFocus) {
369-
if (!_hasFocused) {
370-
_hasFocused = true;
413+
@override
414+
void update() {
415+
// Ignore the update if editableElement has not been created yet.
416+
// On iOS Safari, when the user dismisses the keyboard using the 'done' button,
417+
// we recieve a `blur` event from the browswer and a semantic update with
418+
// [hasFocus] set to true from the framework. In this case, we ignore the update
419+
// and wait for a tap event before invoking the iOS workaround and creating
420+
// the editable element.
421+
if (editableElement != null) {
422+
activeEditableElement.style
423+
..width = '${semanticsObject.rect!.width}px'
424+
..height = '${semanticsObject.rect!.height}px';
425+
426+
if (semanticsObject.hasFocus) {
427+
if (flutterViewEmbedder.glassPaneShadow!.activeElement !=
428+
activeEditableElement) {
429+
semanticsObject.owner.addOneTimePostUpdateCallback(() {
430+
activeEditableElement.focus();
431+
});
432+
}
371433
SemanticsTextEditingStrategy.instance.activate(this);
372-
needsDomFocusRequest = true;
373-
}
374-
if (domDocument.activeElement != editableElement) {
375-
needsDomFocusRequest = true;
376-
}
377-
} else if (_hasFocused) {
378-
SemanticsTextEditingStrategy.instance.deactivate(this);
379-
380-
if (_hasFocused && domDocument.activeElement == editableElement) {
381-
// Unlike `editableElement.focus()` we don't need to schedule `blur`
382-
// post-update because `document.activeElement` implies that the
383-
// element is already attached to the DOM. If it's not, it can't
384-
// possibly be focused and therefore there's no need to blur.
385-
editableElement.blur();
434+
} else if (flutterViewEmbedder.glassPaneShadow!.activeElement ==
435+
activeEditableElement) {
436+
if (!isIosSafari) {
437+
SemanticsTextEditingStrategy.instance.deactivate(this);
438+
// Only apply text, because this node is not focused.
439+
}
440+
activeEditableElement.blur();
386441
}
387-
_hasFocused = false;
388442
}
389443

390-
if (needsDomFocusRequest) {
391-
// Schedule focus post-update to make sure the element is attached to
392-
// the document. Otherwise focus() has no effect.
393-
semanticsObject.owner.addOneTimePostUpdateCallback(() {
394-
if (domDocument.activeElement != editableElement) {
395-
editableElement.focus();
396-
}
397-
});
444+
final DomElement element = editableElement ?? semanticsObject.element;
445+
if (semanticsObject.hasLabel) {
446+
element.setAttribute(
447+
'aria-label',
448+
semanticsObject.label!,
449+
);
450+
} else {
451+
element.removeAttribute('aria-label');
398452
}
399453
}
400454

401455
@override
402456
void dispose() {
403-
editableElement.remove();
457+
_positionInputElementTimer?.cancel();
458+
_positionInputElementTimer = null;
459+
// on iOS, the `blur` event listener callback will remove the element.
460+
if (!isIosSafari) {
461+
editableElement?.remove();
462+
}
404463
SemanticsTextEditingStrategy.instance.deactivate(this);
405464
}
406465
}

lib/web_ui/lib/src/engine/text_editing/text_editing.dart

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ bool _debugPrintTextInputCommands = false;
3434
/// The `keyCode` of the "Enter" key.
3535
const int _kReturnKeyCode = 13;
3636

37+
/// Offset in pixels to place an element outside of the screen.
38+
const int offScreenOffset = -9999;
39+
3740
/// Blink and Webkit engines, bring an overlay on top of the text field when it
3841
/// is autofilled.
3942
bool browserHasAutofillOverlay() =>
@@ -119,8 +122,8 @@ void _hideAutofillElements(DomHTMLElement domElement,
119122

120123
if (isOffScreen) {
121124
elementStyle
122-
..top = '-9999px'
123-
..left = '-9999px';
125+
..top = '${offScreenOffset}px'
126+
..left = '${offScreenOffset}px';
124127
}
125128

126129
if (browserHasAutofillOverlay()) {
@@ -1509,7 +1512,7 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy {
15091512
/// Position the element outside of the page before focusing on it. This is
15101513
/// useful for not triggering a scroll when iOS virtual keyboard is
15111514
/// coming up.
1512-
activeDomElement.style.transform = 'translate(-9999px, -9999px)';
1515+
activeDomElement.style.transform = 'translate(${offScreenOffset}px, ${offScreenOffset}px)';
15131516

15141517
_canPosition = false;
15151518
}

0 commit comments

Comments
 (0)