From 8455f8e7254a817fd579d49ae29f632081384714 Mon Sep 17 00:00:00 2001 From: JunbinDeng Date: Sat, 5 Mar 2022 17:03:01 +0800 Subject: [PATCH 1/7] [web] Fix done button click not blur in iOS keyboard (#96357) --- .../src/engine/text_editing/text_editing.dart | 15 +++++- lib/web_ui/test/text_editing_test.dart | 50 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) 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 1f7918577f932..5a78a960190e9 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 @@ -1389,6 +1389,16 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { Timer? _positionInputElementTimer; static const Duration _delayBeforePlacement = Duration(milliseconds: 100); + /// The interval is considered as fast between blur subscription and callback. + /// + /// This is only used for iOS. The blur callback may trigger as soon as + /// the creation of the subscription. In that case, the virtual keyboard will + /// show and hide again occasionally. + /// + /// Lesser than the interval allows the virtual keyboard to keep showing up + /// instead of hiding rapidly. + static const Duration _blurFastCallbackInterval = Duration(milliseconds: 200); + /// Whether or not the input element can be positioned at this point in time. /// /// This is currently only used in iOS. It's set to false before focusing the @@ -1453,6 +1463,9 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { _addTapListener(); + // Record start time of blur subscription. + final DateTime blurSubscriptionStart = DateTime.now(); + // On iOS, blur is trigerred in the following cases: // // 1. The browser app is sent to the background (or the tab is changed). In @@ -1465,7 +1478,7 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { // okay because the virtual keyboard will hide, and as soon as the user // taps the text field again, the virtual keyboard will come up. subscriptions.add(activeDomElement.onBlur.listen((_) { - if (windowHasFocus) { + if (DateTime.now().difference(blurSubscriptionStart) < _blurFastCallbackInterval) { activeDomElement.focus(); } else { owner.sendTextConnectionClosedToFrameworkIfAny(); diff --git a/lib/web_ui/test/text_editing_test.dart b/lib/web_ui/test/text_editing_test.dart index 486b3e87c57fc..a19d3c1f0dabe 100644 --- a/lib/web_ui/test/text_editing_test.dart +++ b/lib/web_ui/test/text_editing_test.dart @@ -675,6 +675,56 @@ void testMain() { // TODO(mdebbar): https://github.com/flutter/flutter/issues/50769 skip: browserEngine == BrowserEngine.edge); + test('focus and disconnection with delaying blur in iOS', () async { + // Test on ios-safari only. + if (browserEngine == BrowserEngine.webkit && + operatingSystem == OperatingSystem.iOs) { + final MethodCall setClient = MethodCall( + 'TextInput.setClient', [123, flutterSinglelineConfig]); + sendFrameworkMessage(codec.encodeMethodCall(setClient)); + + const MethodCall setEditingState = + MethodCall('TextInput.setEditingState', { + 'text': 'abcd', + 'selectionBase': 2, + 'selectionExtent': 3, + }); + sendFrameworkMessage(codec.encodeMethodCall(setEditingState)); + + // Editing shouldn't have started yet. + expect(defaultTextEditingRoot.activeElement, null); + + const MethodCall show = MethodCall('TextInput.show'); + sendFrameworkMessage(codec.encodeMethodCall(show)); + + // The "setSizeAndTransform" message has to be here before we call + // checkInputEditingState, since on some platforms (e.g. Desktop Safari) + // we don't put the input element into the DOM until we get its correct + // dimensions from the framework. + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(150, 50, + Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + + checkInputEditingState( + textEditing!.strategy.domElement, 'abcd', 2, 3); + expect(textEditing!.isEditing, isTrue); + + // Delay for not to be a fast callback with blur. + await Future.delayed(const Duration(milliseconds: 200)); + // DOM element is blurred. + textEditing!.strategy.domElement!.blur(); + + expect(spy.messages, hasLength(1)); + expect(spy.messages[0].channel, 'flutter/textinput'); + expect( + spy.messages[0].methodName, 'TextInputClient.onConnectionClosed'); + await Future.delayed(Duration.zero); + // DOM element loses the focus. + expect(defaultTextEditingRoot.activeElement, null); + } + }); + test('finishAutofillContext closes connection no autofill element', () async { final MethodCall setClient = MethodCall( From 8abbeff36edb53028e7da0fa37339bc3bf069ca3 Mon Sep 17 00:00:00 2001 From: JunbinDeng Date: Fri, 11 Mar 2022 01:08:51 +0800 Subject: [PATCH 2/7] [web] Using the skip parameter to skip the test on non-ios-safari browsers --- lib/web_ui/test/text_editing_test.dart | 93 +++++++++++++------------- 1 file changed, 46 insertions(+), 47 deletions(-) diff --git a/lib/web_ui/test/text_editing_test.dart b/lib/web_ui/test/text_editing_test.dart index a19d3c1f0dabe..6552d01dc1268 100644 --- a/lib/web_ui/test/text_editing_test.dart +++ b/lib/web_ui/test/text_editing_test.dart @@ -676,54 +676,53 @@ void testMain() { skip: browserEngine == BrowserEngine.edge); test('focus and disconnection with delaying blur in iOS', () async { - // Test on ios-safari only. - if (browserEngine == BrowserEngine.webkit && - operatingSystem == OperatingSystem.iOs) { - final MethodCall setClient = MethodCall( - 'TextInput.setClient', [123, flutterSinglelineConfig]); - sendFrameworkMessage(codec.encodeMethodCall(setClient)); + final MethodCall setClient = MethodCall( + 'TextInput.setClient', [123, flutterSinglelineConfig]); + sendFrameworkMessage(codec.encodeMethodCall(setClient)); - const MethodCall setEditingState = - MethodCall('TextInput.setEditingState', { - 'text': 'abcd', - 'selectionBase': 2, - 'selectionExtent': 3, - }); - sendFrameworkMessage(codec.encodeMethodCall(setEditingState)); - - // Editing shouldn't have started yet. - expect(defaultTextEditingRoot.activeElement, null); - - const MethodCall show = MethodCall('TextInput.show'); - sendFrameworkMessage(codec.encodeMethodCall(show)); - - // The "setSizeAndTransform" message has to be here before we call - // checkInputEditingState, since on some platforms (e.g. Desktop Safari) - // we don't put the input element into the DOM until we get its correct - // dimensions from the framework. - final MethodCall setSizeAndTransform = - configureSetSizeAndTransformMethodCall(150, 50, - Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); - sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); - - checkInputEditingState( - textEditing!.strategy.domElement, 'abcd', 2, 3); - expect(textEditing!.isEditing, isTrue); - - // Delay for not to be a fast callback with blur. - await Future.delayed(const Duration(milliseconds: 200)); - // DOM element is blurred. - textEditing!.strategy.domElement!.blur(); - - expect(spy.messages, hasLength(1)); - expect(spy.messages[0].channel, 'flutter/textinput'); - expect( - spy.messages[0].methodName, 'TextInputClient.onConnectionClosed'); - await Future.delayed(Duration.zero); - // DOM element loses the focus. - expect(defaultTextEditingRoot.activeElement, null); - } - }); + const MethodCall setEditingState = + MethodCall('TextInput.setEditingState', { + 'text': 'abcd', + 'selectionBase': 2, + 'selectionExtent': 3, + }); + sendFrameworkMessage(codec.encodeMethodCall(setEditingState)); + + // Editing shouldn't have started yet. + expect(defaultTextEditingRoot.activeElement, null); + + const MethodCall show = MethodCall('TextInput.show'); + sendFrameworkMessage(codec.encodeMethodCall(show)); + + // The "setSizeAndTransform" message has to be here before we call + // checkInputEditingState, since on some platforms (e.g. Desktop Safari) + // we don't put the input element into the DOM until we get its correct + // dimensions from the framework. + final MethodCall setSizeAndTransform = + configureSetSizeAndTransformMethodCall(150, 50, + Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); + sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + + checkInputEditingState( + textEditing!.strategy.domElement, 'abcd', 2, 3); + expect(textEditing!.isEditing, isTrue); + + // Delay for not to be a fast callback with blur. + await Future.delayed(const Duration(milliseconds: 200)); + // DOM element is blurred. + textEditing!.strategy.domElement!.blur(); + + expect(spy.messages, hasLength(1)); + expect(spy.messages[0].channel, 'flutter/textinput'); + expect( + spy.messages[0].methodName, 'TextInputClient.onConnectionClosed'); + await Future.delayed(Duration.zero); + // DOM element loses the focus. + expect(defaultTextEditingRoot.activeElement, null); + }, + // Test on ios-safari only. + skip: browserEngine != BrowserEngine.webkit || + operatingSystem != OperatingSystem.iOs); test('finishAutofillContext closes connection no autofill element', () async { From 03d1ac56395cb0bb6d25d13ae39b0e62083d796b Mon Sep 17 00:00:00 2001 From: JunbinDeng Date: Fri, 11 Mar 2022 19:06:33 +0800 Subject: [PATCH 3/7] [web] Fix the missing windowHasFocus in the blur callback --- lib/web_ui/lib/src/engine/text_editing/text_editing.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 5a78a960190e9..fc8fec2688614 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 @@ -1478,7 +1478,9 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { // okay because the virtual keyboard will hide, and as soon as the user // taps the text field again, the virtual keyboard will come up. subscriptions.add(activeDomElement.onBlur.listen((_) { - if (DateTime.now().difference(blurSubscriptionStart) < _blurFastCallbackInterval) { + if (windowHasFocus && + DateTime.now().difference(blurSubscriptionStart) < + _blurFastCallbackInterval) { activeDomElement.focus(); } else { owner.sendTextConnectionClosedToFrameworkIfAny(); From 82750f09c0e2ad46b6952f272fe0658c766a11e6 Mon Sep 17 00:00:00 2001 From: Junbin Deng Date: Tue, 15 Mar 2022 09:59:09 +0800 Subject: [PATCH 4/7] Update lib/web_ui/lib/src/engine/text_editing/text_editing.dart Co-authored-by: Mouad Debbar --- lib/web_ui/lib/src/engine/text_editing/text_editing.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 fc8fec2688614..0319df80b288f 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 @@ -1478,9 +1478,9 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { // okay because the virtual keyboard will hide, and as soon as the user // taps the text field again, the virtual keyboard will come up. subscriptions.add(activeDomElement.onBlur.listen((_) { - if (windowHasFocus && - DateTime.now().difference(blurSubscriptionStart) < - _blurFastCallbackInterval) { + final bool isFastCallback = + DateTime.now().difference(blurSubscriptionStart) < _blurFastCallbackInterval; + if (windowHasFocus && isFastCallback) { activeDomElement.focus(); } else { owner.sendTextConnectionClosedToFrameworkIfAny(); From 442a079ddedc88ff8fb90544f7261af979464e38 Mon Sep 17 00:00:00 2001 From: Junbin Deng Date: Tue, 15 Mar 2022 10:02:23 +0800 Subject: [PATCH 5/7] Update lib/web_ui/lib/src/engine/text_editing/text_editing.dart Co-authored-by: Mouad Debbar --- lib/web_ui/lib/src/engine/text_editing/text_editing.dart | 5 +++++ 1 file changed, 5 insertions(+) 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 0319df80b288f..8691c14e8ddf6 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 @@ -1477,6 +1477,11 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { // programmatically, so we end up refocusing the input field. This is // okay because the virtual keyboard will hide, and as soon as the user // taps the text field again, the virtual keyboard will come up. + // 4. Safari sometimes sends a blur event immediately after activating the + // input field. In this case, we want to keep the focus on the input field. + // In order to detect this, we measure how much time has passed since the + // input field was activated. If the time is too short, we re-focus the + // input element. subscriptions.add(activeDomElement.onBlur.listen((_) { final bool isFastCallback = DateTime.now().difference(blurSubscriptionStart) < _blurFastCallbackInterval; From 5ced13cf5764c53f94083235ac14c15d51bcad30 Mon Sep 17 00:00:00 2001 From: JunbinDeng Date: Wed, 16 Mar 2022 09:11:38 +0800 Subject: [PATCH 6/7] [web] Improve docs readability --- .../lib/src/engine/text_editing/text_editing.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) 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 fc8fec2688614..2806afdc266c0 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 @@ -1389,13 +1389,14 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { Timer? _positionInputElementTimer; static const Duration _delayBeforePlacement = Duration(milliseconds: 100); - /// The interval is considered as fast between blur subscription and callback. + /// This interval between the blur subscription and callback is considered to + /// be fast. /// - /// This is only used for iOS. The blur callback may trigger as soon as - /// the creation of the subscription. In that case, the virtual keyboard will - /// show and hide again occasionally. + /// This is only used for iOS. The blur callback may trigger as soon as the + /// creation of the subscription. Occasionally in this case, the virtual + /// keyboard will quickly show and hide again. /// - /// Lesser than the interval allows the virtual keyboard to keep showing up + /// Less than this interval allows the virtual keyboard to keep showing up /// instead of hiding rapidly. static const Duration _blurFastCallbackInterval = Duration(milliseconds: 200); From 8cf56555c0083dca52452608551f701a37fddfeb Mon Sep 17 00:00:00 2001 From: JunbinDeng Date: Wed, 16 Mar 2022 12:54:50 +0800 Subject: [PATCH 7/7] [web] Improve the performance of timing fast callback intervals by using Stopwatch --- lib/web_ui/lib/src/engine/text_editing/text_editing.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 07c939b92289d..0b69a49b0444a 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 @@ -1465,7 +1465,7 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { _addTapListener(); // Record start time of blur subscription. - final DateTime blurSubscriptionStart = DateTime.now(); + final Stopwatch blurWatch = Stopwatch()..start(); // On iOS, blur is trigerred in the following cases: // @@ -1484,8 +1484,7 @@ class IOSTextEditingStrategy extends GloballyPositionedTextEditingStrategy { // input field was activated. If the time is too short, we re-focus the // input element. subscriptions.add(activeDomElement.onBlur.listen((_) { - final bool isFastCallback = - DateTime.now().difference(blurSubscriptionStart) < _blurFastCallbackInterval; + final bool isFastCallback = blurWatch.elapsed < _blurFastCallbackInterval; if (windowHasFocus && isFastCallback) { activeDomElement.focus(); } else {