-
Notifications
You must be signed in to change notification settings - Fork 6k
unhide uitextinput when focused #23776
Changes from all commits
6694c82
5239620
68d1d95
e7dbb0b
3b0ec2a
b8a5a0f
fb6b480
a708c93
41a1a4f
6bec04c
e1499f2
c012e5b
822c05c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 @@ @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<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 @@ - (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]; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. there needs to be
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. that will be trigger by hideTextInput, my thinking is that the timer is created in showtextinput, so it should be removed in hidetextinput
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yea, I think in practice you are right since the timer will retain the FlutterTextInputPlugin until the timer fires. |
||
| [_autofillContext release]; | ||
|
|
||
| [super dealloc]; | ||
| } | ||
|
|
||
| - (void)removeEnableFlutterTextInputViewAccessibilityTimer { | ||
| if (_enableFlutterTextInputViewAccessibilityTimer) { | ||
| [_enableFlutterTextInputViewAccessibilityTimer invalidate]; | ||
| [_enableFlutterTextInputViewAccessibilityTimer release]; | ||
| _enableFlutterTextInputViewAccessibilityTimer = nil; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a leak, sorry i didn't see it earlier. You need to retain this below, and release this here. |
||
| } | ||
| } | ||
|
|
||
| - (UIView<UITextInput>*)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,20 +1437,25 @@ - (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 " | ||
| @"must be part of the responder chain to function"); | ||
| 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 @@ - (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]; | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Couldn't this result in duplicate Voiceover announcements? One for the FlutterTextInputView and one SemanticsObject?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
that is true, that is why i put the first two fail safe and hope it can catch all of the corner cases.
If this text view does receive the focus, duplicate announcement is still much better than focus just stuck at this textview.
I have tried a number of different hack such as changing the trait and label of this text view to prevent voiceover from pronouncing any word when the text view is focused, but none of them work. If you have a textview without any trait or label, it will pronounce some random phrase such as "blue sky" when it is focused.