unhide uitextinput when focused (#23776)
diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h
index bb915dd..ced4268 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h
+++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h
@@ -71,6 +71,7 @@
@property(nonatomic, copy) UITextContentType textContentType API_AVAILABLE(ios(10.0));
@property(nonatomic, assign) id<FlutterTextInputDelegate> textInputDelegate;
+@property(nonatomic, assign) UIAccessibilityElement* backingTextInputAccessibilityObject;
@end
#endif // SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERTEXTINPUTPLUGIN_H_
diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
index 507b9ca..52b3103 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
+++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
@@ -7,10 +7,14 @@
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
+#include "flutter/fml/logging.h"
#include "flutter/fml/platform/darwin/string_range_sanitization.h"
static const char _kTextAffinityDownstream[] = "TextAffinity.downstream";
static const char _kTextAffinityUpstream[] = "TextAffinity.upstream";
+// A delay before enabling the accessibility of FlutterTextInputView after
+// it is activated.
+static constexpr double kUITextInputAccessibilityEnablingDelaySeconds = 0.5;
// The "canonical" invalid CGRect, similar to CGRectNull, used to
// indicate a CGRect involved in firstRectForRange calculation is
@@ -424,6 +428,7 @@
@property(nonatomic, readonly) CATransform3D editableTransform;
@property(nonatomic, assign) CGRect markedRect;
@property(nonatomic) BOOL isVisibleToAutofill;
+@property(nonatomic, assign) BOOL accessibilityEnabled;
- (void)setEditableTransform:(NSArray*)matrix;
@end
@@ -462,6 +467,7 @@
_keyboardType = UIKeyboardTypeDefault;
_returnKeyType = UIReturnKeyDone;
_secureTextEntry = NO;
+ _accessibilityEnabled = NO;
if (@available(iOS 11.0, *)) {
_smartQuotesType = UITextSmartQuotesTypeYes;
_smartDashesType = UITextSmartDashesTypeYes;
@@ -1106,16 +1112,52 @@
[self replaceRange:_selectedTextRange withText:@""];
}
+- (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target {
+ UIAccessibilityPostNotification(notification, target);
+}
+
+- (void)accessibilityElementDidBecomeFocused {
+ if ([self accessibilityElementIsFocused]) {
+ // For most of the cases, this flutter text input view should never
+ // receive the focus. If we do receive the focus, we make the best effort
+ // to send the focus back to the real text field.
+ FML_DCHECK(_backingTextInputAccessibilityObject);
+ [self postAccessibilityNotification:UIAccessibilityScreenChangedNotification
+ target:_backingTextInputAccessibilityObject];
+ }
+}
+
- (BOOL)accessibilityElementsHidden {
- // We are hiding this accessibility element.
- // There are 2 accessible elements involved in text entry in 2 different parts of the view
- // hierarchy. This `FlutterTextInputView` is injected at the top of key window. We use this as a
- // `UITextInput` protocol to bridge text edit events between Flutter and iOS.
- //
- // We also create ur own custom `UIAccessibilityElements` tree with our `SemanticsObject` to
- // mimic the semantics tree from Flutter. We want the text field to be represented as a
- // `TextInputSemanticsObject` in that `SemanticsObject` tree rather than in this
- // `FlutterTextInputView` bridge which doesn't appear above a text field from the Flutter side.
+ return !_accessibilityEnabled;
+}
+
+@end
+
+/**
+ * Hides `FlutterTextInputView` from iOS accessibility system so it
+ * does not show up twice, once where it is in the `UIView` hierarchy,
+ * and a second time as part of the `SemanticsObject` hierarchy.
+ *
+ * This prevents the `FlutterTextInputView` from receiving the focus
+ * due to swipping gesture.
+ *
+ * There are other cases the `FlutterTextInputView` may receive
+ * focus. One example is during screen changes, the accessibility
+ * tree will undergo a dramatic structural update. The Voiceover may
+ * decide to focus the `FlutterTextInputView` that is not involved
+ * in the structural update instead. If that happens, the
+ * `FlutterTextInputView` will make a best effort to direct the
+ * focus back to the `SemanticsObject`.
+ */
+@interface FlutterTextInputViewAccessibilityHider : UIView {
+}
+
+@end
+
+@implementation FlutterTextInputViewAccessibilityHider {
+}
+
+- (BOOL)accessibilityElementsHidden {
return YES;
}
@@ -1128,9 +1170,12 @@
@property(nonatomic, readonly)
NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
@property(nonatomic, assign) FlutterTextInputView* activeView;
+@property(nonatomic, strong) FlutterTextInputViewAccessibilityHider* inputHider;
@end
-@implementation FlutterTextInputPlugin
+@implementation FlutterTextInputPlugin {
+ NSTimer* _enableFlutterTextInputViewAccessibilityTimer;
+}
@synthesize textInputDelegate = _textInputDelegate;
@@ -1142,6 +1187,7 @@
_reusableInputView.secureTextEntry = NO;
_autofillContext = [[NSMutableDictionary alloc] init];
_activeView = _reusableInputView;
+ _inputHider = [[FlutterTextInputViewAccessibilityHider alloc] init];
}
return self;
@@ -1150,11 +1196,19 @@
- (void)dealloc {
[self hideTextInput];
[_reusableInputView release];
+ [_inputHider release];
[_autofillContext release];
-
[super dealloc];
}
+- (void)removeEnableFlutterTextInputViewAccessibilityTimer {
+ if (_enableFlutterTextInputViewAccessibilityTimer) {
+ [_enableFlutterTextInputViewAccessibilityTimer invalidate];
+ [_enableFlutterTextInputViewAccessibilityTimer release];
+ _enableFlutterTextInputViewAccessibilityTimer = nil;
+ }
+}
+
- (UIView<UITextInput>*)textInputView {
return _activeView;
}
@@ -1207,11 +1261,38 @@
- (void)showTextInput {
_activeView.textInputDelegate = _textInputDelegate;
[self addToInputParentViewIfNeeded:_activeView];
+ // Adds a delay to prevent the text view from receiving accessibility
+ // focus in case it is activated during semantics updates.
+ //
+ // One common case is when the app navigates to a page with an auto
+ // focused text field. The text field will activate the FlutterTextInputView
+ // with a semantics update sent to the engine. The voiceover will focus
+ // the newly attached active view while performing accessibility update.
+ // This results in accessibility focus stuck at the FlutterTextInputView.
+ if (!_enableFlutterTextInputViewAccessibilityTimer) {
+ _enableFlutterTextInputViewAccessibilityTimer =
+ [[NSTimer scheduledTimerWithTimeInterval:kUITextInputAccessibilityEnablingDelaySeconds
+ target:self
+ selector:@selector(enableActiveViewAccessibility:)
+ userInfo:nil
+ repeats:NO] retain];
+ }
[_activeView becomeFirstResponder];
}
+- (void)enableActiveViewAccessibility:(NSTimer*)time {
+ if (_activeView.isFirstResponder) {
+ _activeView.accessibilityEnabled = YES;
+ }
+ [self removeEnableFlutterTextInputViewAccessibilityTimer];
+}
+
- (void)hideTextInput {
+ [self removeEnableFlutterTextInputViewAccessibilityTimer];
+ _activeView.accessibilityEnabled = NO;
[_activeView resignFirstResponder];
+ [_activeView removeFromSuperview];
+ [_inputHider removeFromSuperview];
}
- (void)triggerAutofillSave:(BOOL)saveEntries {
@@ -1356,7 +1437,7 @@
}
// The UIView to add FlutterTextInputViews to.
-- (UIView*)textInputParentView {
+- (UIView*)keyWindow {
UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow;
NSAssert(keyWindow != nullptr,
@"The application must have a key window since the keyboard client "
@@ -1364,12 +1445,17 @@
return keyWindow;
}
+// The UIView to add FlutterTextInputViews to.
+- (NSArray<UIView*>*)textInputViews {
+ return _inputHider.subviews;
+}
+
// Removes every installed input field, unless it's in the current autofill
// context. May remove the active view too if includeActiveView is YES.
// When clearText is YES, the text on the input fields will be set to empty before
// they are removed from the view hierarchy, to avoid triggering autofill save.
- (void)cleanUpViewHierarchy:(BOOL)includeActiveView clearText:(BOOL)clearText {
- for (UIView* view in self.textInputParentView.subviews) {
+ for (UIView* view in self.textInputViews) {
if ([view isKindOfClass:[FlutterTextInputView class]] &&
(includeActiveView || view != _activeView)) {
FlutterTextInputView* inputView = (FlutterTextInputView*)view;
@@ -1390,7 +1476,7 @@
// Changes the visibility of every FlutterTextInputView currently in the
// view hierarchy.
- (void)changeInputViewsAutofillVisibility:(BOOL)newVisibility {
- for (UIView* view in self.textInputParentView.subviews) {
+ for (UIView* view in self.textInputViews) {
if ([view isKindOfClass:[FlutterTextInputView class]]) {
FlutterTextInputView* inputView = (FlutterTextInputView*)view;
inputView.isVisibleToAutofill = newVisibility;
@@ -1401,7 +1487,7 @@
// Resets the client id of every FlutterTextInputView in the view hierarchy
// to 0. Called when a new text input connection will be established.
- (void)resetAllClientIds {
- for (UIView* view in self.textInputParentView.subviews) {
+ for (UIView* view in self.textInputViews) {
if ([view isKindOfClass:[FlutterTextInputView class]]) {
FlutterTextInputView* inputView = (FlutterTextInputView*)view;
[inputView setTextInputClient:0];
@@ -1410,9 +1496,12 @@
}
- (void)addToInputParentViewIfNeeded:(FlutterTextInputView*)inputView {
- UIView* parentView = self.textInputParentView;
- if (inputView.superview != parentView) {
- [parentView addSubview:inputView];
+ if (![inputView isDescendantOfView:_inputHider]) {
+ [_inputHider addSubview:inputView];
+ }
+ UIView* parentView = self.keyWindow;
+ if (_inputHider.superview != parentView) {
+ [parentView addSubview:_inputHider];
}
}
diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m
index 88683ab..618e55a 100644
--- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m
+++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m
@@ -20,6 +20,30 @@
- (void)setMarkedRect:(CGRect)markedRect;
- (void)updateEditingState;
- (BOOL)isVisibleToAutofill;
+
+@end
+
+@interface FlutterTextInputViewSpy : FlutterTextInputView
+@property(nonatomic, assign) UIAccessibilityNotifications receivedNotification;
+@property(nonatomic, assign) id receivedNotificationTarget;
+@property(nonatomic, assign) BOOL isAccessibilityFocused;
+
+- (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target;
+
+@end
+
+@implementation FlutterTextInputViewSpy {
+}
+
+- (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target {
+ self.receivedNotification = notification;
+ self.receivedNotificationTarget = target;
+}
+
+- (BOOL)accessibilityElementIsFocused {
+ return _isAccessibilityFocused;
+}
+
@end
@interface FlutterSecureTextInputView : FlutterTextInputView
@@ -33,7 +57,7 @@
NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
- (void)collectGarbageInputViews;
-- (UIView*)textInputParentView;
+- (NSArray<UIView*>*)textInputViews;
@end
@interface FlutterTextInputPluginTest : XCTestCase
@@ -71,6 +95,22 @@
}];
}
+- (void)setTextInputShow {
+ FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.show"
+ arguments:@[]];
+ [textInputPlugin handleMethodCall:setClientCall
+ result:^(id _Nullable result){
+ }];
+}
+
+- (void)setTextInputHide {
+ FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.hide"
+ arguments:@[]];
+ [textInputPlugin handleMethodCall:setClientCall
+ result:^(id _Nullable result){
+ }];
+}
+
- (NSMutableDictionary*)mutableTemplateCopy {
if (!_template) {
_template = @{
@@ -88,7 +128,7 @@
}
- (NSArray<FlutterTextInputView*>*)installedInputViews {
- return [textInputPlugin.textInputParentView.subviews
+ return (NSArray<FlutterTextInputView*>*)[textInputPlugin.textInputViews
filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self isKindOfClass: %@",
[FlutterTextInputView class]]];
}
@@ -743,4 +783,39 @@
[self commitAutofillContextAndVerify];
}
+#pragma mark - Accessibility - Tests
+
+- (void)testUITextInputAccessibilityNotHiddenWhenShowed {
+ // Send show text input method call.
+ [self setTextInputShow];
+ // Find all the FlutterTextInputViews we created.
+ NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
+
+ // The input view should not be hidden.
+ XCTAssertEqual([inputFields count], 1u);
+
+ // Send hide text input method call.
+ [self setTextInputHide];
+
+ inputFields = self.installedInputViews;
+
+ // The input view should be hidden.
+ XCTAssertEqual([inputFields count], 0u);
+}
+
+- (void)testFlutterTextInputViewDirectFocusToBackingTextInput {
+ FlutterTextInputViewSpy* inputView = [[FlutterTextInputViewSpy alloc] init];
+ inputView.textInputDelegate = engine;
+ UIView* container = [[UIView alloc] init];
+ UIAccessibilityElement* backing =
+ [[UIAccessibilityElement alloc] initWithAccessibilityContainer:container];
+ inputView.backingTextInputAccessibilityObject = backing;
+ // Simulate accessibility focus.
+ inputView.isAccessibilityFocused = YES;
+ [inputView accessibilityElementDidBecomeFocused];
+
+ XCTAssertEqual(inputView.receivedNotification, UIAccessibilityScreenChangedNotification);
+ XCTAssertEqual(inputView.receivedNotificationTarget, backing);
+}
+
@end
diff --git a/shell/platform/darwin/ios/framework/Source/accessibility_text_entry.mm b/shell/platform/darwin/ios/framework/Source/accessibility_text_entry.mm
index d9bf1cb..2b6b522 100644
--- a/shell/platform/darwin/ios/framework/Source/accessibility_text_entry.mm
+++ b/shell/platform/darwin/ios/framework/Source/accessibility_text_entry.mm
@@ -198,10 +198,14 @@
- (void)setSemanticsNode:(const flutter::SemanticsNode*)node {
[super setSemanticsNode:node];
_inactive_text_input.text = @(node->value.data());
+ FlutterTextInputView* textInput = (FlutterTextInputView*)[self bridge]->textInputView();
if ([self node].HasFlag(flutter::SemanticsFlags::kIsFocused)) {
+ textInput.backingTextInputAccessibilityObject = self;
// The text input view must have a non-trivial size for the accessibility
// system to send text editing events.
- [self bridge]->textInputView().frame = CGRectMake(0.0, 0.0, 1.0, 1.0);
+ textInput.frame = CGRectMake(0.0, 0.0, 1.0, 1.0);
+ } else if (textInput.backingTextInputAccessibilityObject == self) {
+ textInput.backingTextInputAccessibilityObject = nil;
}
}