Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ FLUTTER_DARWIN_EXPORT
@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_
125 changes: 107 additions & 18 deletions shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -462,6 +467,7 @@ - (instancetype)init {
_keyboardType = UIKeyboardTypeDefault;
_returnKeyType = UIReturnKeyDone;
_secureTextEntry = NO;
_accessibilityEnabled = NO;
if (@available(iOS 11.0, *)) {
_smartQuotesType = UITextSmartQuotesTypeYes;
_smartDashesType = UITextSmartDashesTypeYes;
Expand Down Expand Up @@ -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`.
Comment on lines +1144 to +1150
Copy link
Member

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?

Copy link
Contributor Author

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.

*/
@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;
}

Expand All @@ -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;

Expand All @@ -1142,6 +1187,7 @@ - (instancetype)init {
_reusableInputView.secureTextEntry = NO;
_autofillContext = [[NSMutableDictionary alloc] init];
_activeView = _reusableInputView;
_inputHider = [[FlutterTextInputViewAccessibilityHider alloc] init];
}

return self;
Expand All @@ -1150,11 +1196,19 @@ - (instancetype)init {
- (void)dealloc {
[self hideTextInput];
[_reusableInputView release];
[_inputHider release];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there needs to be [_enableFlutterTextInputViewAccessibilityTimer release] as well =)

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Member

Choose a reason for hiding this comment

The 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;
Copy link
Member

Choose a reason for hiding this comment

The 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;
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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];
Expand All @@ -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];
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,7 +57,7 @@ @interface FlutterTextInputPlugin ()
NSMutableDictionary<NSString*, FlutterTextInputView*>* autofillContext;

- (void)collectGarbageInputViews;
- (UIView*)textInputParentView;
- (NSArray<UIView*>*)textInputViews;
@end

@interface FlutterTextInputPluginTest : XCTestCase
Expand Down Expand Up @@ -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 = @{
Expand All @@ -88,7 +128,7 @@ - (NSMutableDictionary*)mutableTemplateCopy {
}

- (NSArray<FlutterTextInputView*>*)installedInputViews {
return [textInputPlugin.textInputParentView.subviews
return (NSArray<FlutterTextInputView*>*)[textInputPlugin.textInputViews
filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"self isKindOfClass: %@",
[FlutterTextInputView class]]];
}
Expand Down Expand Up @@ -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<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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down