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

Commit 2589d07

Browse files
authored
Fix accessibility focus loss when first focusing on text field (#17803)
1 parent 3af2b1a commit 2589d07

File tree

19 files changed

+427
-102
lines changed

19 files changed

+427
-102
lines changed

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

Lines changed: 21 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -780,22 +780,16 @@ - (void)deleteBackward {
780780
[self replaceRange:_selectedTextRange withText:@""];
781781
}
782782

783-
@end
784-
785-
/**
786-
* Hides `FlutterTextInputView` from iOS accessibility system so it
787-
* does not show up twice, once where it is in the `UIView` hierarchy,
788-
* and a second time as part of the `SemanticsObject` hierarchy.
789-
*/
790-
@interface FlutterTextInputViewAccessibilityHider : UIView {
791-
}
792-
793-
@end
794-
795-
@implementation FlutterTextInputViewAccessibilityHider {
796-
}
797-
798783
- (BOOL)accessibilityElementsHidden {
784+
// We are hiding this accessibility element.
785+
// There are 2 accessible elements involved in text entry in 2 different parts of the view
786+
// hierarchy. This `FlutterTextInputView` is injected at the top of key window. We use this as a
787+
// `UITextInput` protocol to bridge text edit events between Flutter and iOS.
788+
//
789+
// We also create ur own custom `UIAccessibilityElements` tree with our `SemanticsObject` to
790+
// mimic the semantics tree from Flutter. We want the text field to be represented as a
791+
// `TextInputSemanticsObject` in that `SemanticsObject` tree rather than in this
792+
// `FlutterTextInputView` bridge which doesn't appear above a text field from the Flutter side.
799793
return YES;
800794
}
801795

@@ -806,7 +800,6 @@ @interface FlutterTextInputPlugin ()
806800
@property(nonatomic, retain) FlutterTextInputView* nonAutofillSecureInputView;
807801
@property(nonatomic, retain) NSMutableArray<FlutterTextInputView*>* inputViews;
808802
@property(nonatomic, assign) FlutterTextInputView* activeView;
809-
@property(nonatomic, retain) FlutterTextInputViewAccessibilityHider* inputHider;
810803
@end
811804

812805
@implementation FlutterTextInputPlugin
@@ -824,7 +817,6 @@ - (instancetype)init {
824817
_inputViews = [[NSMutableArray alloc] init];
825818

826819
_activeView = _nonAutofillInputView;
827-
_inputHider = [[FlutterTextInputViewAccessibilityHider alloc] init];
828820
}
829821

830822
return self;
@@ -834,7 +826,6 @@ - (void)dealloc {
834826
[self hideTextInput];
835827
[_nonAutofillInputView release];
836828
[_nonAutofillSecureInputView release];
837-
[_inputHider release];
838829
[_inputViews release];
839830

840831
[super dealloc];
@@ -873,19 +864,19 @@ - (void)showTextInput {
873864
@"The application must have a key window since the keyboard client "
874865
@"must be part of the responder chain to function");
875866
_activeView.textInputDelegate = _textInputDelegate;
876-
if (![_activeView isDescendantOfView:_inputHider]) {
877-
[_inputHider addSubview:_activeView];
867+
868+
if (_activeView.window != keyWindow) {
869+
[keyWindow addSubview:_activeView];
878870
}
879-
[keyWindow addSubview:_inputHider];
880871
[_activeView becomeFirstResponder];
881872
}
882873

883874
- (void)hideTextInput {
884875
[_activeView resignFirstResponder];
885-
[_inputHider removeFromSuperview];
886876
}
887877

888878
- (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configuration {
879+
UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow;
889880
NSArray* fields = configuration[@"fields"];
890881
NSString* clientUniqueId = uniqueIdFromDictionary(configuration);
891882
bool isSecureTextEntry = [configuration[@"obscureText"] boolValue];
@@ -894,16 +885,19 @@ - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configur
894885
_activeView = isSecureTextEntry ? _nonAutofillSecureInputView : _nonAutofillInputView;
895886
[FlutterTextInputPlugin setupInputView:_activeView withConfiguration:configuration];
896887

897-
if (![_activeView isDescendantOfView:_inputHider]) {
898-
[_inputHider addSubview:_activeView];
888+
if (_activeView.window != keyWindow) {
889+
[keyWindow addSubview:_activeView];
899890
}
900891
} else {
901892
NSAssert(clientUniqueId != nil, @"The client's unique id can't be null");
902893
for (FlutterTextInputView* view in _inputViews) {
903894
[view removeFromSuperview];
904895
}
905-
for (UIView* subview in _inputHider.subviews) {
906-
[subview removeFromSuperview];
896+
897+
for (UIView* view in keyWindow.subviews) {
898+
if ([view isKindOfClass:[FlutterTextInputView class]]) {
899+
[view removeFromSuperview];
900+
}
907901
}
908902

909903
[_inputViews removeAllObjects];
@@ -921,7 +915,7 @@ - (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configur
921915
}
922916

923917
[FlutterTextInputPlugin setupInputView:newInputView withConfiguration:field];
924-
[_inputHider addSubview:newInputView];
918+
[keyWindow addSubview:newInputView];
925919
}
926920
}
927921

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

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,30 @@ - (void)testSecureInput {
3838
result:^(id _Nullable result){
3939
}];
4040

41-
// Find all input views in the input hider view.
42-
NSArray<FlutterTextInputView*>* inputFields =
43-
[[[textInputPlugin textInputView] superview] subviews];
44-
45-
// Find the inactive autofillable input field.
41+
// Find all the FlutterTextInputViews we created.
42+
NSArray<FlutterTextInputView*>* inputFields = [[[[textInputPlugin textInputView] superview]
43+
subviews]
44+
filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"class == %@",
45+
[FlutterTextInputView class]]];
46+
47+
// There are no autofill and the mock framework requested a secure entry. The first and only
48+
// inserted FlutterTextInputView should be a secure text entry one.
4649
FlutterTextInputView* inputView = inputFields[0];
4750

4851
// Verify secureTextEntry is set to the correct value.
4952
XCTAssertTrue(inputView.secureTextEntry);
5053

51-
// Clean up mocks
54+
// We should have only ever created one FlutterTextInputView.
55+
XCTAssertEqual(inputFields.count, 1);
56+
57+
// The one FlutterTextInputView we inserted into the view hierarchy should be the text input
58+
// plugin's active text input view.
59+
XCTAssertEqual(inputView, textInputPlugin.textInputView);
60+
61+
// Clean up.
5262
[engine stopMocking];
63+
[[[[textInputPlugin textInputView] superview] subviews]
64+
makeObjectsPerformSelector:@selector(removeFromSuperview)];
5365
}
5466
- (void)testAutofillInputViews {
5567
// Setup test.
@@ -94,9 +106,11 @@ - (void)testAutofillInputViews {
94106
result:^(id _Nullable result){
95107
}];
96108

97-
// Find all input views in the input hider view.
98-
NSArray<FlutterTextInputView*>* inputFields =
99-
[[[textInputPlugin textInputView] superview] subviews];
109+
// Find all the FlutterTextInputViews we created.
110+
NSArray<FlutterTextInputView*>* inputFields = [[[[textInputPlugin textInputView] superview]
111+
subviews]
112+
filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"class == %@",
113+
[FlutterTextInputView class]]];
100114

101115
XCTAssertEqual(inputFields.count, 2);
102116

@@ -108,8 +122,10 @@ - (void)testAutofillInputViews {
108122
// Verify behavior.
109123
OCMVerify([engine updateEditingClient:0 withState:[OCMArg isNotNil] withTag:@"field2"]);
110124

111-
// Clean up mocks
125+
// Clean up.
112126
[engine stopMocking];
127+
[[[[textInputPlugin textInputView] superview] subviews]
128+
makeObjectsPerformSelector:@selector(removeFromSuperview)];
113129
}
114130

115131
- (void)testAutocorrectionPromptRectAppears {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
#import <OCMock/OCMock.h>
66
#import <XCTest/XCTest.h>
7-
#include "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h"
7+
#import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h"
88
#import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h"
99
#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h"
1010

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<Scheme
3+
LastUpgradeVersion = "1140"
4+
version = "1.3">
5+
<BuildAction
6+
parallelizeBuildables = "YES"
7+
buildImplicitDependencies = "YES">
8+
<BuildActionEntries>
9+
<BuildActionEntry
10+
buildForTesting = "YES"
11+
buildForRunning = "YES"
12+
buildForProfiling = "YES"
13+
buildForArchiving = "YES"
14+
buildForAnalyzing = "YES">
15+
<BuildableReference
16+
BuildableIdentifier = "primary"
17+
BlueprintIdentifier = "0D6AB6B022BB05E100EEE540"
18+
BuildableName = "IosUnitTests.app"
19+
BlueprintName = "IosUnitTests"
20+
ReferencedContainer = "container:IosUnitTests.xcodeproj">
21+
</BuildableReference>
22+
</BuildActionEntry>
23+
</BuildActionEntries>
24+
</BuildAction>
25+
<TestAction
26+
buildConfiguration = "Debug"
27+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
28+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
29+
shouldUseLaunchSchemeArgsEnv = "YES">
30+
<Testables>
31+
<TestableReference
32+
skipped = "NO">
33+
<BuildableReference
34+
BuildableIdentifier = "primary"
35+
BlueprintIdentifier = "0D6AB6C822BB05E200EEE540"
36+
BuildableName = "IosUnitTestsTests.xctest"
37+
BlueprintName = "IosUnitTestsTests"
38+
ReferencedContainer = "container:IosUnitTests.xcodeproj">
39+
</BuildableReference>
40+
</TestableReference>
41+
</Testables>
42+
</TestAction>
43+
<LaunchAction
44+
buildConfiguration = "Debug"
45+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
46+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
47+
launchStyle = "0"
48+
useCustomWorkingDirectory = "NO"
49+
ignoresPersistentStateOnLaunch = "NO"
50+
debugDocumentVersioning = "YES"
51+
debugServiceExtension = "internal"
52+
allowLocationSimulation = "YES">
53+
<BuildableProductRunnable
54+
runnableDebuggingMode = "0">
55+
<BuildableReference
56+
BuildableIdentifier = "primary"
57+
BlueprintIdentifier = "0D6AB6B022BB05E100EEE540"
58+
BuildableName = "IosUnitTests.app"
59+
BlueprintName = "IosUnitTests"
60+
ReferencedContainer = "container:IosUnitTests.xcodeproj">
61+
</BuildableReference>
62+
</BuildableProductRunnable>
63+
</LaunchAction>
64+
<ProfileAction
65+
buildConfiguration = "Release"
66+
shouldUseLaunchSchemeArgsEnv = "YES"
67+
savedToolIdentifier = ""
68+
useCustomWorkingDirectory = "NO"
69+
debugDocumentVersioning = "YES">
70+
<BuildableProductRunnable
71+
runnableDebuggingMode = "0">
72+
<BuildableReference
73+
BuildableIdentifier = "primary"
74+
BlueprintIdentifier = "0D6AB6B022BB05E100EEE540"
75+
BuildableName = "IosUnitTests.app"
76+
BlueprintName = "IosUnitTests"
77+
ReferencedContainer = "container:IosUnitTests.xcodeproj">
78+
</BuildableReference>
79+
</BuildableProductRunnable>
80+
</ProfileAction>
81+
<AnalyzeAction
82+
buildConfiguration = "Debug">
83+
</AnalyzeAction>
84+
<ArchiveAction
85+
buildConfiguration = "Release"
86+
revealArchiveInOrganizer = "YES">
87+
</ArchiveAction>
88+
</Scheme>

testing/scenario_app/android/app/src/androidTest/java/dev/flutter/scenarios/EngineLaunchE2ETest.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,16 @@ public void smokeTestEngineLaunch() throws Throwable {
3434
UiThreadStatement.runOnUiThread(() -> engine.set(new FlutterEngine(applicationContext)));
3535
CompletableFuture<Boolean> statusReceived = new CompletableFuture<>();
3636

37-
// The default Dart main entrypoint sends back a platform message on the "scenario_status"
37+
// The default Dart main entrypoint sends back a platform message on the "waiting_for_status"
3838
// channel. That will be our launch success assertion condition.
3939
engine
4040
.get()
4141
.getDartExecutor()
4242
.setMessageHandler(
43-
"scenario_status", (byteBuffer, binaryReply) -> statusReceived.complete(Boolean.TRUE));
43+
"waiting_for_status",
44+
(byteBuffer, binaryReply) -> statusReceived.complete(Boolean.TRUE));
4445

45-
// Launching the entrypoint will run the Dart code that sends the "scenario_status" platform
46+
// Launching the entrypoint will run the Dart code that sends the "waiting_for_status" platform
4647
// message.
4748
UiThreadStatement.runOnUiThread(
4849
() ->
@@ -54,7 +55,7 @@ public void smokeTestEngineLaunch() throws Throwable {
5455
try {
5556
Boolean result = statusReceived.get(10, TimeUnit.SECONDS);
5657
if (!result) {
57-
fail("expected message on scenario_status not received");
58+
fail("expected message on waiting_for_status not received");
5859
}
5960
} catch (ExecutionException e) {
6061
fail(e.getMessage());

testing/scenario_app/ios/Scenarios/Scenarios.xcodeproj/project.pbxproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
objects = {
88

99
/* Begin PBXBuildFile section */
10+
0A42BFB42447E179007E212E /* TextSemanticsFocusTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A42BFB32447E179007E212E /* TextSemanticsFocusTest.m */; };
1011
0A57B3BD2323C4BD00DD9521 /* ScreenBeforeFlutter.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A57B3BC2323C4BD00DD9521 /* ScreenBeforeFlutter.m */; };
1112
0A57B3BF2323C74200DD9521 /* FlutterEngine+ScenariosTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A57B3BE2323C74200DD9521 /* FlutterEngine+ScenariosTest.m */; };
1213
0A57B3C22323D2D700DD9521 /* AppLifecycleTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A57B3C12323D2D700DD9521 /* AppLifecycleTests.m */; };
@@ -110,6 +111,8 @@
110111
/* End PBXCopyFilesBuildPhase section */
111112

112113
/* Begin PBXFileReference section */
114+
0A42BFB32447E179007E212E /* TextSemanticsFocusTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TextSemanticsFocusTest.m; sourceTree = "<group>"; };
115+
0A42BFB52447E19F007E212E /* TextSemanticsFocusTest.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TextSemanticsFocusTest.h; sourceTree = "<group>"; };
113116
0A57B3BB2323C4BD00DD9521 /* ScreenBeforeFlutter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ScreenBeforeFlutter.h; sourceTree = "<group>"; };
114117
0A57B3BC2323C4BD00DD9521 /* ScreenBeforeFlutter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ScreenBeforeFlutter.m; sourceTree = "<group>"; };
115118
0A57B3BE2323C74200DD9521 /* FlutterEngine+ScenariosTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FlutterEngine+ScenariosTest.m"; sourceTree = "<group>"; };
@@ -280,6 +283,8 @@
280283
68A5B63323EB71D300BDBCDB /* PlatformViewGestureRecognizerTests.m */,
281284
0D8470A2240F0B1F0030B565 /* StatusBarTest.h */,
282285
0D8470A3240F0B1F0030B565 /* StatusBarTest.m */,
286+
0A42BFB32447E179007E212E /* TextSemanticsFocusTest.m */,
287+
0A42BFB52447E19F007E212E /* TextSemanticsFocusTest.h */,
283288
);
284289
path = ScenariosUITests;
285290
sourceTree = "<group>";
@@ -498,6 +503,7 @@
498503
6816DBA42318358200A51400 /* PlatformViewGoldenTestManager.m in Sources */,
499504
248D76EF22E388380012F0C1 /* PlatformViewUITests.m in Sources */,
500505
0D8470A4240F0B1F0030B565 /* StatusBarTest.m in Sources */,
506+
0A42BFB42447E179007E212E /* TextSemanticsFocusTest.m in Sources */,
501507
);
502508
runOnlyForDeploymentPostprocessing = 0;
503509
};

testing/scenario_app/ios/Scenarios/Scenarios.xcodeproj/xcshareddata/xcschemes/Scenarios.xcscheme

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@
2727
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
2828
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
2929
shouldUseLaunchSchemeArgsEnv = "YES">
30+
<MacroExpansion>
31+
<BuildableReference
32+
BuildableIdentifier = "primary"
33+
BlueprintIdentifier = "248D76C622E388370012F0C1"
34+
BuildableName = "Scenarios.app"
35+
BlueprintName = "Scenarios"
36+
ReferencedContainer = "container:Scenarios.xcodeproj">
37+
</BuildableReference>
38+
</MacroExpansion>
3039
<Testables>
3140
<TestableReference
3241
skipped = "NO"
@@ -51,17 +60,6 @@
5160
</BuildableReference>
5261
</TestableReference>
5362
</Testables>
54-
<MacroExpansion>
55-
<BuildableReference
56-
BuildableIdentifier = "primary"
57-
BlueprintIdentifier = "248D76C622E388370012F0C1"
58-
BuildableName = "Scenarios.app"
59-
BlueprintName = "Scenarios"
60-
ReferencedContainer = "container:Scenarios.xcodeproj">
61-
</BuildableReference>
62-
</MacroExpansion>
63-
<AdditionalOptions>
64-
</AdditionalOptions>
6563
</TestAction>
6664
<LaunchAction
6765
buildConfiguration = "Debug"
@@ -88,13 +86,15 @@
8886
argument = "--screen-before-flutter"
8987
isEnabled = "NO">
9088
</CommandLineArgument>
89+
<CommandLineArgument
90+
argument = "--text-semantics-focus"
91+
isEnabled = "NO">
92+
</CommandLineArgument>
9193
<CommandLineArgument
9294
argument = "--platform-view"
9395
isEnabled = "NO">
9496
</CommandLineArgument>
9597
</CommandLineArguments>
96-
<AdditionalOptions>
97-
</AdditionalOptions>
9898
</LaunchAction>
9999
<ProfileAction
100100
buildConfiguration = "Release"

0 commit comments

Comments
 (0)