Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit b3a18f5

Browse files
authored
unhide uitextinput when focused (#23776)
1 parent b978515 commit b3a18f5

File tree

4 files changed

+190
-21
lines changed

4 files changed

+190
-21
lines changed

shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ FLUTTER_DARWIN_EXPORT
7171
@property(nonatomic, copy) UITextContentType textContentType API_AVAILABLE(ios(10.0));
7272

7373
@property(nonatomic, assign) id<FlutterTextInputDelegate> textInputDelegate;
74+
@property(nonatomic, assign) UIAccessibilityElement* backingTextInputAccessibilityObject;
7475

7576
@end
7677
#endif // SHELL_PLATFORM_IOS_FRAMEWORK_SOURCE_FLUTTERTEXTINPUTPLUGIN_H_

shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm

Lines changed: 107 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@
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

1213
static const char _kTextAffinityDownstream[] = "TextAffinity.downstream";
1314
static 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

shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.m

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,30 @@ - (void)setTextInputState:(NSDictionary*)state;
2020
- (void)setMarkedRect:(CGRect)markedRect;
2121
- (void)updateEditingState;
2222
- (BOOL)isVisibleToAutofill;
23+
24+
@end
25+
26+
@interface FlutterTextInputViewSpy : FlutterTextInputView
27+
@property(nonatomic, assign) UIAccessibilityNotifications receivedNotification;
28+
@property(nonatomic, assign) id receivedNotificationTarget;
29+
@property(nonatomic, assign) BOOL isAccessibilityFocused;
30+
31+
- (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target;
32+
33+
@end
34+
35+
@implementation FlutterTextInputViewSpy {
36+
}
37+
38+
- (void)postAccessibilityNotification:(UIAccessibilityNotifications)notification target:(id)target {
39+
self.receivedNotification = notification;
40+
self.receivedNotificationTarget = target;
41+
}
42+
43+
- (BOOL)accessibilityElementIsFocused {
44+
return _isAccessibilityFocused;
45+
}
46+
2347
@end
2448

2549
@interface FlutterSecureTextInputView : FlutterTextInputView
@@ -33,7 +57,7 @@ @interface FlutterTextInputPlugin ()
3357
NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;
3458

3559
- (void)collectGarbageInputViews;
36-
- (UIView*)textInputParentView;
60+
- (NSArray<UIView*>*)textInputViews;
3761
@end
3862

3963
@interface FlutterTextInputPluginTest : XCTestCase
@@ -71,6 +95,22 @@ - (void)setClientId:(int)clientId configuration:(NSDictionary*)config {
7195
}];
7296
}
7397

98+
- (void)setTextInputShow {
99+
FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.show"
100+
arguments:@[]];
101+
[textInputPlugin handleMethodCall:setClientCall
102+
result:^(id _Nullable result){
103+
}];
104+
}
105+
106+
- (void)setTextInputHide {
107+
FlutterMethodCall* setClientCall = [FlutterMethodCall methodCallWithMethodName:@"TextInput.hide"
108+
arguments:@[]];
109+
[textInputPlugin handleMethodCall:setClientCall
110+
result:^(id _Nullable result){
111+
}];
112+
}
113+
74114
- (NSMutableDictionary*)mutableTemplateCopy {
75115
if (!_template) {
76116
_template = @{
@@ -88,7 +128,7 @@ - (NSMutableDictionary*)mutableTemplateCopy {
88128
}
89129

90130
- (NSArray<FlutterTextInputView*>*)installedInputViews {
91-
return [textInputPlugin.textInputParentView.subviews
131+
return (NSArray<FlutterTextInputView*>*)[textInputPlugin.textInputViews
92132
filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self isKindOfClass: %@",
93133
[FlutterTextInputView class]]];
94134
}
@@ -743,4 +783,39 @@ - (void)testGarbageInputViewsAreNotRemovedImmediately {
743783
[self commitAutofillContextAndVerify];
744784
}
745785

786+
#pragma mark - Accessibility - Tests
787+
788+
- (void)testUITextInputAccessibilityNotHiddenWhenShowed {
789+
// Send show text input method call.
790+
[self setTextInputShow];
791+
// Find all the FlutterTextInputViews we created.
792+
NSArray<FlutterTextInputView*>* inputFields = self.installedInputViews;
793+
794+
// The input view should not be hidden.
795+
XCTAssertEqual([inputFields count], 1u);
796+
797+
// Send hide text input method call.
798+
[self setTextInputHide];
799+
800+
inputFields = self.installedInputViews;
801+
802+
// The input view should be hidden.
803+
XCTAssertEqual([inputFields count], 0u);
804+
}
805+
806+
- (void)testFlutterTextInputViewDirectFocusToBackingTextInput {
807+
FlutterTextInputViewSpy* inputView = [[FlutterTextInputViewSpy alloc] init];
808+
inputView.textInputDelegate = engine;
809+
UIView* container = [[UIView alloc] init];
810+
UIAccessibilityElement* backing =
811+
[[UIAccessibilityElement alloc] initWithAccessibilityContainer:container];
812+
inputView.backingTextInputAccessibilityObject = backing;
813+
// Simulate accessibility focus.
814+
inputView.isAccessibilityFocused = YES;
815+
[inputView accessibilityElementDidBecomeFocused];
816+
817+
XCTAssertEqual(inputView.receivedNotification, UIAccessibilityScreenChangedNotification);
818+
XCTAssertEqual(inputView.receivedNotificationTarget, backing);
819+
}
820+
746821
@end

shell/platform/darwin/ios/framework/Source/accessibility_text_entry.mm

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,14 @@ - (void)dealloc {
198198
- (void)setSemanticsNode:(const flutter::SemanticsNode*)node {
199199
[super setSemanticsNode:node];
200200
_inactive_text_input.text = @(node->value.data());
201+
FlutterTextInputView* textInput = (FlutterTextInputView*)[self bridge]->textInputView();
201202
if ([self node].HasFlag(flutter::SemanticsFlags::kIsFocused)) {
203+
textInput.backingTextInputAccessibilityObject = self;
202204
// The text input view must have a non-trivial size for the accessibility
203205
// system to send text editing events.
204-
[self bridge]->textInputView().frame = CGRectMake(0.0, 0.0, 1.0, 1.0);
206+
textInput.frame = CGRectMake(0.0, 0.0, 1.0, 1.0);
207+
} else if (textInput.backingTextInputAccessibilityObject == self) {
208+
textInput.backingTextInputAccessibilityObject = nil;
205209
}
206210
}
207211

0 commit comments

Comments
 (0)