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' ;
56import 'package:ui/ui.dart' as ui;
67
78import '../browser_detection.dart' ;
89import '../dom.dart' ;
10+ import '../embedder.dart' ;
911import '../platform_dispatcher.dart' ;
1012import '../safe_browser_api.dart' ;
1113import '../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.
211214class 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}
0 commit comments