diff --git a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h index bb915dd4065ef..ced4268bbaa2c 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h @@ -71,6 +71,7 @@ FLUTTER_DARWIN_EXPORT @property(nonatomic, copy) UITextContentType textContentType API_AVAILABLE(ios(10.0)); @property(nonatomic, assign) id 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 507b9cae8434a..52b31036f1bfc 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -7,10 +7,14 @@ #import #import +#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 @@ @interface FlutterTextInputView () @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 @@ - (instancetype)init { _keyboardType = UIKeyboardTypeDefault; _returnKeyType = UIReturnKeyDone; _secureTextEntry = NO; + _accessibilityEnabled = NO; if (@available(iOS 11.0, *)) { _smartQuotesType = UITextSmartQuotesTypeYes; _smartDashesType = UITextSmartDashesTypeYes; @@ -1106,16 +1112,52 @@ - (void)deleteBackward { [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 { + 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 { - // 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 YES; } @@ -1128,9 +1170,12 @@ @interface FlutterTextInputPlugin () @property(nonatomic, readonly) NSMutableDictionary* 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 @@ - (instancetype)init { _reusableInputView.secureTextEntry = NO; _autofillContext = [[NSMutableDictionary alloc] init]; _activeView = _reusableInputView; + _inputHider = [[FlutterTextInputViewAccessibilityHider alloc] init]; } return self; @@ -1150,11 +1196,19 @@ - (instancetype)init { - (void)dealloc { [self hideTextInput]; [_reusableInputView release]; + [_inputHider release]; [_autofillContext release]; - [super dealloc]; } +- (void)removeEnableFlutterTextInputViewAccessibilityTimer { + if (_enableFlutterTextInputViewAccessibilityTimer) { + [_enableFlutterTextInputViewAccessibilityTimer invalidate]; + [_enableFlutterTextInputViewAccessibilityTimer release]; + _enableFlutterTextInputViewAccessibilityTimer = nil; + } +} + - (UIView*)textInputView { return _activeView; } @@ -1207,11 +1261,38 @@ - (void)updateMarkedRect:(NSDictionary*)dictionary { - (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 @@ - (FlutterTextInputView*)getOrCreateAutofillableView:(NSDictionary*)field } // 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 @@ - (UIView*)textInputParentView { return keyWindow; } +// The UIView to add FlutterTextInputViews to. +- (NSArray*)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 @@ - (void)collectGarbageInputViews { // 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 @@ - (void)changeInputViewsAutofillVisibility:(BOOL)newVisibility { // 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)resetAllClientIds { } - (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 88683abb087f6..618e55a016d91 100644 --- a/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m +++ b/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m @@ -20,6 +20,30 @@ - (void)setTextInputState:(NSDictionary*)state; - (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 @@ @interface FlutterTextInputPlugin () NSMutableDictionary* autofillContext; - (void)collectGarbageInputViews; -- (UIView*)textInputParentView; +- (NSArray*)textInputViews; @end @interface FlutterTextInputPluginTest : XCTestCase @@ -71,6 +95,22 @@ - (void)setClientId:(int)clientId configuration:(NSDictionary*)config { }]; } +- (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 @@ - (NSMutableDictionary*)mutableTemplateCopy { } - (NSArray*)installedInputViews { - return [textInputPlugin.textInputParentView.subviews + return (NSArray*)[textInputPlugin.textInputViews filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self isKindOfClass: %@", [FlutterTextInputView class]]]; } @@ -743,4 +783,39 @@ - (void)testGarbageInputViewsAreNotRemovedImmediately { [self commitAutofillContextAndVerify]; } +#pragma mark - Accessibility - Tests + +- (void)testUITextInputAccessibilityNotHiddenWhenShowed { + // Send show text input method call. + [self setTextInputShow]; + // Find all the FlutterTextInputViews we created. + NSArray* 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 d9bf1cb86a717..2b6b52289b43d 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)dealloc { - (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; } }