77#import < Foundation/Foundation.h>
88#import < UIKit/UIKit.h>
99
10+ #include " flutter/fml/logging.h"
1011#include " flutter/fml/platform/darwin/string_range_sanitization.h"
1112
1213static const char _kTextAffinityDownstream[] = " TextAffinity.downstream" ;
1314static const char _kTextAffinityUpstream[] = " TextAffinity.upstream" ;
15+ // A delay before enabling the accessibility of FlutterTextInputView after
16+ // it is activated.
17+ static constexpr double kUITextInputAccessibilityEnablingDelaySeconds = 0.5 ;
1418
1519// The "canonical" invalid CGRect, similar to CGRectNull, used to
1620// indicate a CGRect involved in firstRectForRange calculation is
@@ -424,6 +428,7 @@ @interface FlutterTextInputView ()
424428@property (nonatomic , readonly ) CATransform3D editableTransform;
425429@property (nonatomic , assign ) CGRect markedRect;
426430@property (nonatomic ) BOOL isVisibleToAutofill;
431+ @property (nonatomic , assign ) BOOL accessibilityEnabled;
427432
428433- (void )setEditableTransform : (NSArray *)matrix ;
429434@end
@@ -462,6 +467,7 @@ - (instancetype)init {
462467 _keyboardType = UIKeyboardTypeDefault;
463468 _returnKeyType = UIReturnKeyDone;
464469 _secureTextEntry = NO ;
470+ _accessibilityEnabled = NO ;
465471 if (@available (iOS 11.0 , *)) {
466472 _smartQuotesType = UITextSmartQuotesTypeYes;
467473 _smartDashesType = UITextSmartDashesTypeYes;
@@ -1106,16 +1112,52 @@ - (void)deleteBackward {
11061112 [self replaceRange: _selectedTextRange withText: @" " ];
11071113}
11081114
1115+ - (void )postAccessibilityNotification : (UIAccessibilityNotifications)notification target : (id )target {
1116+ UIAccessibilityPostNotification (notification, target);
1117+ }
1118+
1119+ - (void )accessibilityElementDidBecomeFocused {
1120+ if ([self accessibilityElementIsFocused ]) {
1121+ // For most of the cases, this flutter text input view should never
1122+ // receive the focus. If we do receive the focus, we make the best effort
1123+ // to send the focus back to the real text field.
1124+ FML_DCHECK (_backingTextInputAccessibilityObject);
1125+ [self postAccessibilityNotification: UIAccessibilityScreenChangedNotification
1126+ target: _backingTextInputAccessibilityObject];
1127+ }
1128+ }
1129+
1130+ - (BOOL )accessibilityElementsHidden {
1131+ return !_accessibilityEnabled;
1132+ }
1133+
1134+ @end
1135+
1136+ /* *
1137+ * Hides `FlutterTextInputView` from iOS accessibility system so it
1138+ * does not show up twice, once where it is in the `UIView` hierarchy,
1139+ * and a second time as part of the `SemanticsObject` hierarchy.
1140+ *
1141+ * This prevents the `FlutterTextInputView` from receiving the focus
1142+ * due to swipping gesture.
1143+ *
1144+ * There are other cases the `FlutterTextInputView` may receive
1145+ * focus. One example is during screen changes, the accessibility
1146+ * tree will undergo a dramatic structural update. The Voiceover may
1147+ * decide to focus the `FlutterTextInputView` that is not involved
1148+ * in the structural update instead. If that happens, the
1149+ * `FlutterTextInputView` will make a best effort to direct the
1150+ * focus back to the `SemanticsObject`.
1151+ */
1152+ @interface FlutterTextInputViewAccessibilityHider : UIView {
1153+ }
1154+
1155+ @end
1156+
1157+ @implementation FlutterTextInputViewAccessibilityHider {
1158+ }
1159+
11091160- (BOOL )accessibilityElementsHidden {
1110- // We are hiding this accessibility element.
1111- // There are 2 accessible elements involved in text entry in 2 different parts of the view
1112- // hierarchy. This `FlutterTextInputView` is injected at the top of key window. We use this as a
1113- // `UITextInput` protocol to bridge text edit events between Flutter and iOS.
1114- //
1115- // We also create ur own custom `UIAccessibilityElements` tree with our `SemanticsObject` to
1116- // mimic the semantics tree from Flutter. We want the text field to be represented as a
1117- // `TextInputSemanticsObject` in that `SemanticsObject` tree rather than in this
1118- // `FlutterTextInputView` bridge which doesn't appear above a text field from the Flutter side.
11191161 return YES ;
11201162}
11211163
@@ -1128,9 +1170,12 @@ @interface FlutterTextInputPlugin ()
11281170@property (nonatomic , readonly )
11291171 NSMutableDictionary <NSString*, FlutterTextInputView*>* autofillContext;
11301172@property (nonatomic , assign ) FlutterTextInputView* activeView;
1173+ @property (nonatomic , strong ) FlutterTextInputViewAccessibilityHider* inputHider;
11311174@end
11321175
1133- @implementation FlutterTextInputPlugin
1176+ @implementation FlutterTextInputPlugin {
1177+ NSTimer * _enableFlutterTextInputViewAccessibilityTimer;
1178+ }
11341179
11351180@synthesize textInputDelegate = _textInputDelegate;
11361181
@@ -1142,6 +1187,7 @@ - (instancetype)init {
11421187 _reusableInputView.secureTextEntry = NO ;
11431188 _autofillContext = [[NSMutableDictionary alloc ] init ];
11441189 _activeView = _reusableInputView;
1190+ _inputHider = [[FlutterTextInputViewAccessibilityHider alloc ] init ];
11451191 }
11461192
11471193 return self;
@@ -1150,11 +1196,19 @@ - (instancetype)init {
11501196- (void )dealloc {
11511197 [self hideTextInput ];
11521198 [_reusableInputView release ];
1199+ [_inputHider release ];
11531200 [_autofillContext release ];
1154-
11551201 [super dealloc ];
11561202}
11571203
1204+ - (void )removeEnableFlutterTextInputViewAccessibilityTimer {
1205+ if (_enableFlutterTextInputViewAccessibilityTimer) {
1206+ [_enableFlutterTextInputViewAccessibilityTimer invalidate ];
1207+ [_enableFlutterTextInputViewAccessibilityTimer release ];
1208+ _enableFlutterTextInputViewAccessibilityTimer = nil ;
1209+ }
1210+ }
1211+
11581212- (UIView<UITextInput>*)textInputView {
11591213 return _activeView;
11601214}
@@ -1207,11 +1261,38 @@ - (void)updateMarkedRect:(NSDictionary*)dictionary {
12071261- (void )showTextInput {
12081262 _activeView.textInputDelegate = _textInputDelegate;
12091263 [self addToInputParentViewIfNeeded: _activeView];
1264+ // Adds a delay to prevent the text view from receiving accessibility
1265+ // focus in case it is activated during semantics updates.
1266+ //
1267+ // One common case is when the app navigates to a page with an auto
1268+ // focused text field. The text field will activate the FlutterTextInputView
1269+ // with a semantics update sent to the engine. The voiceover will focus
1270+ // the newly attached active view while performing accessibility update.
1271+ // This results in accessibility focus stuck at the FlutterTextInputView.
1272+ if (!_enableFlutterTextInputViewAccessibilityTimer) {
1273+ _enableFlutterTextInputViewAccessibilityTimer =
1274+ [[NSTimer scheduledTimerWithTimeInterval: kUITextInputAccessibilityEnablingDelaySeconds
1275+ target: self
1276+ selector: @selector (enableActiveViewAccessibility: )
1277+ userInfo: nil
1278+ repeats: NO ] retain ];
1279+ }
12101280 [_activeView becomeFirstResponder ];
12111281}
12121282
1283+ - (void )enableActiveViewAccessibility : (NSTimer *)time {
1284+ if (_activeView.isFirstResponder ) {
1285+ _activeView.accessibilityEnabled = YES ;
1286+ }
1287+ [self removeEnableFlutterTextInputViewAccessibilityTimer ];
1288+ }
1289+
12131290- (void )hideTextInput {
1291+ [self removeEnableFlutterTextInputViewAccessibilityTimer ];
1292+ _activeView.accessibilityEnabled = NO ;
12141293 [_activeView resignFirstResponder ];
1294+ [_activeView removeFromSuperview ];
1295+ [_inputHider removeFromSuperview ];
12151296}
12161297
12171298- (void )triggerAutofillSave : (BOOL )saveEntries {
@@ -1356,20 +1437,25 @@ - (FlutterTextInputView*)getOrCreateAutofillableView:(NSDictionary*)field
13561437}
13571438
13581439// The UIView to add FlutterTextInputViews to.
1359- - (UIView*)textInputParentView {
1440+ - (UIView*)keyWindow {
13601441 UIWindow* keyWindow = [UIApplication sharedApplication ].keyWindow ;
13611442 NSAssert (keyWindow != nullptr ,
13621443 @" The application must have a key window since the keyboard client "
13631444 @" must be part of the responder chain to function" );
13641445 return keyWindow;
13651446}
13661447
1448+ // The UIView to add FlutterTextInputViews to.
1449+ - (NSArray <UIView*>*)textInputViews {
1450+ return _inputHider.subviews ;
1451+ }
1452+
13671453// Removes every installed input field, unless it's in the current autofill
13681454// context. May remove the active view too if includeActiveView is YES.
13691455// When clearText is YES, the text on the input fields will be set to empty before
13701456// they are removed from the view hierarchy, to avoid triggering autofill save.
13711457- (void )cleanUpViewHierarchy : (BOOL )includeActiveView clearText : (BOOL )clearText {
1372- for (UIView* view in self.textInputParentView . subviews ) {
1458+ for (UIView* view in self.textInputViews ) {
13731459 if ([view isKindOfClass: [FlutterTextInputView class ]] &&
13741460 (includeActiveView || view != _activeView)) {
13751461 FlutterTextInputView* inputView = (FlutterTextInputView*)view;
@@ -1390,7 +1476,7 @@ - (void)collectGarbageInputViews {
13901476// Changes the visibility of every FlutterTextInputView currently in the
13911477// view hierarchy.
13921478- (void )changeInputViewsAutofillVisibility : (BOOL )newVisibility {
1393- for (UIView* view in self.textInputParentView . subviews ) {
1479+ for (UIView* view in self.textInputViews ) {
13941480 if ([view isKindOfClass: [FlutterTextInputView class ]]) {
13951481 FlutterTextInputView* inputView = (FlutterTextInputView*)view;
13961482 inputView.isVisibleToAutofill = newVisibility;
@@ -1401,7 +1487,7 @@ - (void)changeInputViewsAutofillVisibility:(BOOL)newVisibility {
14011487// Resets the client id of every FlutterTextInputView in the view hierarchy
14021488// to 0. Called when a new text input connection will be established.
14031489- (void )resetAllClientIds {
1404- for (UIView* view in self.textInputParentView . subviews ) {
1490+ for (UIView* view in self.textInputViews ) {
14051491 if ([view isKindOfClass: [FlutterTextInputView class ]]) {
14061492 FlutterTextInputView* inputView = (FlutterTextInputView*)view;
14071493 [inputView setTextInputClient: 0 ];
@@ -1410,9 +1496,12 @@ - (void)resetAllClientIds {
14101496}
14111497
14121498- (void )addToInputParentViewIfNeeded : (FlutterTextInputView*)inputView {
1413- UIView* parentView = self.textInputParentView ;
1414- if (inputView.superview != parentView) {
1415- [parentView addSubview: inputView];
1499+ if (![inputView isDescendantOfView: _inputHider]) {
1500+ [_inputHider addSubview: inputView];
1501+ }
1502+ UIView* parentView = self.keyWindow ;
1503+ if (_inputHider.superview != parentView) {
1504+ [parentView addSubview: _inputHider];
14161505 }
14171506}
14181507
0 commit comments