mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Fix accessibility focus loss when first focusing on text field (flutter/engine#17803)
This commit is contained in:
parent
c4f46db590
commit
664700007c
@ -780,22 +780,16 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
|
||||
[self replaceRange:_selectedTextRange withText:@""];
|
||||
}
|
||||
|
||||
@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.
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
|
||||
@ -806,7 +800,6 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
|
||||
@property(nonatomic, retain) FlutterTextInputView* nonAutofillSecureInputView;
|
||||
@property(nonatomic, retain) NSMutableArray<FlutterTextInputView*>* inputViews;
|
||||
@property(nonatomic, assign) FlutterTextInputView* activeView;
|
||||
@property(nonatomic, retain) FlutterTextInputViewAccessibilityHider* inputHider;
|
||||
@end
|
||||
|
||||
@implementation FlutterTextInputPlugin
|
||||
@ -824,7 +817,6 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
|
||||
_inputViews = [[NSMutableArray alloc] init];
|
||||
|
||||
_activeView = _nonAutofillInputView;
|
||||
_inputHider = [[FlutterTextInputViewAccessibilityHider alloc] init];
|
||||
}
|
||||
|
||||
return self;
|
||||
@ -834,7 +826,6 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
|
||||
[self hideTextInput];
|
||||
[_nonAutofillInputView release];
|
||||
[_nonAutofillSecureInputView release];
|
||||
[_inputHider release];
|
||||
[_inputViews release];
|
||||
|
||||
[super dealloc];
|
||||
@ -873,19 +864,19 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
|
||||
@"The application must have a key window since the keyboard client "
|
||||
@"must be part of the responder chain to function");
|
||||
_activeView.textInputDelegate = _textInputDelegate;
|
||||
if (![_activeView isDescendantOfView:_inputHider]) {
|
||||
[_inputHider addSubview:_activeView];
|
||||
|
||||
if (_activeView.window != keyWindow) {
|
||||
[keyWindow addSubview:_activeView];
|
||||
}
|
||||
[keyWindow addSubview:_inputHider];
|
||||
[_activeView becomeFirstResponder];
|
||||
}
|
||||
|
||||
- (void)hideTextInput {
|
||||
[_activeView resignFirstResponder];
|
||||
[_inputHider removeFromSuperview];
|
||||
}
|
||||
|
||||
- (void)setTextInputClient:(int)client withConfiguration:(NSDictionary*)configuration {
|
||||
UIWindow* keyWindow = [UIApplication sharedApplication].keyWindow;
|
||||
NSArray* fields = configuration[@"fields"];
|
||||
NSString* clientUniqueId = uniqueIdFromDictionary(configuration);
|
||||
bool isSecureTextEntry = [configuration[@"obscureText"] boolValue];
|
||||
@ -894,16 +885,19 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
|
||||
_activeView = isSecureTextEntry ? _nonAutofillSecureInputView : _nonAutofillInputView;
|
||||
[FlutterTextInputPlugin setupInputView:_activeView withConfiguration:configuration];
|
||||
|
||||
if (![_activeView isDescendantOfView:_inputHider]) {
|
||||
[_inputHider addSubview:_activeView];
|
||||
if (_activeView.window != keyWindow) {
|
||||
[keyWindow addSubview:_activeView];
|
||||
}
|
||||
} else {
|
||||
NSAssert(clientUniqueId != nil, @"The client's unique id can't be null");
|
||||
for (FlutterTextInputView* view in _inputViews) {
|
||||
[view removeFromSuperview];
|
||||
}
|
||||
for (UIView* subview in _inputHider.subviews) {
|
||||
[subview removeFromSuperview];
|
||||
|
||||
for (UIView* view in keyWindow.subviews) {
|
||||
if ([view isKindOfClass:[FlutterTextInputView class]]) {
|
||||
[view removeFromSuperview];
|
||||
}
|
||||
}
|
||||
|
||||
[_inputViews removeAllObjects];
|
||||
@ -921,7 +915,7 @@ static NSString* uniqueIdFromDictionary(NSDictionary* dictionary) {
|
||||
}
|
||||
|
||||
[FlutterTextInputPlugin setupInputView:newInputView withConfiguration:field];
|
||||
[_inputHider addSubview:newInputView];
|
||||
[keyWindow addSubview:newInputView];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -38,18 +38,30 @@ FLUTTER_ASSERT_ARC
|
||||
result:^(id _Nullable result){
|
||||
}];
|
||||
|
||||
// Find all input views in the input hider view.
|
||||
NSArray<FlutterTextInputView*>* inputFields =
|
||||
[[[textInputPlugin textInputView] superview] subviews];
|
||||
// Find all the FlutterTextInputViews we created.
|
||||
NSArray<FlutterTextInputView*>* inputFields = [[[[textInputPlugin textInputView] superview]
|
||||
subviews]
|
||||
filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"class == %@",
|
||||
[FlutterTextInputView class]]];
|
||||
|
||||
// Find the inactive autofillable input field.
|
||||
// There are no autofill and the mock framework requested a secure entry. The first and only
|
||||
// inserted FlutterTextInputView should be a secure text entry one.
|
||||
FlutterTextInputView* inputView = inputFields[0];
|
||||
|
||||
// Verify secureTextEntry is set to the correct value.
|
||||
XCTAssertTrue(inputView.secureTextEntry);
|
||||
|
||||
// Clean up mocks
|
||||
// We should have only ever created one FlutterTextInputView.
|
||||
XCTAssertEqual(inputFields.count, 1);
|
||||
|
||||
// The one FlutterTextInputView we inserted into the view hierarchy should be the text input
|
||||
// plugin's active text input view.
|
||||
XCTAssertEqual(inputView, textInputPlugin.textInputView);
|
||||
|
||||
// Clean up.
|
||||
[engine stopMocking];
|
||||
[[[[textInputPlugin textInputView] superview] subviews]
|
||||
makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
||||
}
|
||||
- (void)testAutofillInputViews {
|
||||
// Setup test.
|
||||
@ -94,9 +106,11 @@ FLUTTER_ASSERT_ARC
|
||||
result:^(id _Nullable result){
|
||||
}];
|
||||
|
||||
// Find all input views in the input hider view.
|
||||
NSArray<FlutterTextInputView*>* inputFields =
|
||||
[[[textInputPlugin textInputView] superview] subviews];
|
||||
// Find all the FlutterTextInputViews we created.
|
||||
NSArray<FlutterTextInputView*>* inputFields = [[[[textInputPlugin textInputView] superview]
|
||||
subviews]
|
||||
filteredArrayUsingPredicate:[NSPredicate predicateWithFormat:@"class == %@",
|
||||
[FlutterTextInputView class]]];
|
||||
|
||||
XCTAssertEqual(inputFields.count, 2);
|
||||
|
||||
@ -108,8 +122,10 @@ FLUTTER_ASSERT_ARC
|
||||
// Verify behavior.
|
||||
OCMVerify([engine updateEditingClient:0 withState:[OCMArg isNotNil] withTag:@"field2"]);
|
||||
|
||||
// Clean up mocks
|
||||
// Clean up.
|
||||
[engine stopMocking];
|
||||
[[[[textInputPlugin textInputView] superview] subviews]
|
||||
makeObjectsPerformSelector:@selector(removeFromSuperview)];
|
||||
}
|
||||
|
||||
- (void)testAutocorrectionPromptRectAppears {
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
#import <OCMock/OCMock.h>
|
||||
#import <XCTest/XCTest.h>
|
||||
#include "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h"
|
||||
#import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h"
|
||||
#import "flutter/shell/platform/darwin/ios/framework/Headers/FlutterViewController.h"
|
||||
#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterViewController_Internal.h"
|
||||
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1140"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0D6AB6B022BB05E100EEE540"
|
||||
BuildableName = "IosUnitTests.app"
|
||||
BlueprintName = "IosUnitTests"
|
||||
ReferencedContainer = "container:IosUnitTests.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0D6AB6C822BB05E200EEE540"
|
||||
BuildableName = "IosUnitTestsTests.xctest"
|
||||
BlueprintName = "IosUnitTestsTests"
|
||||
ReferencedContainer = "container:IosUnitTests.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0D6AB6B022BB05E100EEE540"
|
||||
BuildableName = "IosUnitTests.app"
|
||||
BlueprintName = "IosUnitTests"
|
||||
ReferencedContainer = "container:IosUnitTests.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "0D6AB6B022BB05E100EEE540"
|
||||
BuildableName = "IosUnitTests.app"
|
||||
BlueprintName = "IosUnitTests"
|
||||
ReferencedContainer = "container:IosUnitTests.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@ -34,15 +34,16 @@ public class EngineLaunchE2ETest {
|
||||
UiThreadStatement.runOnUiThread(() -> engine.set(new FlutterEngine(applicationContext)));
|
||||
CompletableFuture<Boolean> statusReceived = new CompletableFuture<>();
|
||||
|
||||
// The default Dart main entrypoint sends back a platform message on the "scenario_status"
|
||||
// The default Dart main entrypoint sends back a platform message on the "waiting_for_status"
|
||||
// channel. That will be our launch success assertion condition.
|
||||
engine
|
||||
.get()
|
||||
.getDartExecutor()
|
||||
.setMessageHandler(
|
||||
"scenario_status", (byteBuffer, binaryReply) -> statusReceived.complete(Boolean.TRUE));
|
||||
"waiting_for_status",
|
||||
(byteBuffer, binaryReply) -> statusReceived.complete(Boolean.TRUE));
|
||||
|
||||
// Launching the entrypoint will run the Dart code that sends the "scenario_status" platform
|
||||
// Launching the entrypoint will run the Dart code that sends the "waiting_for_status" platform
|
||||
// message.
|
||||
UiThreadStatement.runOnUiThread(
|
||||
() ->
|
||||
@ -54,7 +55,7 @@ public class EngineLaunchE2ETest {
|
||||
try {
|
||||
Boolean result = statusReceived.get(10, TimeUnit.SECONDS);
|
||||
if (!result) {
|
||||
fail("expected message on scenario_status not received");
|
||||
fail("expected message on waiting_for_status not received");
|
||||
}
|
||||
} catch (ExecutionException e) {
|
||||
fail(e.getMessage());
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
0A42BFB42447E179007E212E /* TextSemanticsFocusTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A42BFB32447E179007E212E /* TextSemanticsFocusTest.m */; };
|
||||
0A57B3BD2323C4BD00DD9521 /* ScreenBeforeFlutter.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A57B3BC2323C4BD00DD9521 /* ScreenBeforeFlutter.m */; };
|
||||
0A57B3BF2323C74200DD9521 /* FlutterEngine+ScenariosTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A57B3BE2323C74200DD9521 /* FlutterEngine+ScenariosTest.m */; };
|
||||
0A57B3C22323D2D700DD9521 /* AppLifecycleTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A57B3C12323D2D700DD9521 /* AppLifecycleTests.m */; };
|
||||
@ -110,6 +111,8 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0A42BFB32447E179007E212E /* TextSemanticsFocusTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TextSemanticsFocusTest.m; sourceTree = "<group>"; };
|
||||
0A42BFB52447E19F007E212E /* TextSemanticsFocusTest.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = TextSemanticsFocusTest.h; sourceTree = "<group>"; };
|
||||
0A57B3BB2323C4BD00DD9521 /* ScreenBeforeFlutter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ScreenBeforeFlutter.h; sourceTree = "<group>"; };
|
||||
0A57B3BC2323C4BD00DD9521 /* ScreenBeforeFlutter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ScreenBeforeFlutter.m; sourceTree = "<group>"; };
|
||||
0A57B3BE2323C74200DD9521 /* FlutterEngine+ScenariosTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FlutterEngine+ScenariosTest.m"; sourceTree = "<group>"; };
|
||||
@ -280,6 +283,8 @@
|
||||
68A5B63323EB71D300BDBCDB /* PlatformViewGestureRecognizerTests.m */,
|
||||
0D8470A2240F0B1F0030B565 /* StatusBarTest.h */,
|
||||
0D8470A3240F0B1F0030B565 /* StatusBarTest.m */,
|
||||
0A42BFB32447E179007E212E /* TextSemanticsFocusTest.m */,
|
||||
0A42BFB52447E19F007E212E /* TextSemanticsFocusTest.h */,
|
||||
);
|
||||
path = ScenariosUITests;
|
||||
sourceTree = "<group>";
|
||||
@ -498,6 +503,7 @@
|
||||
6816DBA42318358200A51400 /* PlatformViewGoldenTestManager.m in Sources */,
|
||||
248D76EF22E388380012F0C1 /* PlatformViewUITests.m in Sources */,
|
||||
0D8470A4240F0B1F0030B565 /* StatusBarTest.m in Sources */,
|
||||
0A42BFB42447E179007E212E /* TextSemanticsFocusTest.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
||||
@ -27,6 +27,15 @@
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "248D76C622E388370012F0C1"
|
||||
BuildableName = "Scenarios.app"
|
||||
BlueprintName = "Scenarios"
|
||||
ReferencedContainer = "container:Scenarios.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
@ -51,17 +60,6 @@
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "248D76C622E388370012F0C1"
|
||||
BuildableName = "Scenarios.app"
|
||||
BlueprintName = "Scenarios"
|
||||
ReferencedContainer = "container:Scenarios.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<AdditionalOptions>
|
||||
</AdditionalOptions>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
@ -88,13 +86,15 @@
|
||||
argument = "--screen-before-flutter"
|
||||
isEnabled = "NO">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "--text-semantics-focus"
|
||||
isEnabled = "NO">
|
||||
</CommandLineArgument>
|
||||
<CommandLineArgument
|
||||
argument = "--platform-view"
|
||||
isEnabled = "NO">
|
||||
</CommandLineArgument>
|
||||
</CommandLineArguments>
|
||||
<AdditionalOptions>
|
||||
</AdditionalOptions>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#include "AppDelegate.h"
|
||||
#import "AppDelegate.h"
|
||||
|
||||
#import "FlutterEngine+ScenariosTest.h"
|
||||
#import "ScreenBeforeFlutter.h"
|
||||
#import "TextPlatformView.h"
|
||||
@ -23,11 +24,8 @@
|
||||
didFinishLaunchingWithOptions:(NSDictionary*)launchOptions {
|
||||
self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];
|
||||
|
||||
// This argument is used by the XCUITest for Platform Views so that the app
|
||||
// under test will create platform views.
|
||||
// If the test is one of the platform view golden tests,
|
||||
// the launchArgsMap should match the one in the `PlatformVieGoldenTestManager`.
|
||||
NSDictionary<NSString*, NSString*>* launchArgsMap = @{
|
||||
// The Platform view golden test args should match `PlatformViewGoldenTestManager`.
|
||||
@"--platform-view" : @"platform_view",
|
||||
@"--platform-view-no-overlay-intersection" : @"platform_view_no_overlay_intersection",
|
||||
@"--platform-view-two-intersecting-overlays" : @"platform_view_two_intersecting_overlays",
|
||||
@ -49,18 +47,19 @@
|
||||
@"--gesture-reject-eager" : @"platform_view_gesture_reject_eager",
|
||||
@"--gesture-accept" : @"platform_view_gesture_accept",
|
||||
@"--tap-status-bar" : @"tap_status_bar",
|
||||
@"--text-semantics-focus" : @"text_semantics_focus"
|
||||
};
|
||||
__block NSString* platformViewTestName = nil;
|
||||
__block NSString* flutterViewControllerTestName = nil;
|
||||
[launchArgsMap
|
||||
enumerateKeysAndObjectsUsingBlock:^(NSString* argument, NSString* testName, BOOL* stop) {
|
||||
if ([[[NSProcessInfo processInfo] arguments] containsObject:argument]) {
|
||||
platformViewTestName = testName;
|
||||
flutterViewControllerTestName = testName;
|
||||
*stop = YES;
|
||||
}
|
||||
}];
|
||||
|
||||
if (platformViewTestName) {
|
||||
[self readyContextForPlatformViewTests:platformViewTestName];
|
||||
if (flutterViewControllerTestName) {
|
||||
[self setupFlutterViewControllerTest:flutterViewControllerTestName];
|
||||
} else if ([[[NSProcessInfo processInfo] arguments] containsObject:@"--screen-before-flutter"]) {
|
||||
self.window.rootViewController = [[ScreenBeforeFlutter alloc] initWithEngineRunCompletion:nil];
|
||||
} else {
|
||||
@ -71,22 +70,24 @@
|
||||
return [super application:application didFinishLaunchingWithOptions:launchOptions];
|
||||
}
|
||||
|
||||
- (void)readyContextForPlatformViewTests:(NSString*)scenarioIdentifier {
|
||||
FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"PlatformViewTest" project:nil];
|
||||
[engine runWithEntrypoint:nil];
|
||||
|
||||
FlutterViewController* flutterViewController;
|
||||
- (FlutterViewController*)flutterViewControllerForTest:(NSString*)scenarioIdentifier
|
||||
withEngine:(FlutterEngine*)engine {
|
||||
if ([scenarioIdentifier isEqualToString:@"tap_status_bar"]) {
|
||||
flutterViewController = [[FlutterViewController alloc] initWithEngine:engine
|
||||
nibName:nil
|
||||
bundle:nil];
|
||||
return [[FlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil];
|
||||
} else {
|
||||
flutterViewController = [[NoStatusBarFlutterViewController alloc] initWithEngine:engine
|
||||
nibName:nil
|
||||
bundle:nil];
|
||||
return [[NoStatusBarFlutterViewController alloc] initWithEngine:engine nibName:nil bundle:nil];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)setupFlutterViewControllerTest:(NSString*)scenarioIdentifier {
|
||||
FlutterEngine* engine = [[FlutterEngine alloc] initWithName:@"FlutterControllerTest" project:nil];
|
||||
[engine run];
|
||||
|
||||
FlutterViewController* flutterViewController =
|
||||
[self flutterViewControllerForTest:scenarioIdentifier withEngine:engine];
|
||||
|
||||
[engine.binaryMessenger
|
||||
setMessageHandlerOnChannel:@"scenario_status"
|
||||
setMessageHandlerOnChannel:@"waiting_for_status"
|
||||
binaryMessageHandler:^(NSData* _Nullable message, FlutterBinaryReply _Nonnull reply) {
|
||||
[engine.binaryMessenger
|
||||
sendOnChannel:@"set_scenario"
|
||||
@ -103,9 +104,9 @@
|
||||
[flutterViewController.view addSubview:text];
|
||||
}];
|
||||
TextPlatformViewFactory* textPlatformViewFactory =
|
||||
[[TextPlatformViewFactory alloc] initWithMessenger:flutterViewController.binaryMessenger];
|
||||
[[TextPlatformViewFactory alloc] initWithMessenger:engine.binaryMessenger];
|
||||
NSObject<FlutterPluginRegistrar>* registrar =
|
||||
[flutterViewController.engine registrarForPlugin:@"scenarios/TextPlatformViewPlugin"];
|
||||
[engine registrarForPlugin:@"scenarios/TextPlatformViewPlugin"];
|
||||
[registrar registerViewFactory:textPlatformViewFactory
|
||||
withId:@"scenarios/textPlatformView"
|
||||
gestureRecognizersBlockingPolicy:FlutterPlatformViewGestureRecognizersBlockingPolicyEager];
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
project:nil];
|
||||
[self runWithEntrypoint:nil];
|
||||
[self.binaryMessenger
|
||||
setMessageHandlerOnChannel:@"scenario_status"
|
||||
setMessageHandlerOnChannel:@"waiting_for_status"
|
||||
binaryMessageHandler:^(NSData* message, FlutterBinaryReply reply) {
|
||||
[self.binaryMessenger
|
||||
sendOnChannel:@"set_scenario"
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
#import <XCTest/XCTest.h>
|
||||
#import "ScreenBeforeFlutter.h"
|
||||
|
||||
FLUTTER_ASSERT_ARC
|
||||
|
||||
@interface XCAppLifecycleTestExpectation : XCTestExpectation
|
||||
|
||||
- (instancetype)initForLifecycle:(NSString*)expectedLifecycle forStep:(NSString*)step;
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
// Copyright 2020 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#import <Flutter/Flutter.h>
|
||||
#import <XCTest/XCTest.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface TextSemanticsFocusTest : XCTestCase
|
||||
@property(nonatomic, strong) XCUIApplication* application;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@ -0,0 +1,58 @@
|
||||
// Copyright 2020 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
#import "TextSemanticsFocusTest.h"
|
||||
|
||||
FLUTTER_ASSERT_ARC
|
||||
|
||||
@implementation TextSemanticsFocusTest
|
||||
|
||||
- (void)setUp {
|
||||
[super setUp];
|
||||
self.continueAfterFailure = NO;
|
||||
|
||||
self.application = [[XCUIApplication alloc] init];
|
||||
self.application.launchArguments = @[ @"--text-semantics-focus" ];
|
||||
[self.application launch];
|
||||
}
|
||||
|
||||
- (void)testAccessibilityFocusOnTextSemanticsProducesCorrectIosViews {
|
||||
// Find the initial TextInputSemanticsObject which was sent from the mock framework on first
|
||||
// frame.
|
||||
XCUIElement* textInputSemanticsObject =
|
||||
[[[self.application textFields] matchingIdentifier:@"flutter textfield"] element];
|
||||
XCTAssertTrue([textInputSemanticsObject waitForExistenceWithTimeout:3]);
|
||||
XCTAssertEqualObjects([textInputSemanticsObject valueForKey:@"hasKeyboardFocus"], @(NO));
|
||||
|
||||
// Since the first mock framework text field isn't focused on, it shouldn't produce a UITextInput
|
||||
// in the view hierarchy.
|
||||
XCUIElement* delegateTextInput = [[self.application textViews] element];
|
||||
XCTAssertFalse([delegateTextInput waitForExistenceWithTimeout:3]);
|
||||
|
||||
// Nor should there be a keyboard for text entry.
|
||||
XCUIElement* keyboard = [[self.application keyboards] element];
|
||||
XCTAssertFalse([keyboard waitForExistenceWithTimeout:3]);
|
||||
|
||||
// The tap location doesn't matter. The mock framework just sends a focused text field on tap.
|
||||
[textInputSemanticsObject tap];
|
||||
|
||||
// The new TextInputSemanticsObject now has keyboard focus (the only trait accessible through
|
||||
// UI tests on a XCUIElement).
|
||||
textInputSemanticsObject =
|
||||
[[[self.application textFields] matchingIdentifier:@"focused flutter textfield"] element];
|
||||
XCTAssertTrue([textInputSemanticsObject waitForExistenceWithTimeout:3]);
|
||||
XCTAssertEqualObjects([textInputSemanticsObject valueForKey:@"hasKeyboardFocus"], @(YES));
|
||||
|
||||
// The delegate UITextInput is also inserted on the window but we make only the
|
||||
// TextInputSemanticsObject visible and not the FlutterTextInputView to avoid confusing
|
||||
// accessibility, it shouldn't be visible to the UI test either.
|
||||
delegateTextInput = [[self.application textViews] element];
|
||||
XCTAssertFalse([delegateTextInput waitForExistenceWithTimeout:3]);
|
||||
|
||||
// But since there is focus, the soft keyboard is visible on the simulator.
|
||||
keyboard = [[self.application keyboards] element];
|
||||
XCTAssertTrue([keyboard waitForExistenceWithTimeout:3]);
|
||||
}
|
||||
|
||||
@end
|
||||
@ -14,6 +14,7 @@ import 'src/animated_color_square.dart';
|
||||
import 'src/platform_view.dart';
|
||||
import 'src/poppable_screen.dart';
|
||||
import 'src/scenario.dart';
|
||||
import 'src/send_text_focus_semantics.dart';
|
||||
import 'src/touches_scenario.dart';
|
||||
|
||||
Map<String, Scenario> _scenarios = <String, Scenario>{
|
||||
@ -37,7 +38,8 @@ Map<String, Scenario> _scenarios = <String, Scenario>{
|
||||
'platform_view_gesture_reject_eager': PlatformViewForTouchIOSScenario(window, 'platform view touch', id: 11, accept: false),
|
||||
'platform_view_gesture_accept': PlatformViewForTouchIOSScenario(window, 'platform view touch', id: 11, accept: true),
|
||||
'platform_view_gesture_reject_after_touches_ended': PlatformViewForTouchIOSScenario(window, 'platform view touch', id: 11, accept: false, rejectUntilTouchesEnded: true),
|
||||
'tap_status_bar' : TouchesScenario(window),
|
||||
'tap_status_bar': TouchesScenario(window),
|
||||
'text_semantics_focus': SendTextFocusScemantics(window),
|
||||
};
|
||||
|
||||
Scenario _currentScenario = _scenarios['animated_color_square'];
|
||||
@ -53,7 +55,7 @@ void main() {
|
||||
..scheduleFrame();
|
||||
final ByteData data = ByteData(1);
|
||||
data.setUint8(0, 1);
|
||||
window.sendPlatformMessage('scenario_status', data, null);
|
||||
window.sendPlatformMessage('waiting_for_status', data, null);
|
||||
}
|
||||
|
||||
Future<void> _handlePlatformMessage(
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
// Copyright 2019 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
/// Util method to replicate the behavior of a `MethodChannel` in the Flutter
|
||||
/// framework.
|
||||
void sendJsonMethodCall({
|
||||
@required Window window,
|
||||
@required String channel,
|
||||
@required String method,
|
||||
dynamic arguments,
|
||||
PlatformMessageResponseCallback callback,
|
||||
}) {
|
||||
window.sendPlatformMessage(
|
||||
channel,
|
||||
// This recreates a combination of OptionalMethodChannel, JSONMethodCodec,
|
||||
// and _DefaultBinaryMessenger in the framework.
|
||||
utf8.encoder.convert(
|
||||
const JsonCodec().encode(<String, dynamic>{
|
||||
'method': method,
|
||||
'args': arguments,
|
||||
})
|
||||
).buffer.asByteData(),
|
||||
callback,
|
||||
);
|
||||
}
|
||||
@ -3,9 +3,10 @@
|
||||
// found in the LICENSE file.
|
||||
|
||||
// @dart = 2.6
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:scenario_app/src/channel_util.dart';
|
||||
|
||||
import 'platform_echo_mixin.dart';
|
||||
import 'scenario.dart';
|
||||
|
||||
@ -72,22 +73,15 @@ class PoppableScreenScenario extends Scenario with PlatformEchoMixin {
|
||||
}
|
||||
|
||||
void _pop() {
|
||||
window.sendPlatformMessage(
|
||||
sendJsonMethodCall(
|
||||
window: window,
|
||||
// 'flutter/platform' is the hardcoded name of the 'platform'
|
||||
// `SystemChannel` from the `SystemNavigator` API.
|
||||
// https://github.com/flutter/flutter/blob/master/packages/flutter/lib/src/services/system_navigator.dart.
|
||||
'flutter/platform',
|
||||
// This recreates a combination of OptionalMethodChannel, JSONMethodCodec,
|
||||
// and _DefaultBinaryMessenger in the framework.
|
||||
utf8.encoder.convert(
|
||||
const JsonCodec().encode(<String, dynamic>{
|
||||
'method': 'SystemNavigator.pop',
|
||||
'args': null,
|
||||
})
|
||||
).buffer.asByteData(),
|
||||
channel: 'flutter/platform',
|
||||
method: 'SystemNavigator.pop',
|
||||
// Don't care about the response. If it doesn't go through, the test
|
||||
// will fail.
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ abstract class Scenario {
|
||||
/// Called by the program when a frame is ready to be drawn.
|
||||
///
|
||||
/// See [Window.onBeginFrame] for more details.
|
||||
void onBeginFrame(Duration duration);
|
||||
void onBeginFrame(Duration duration) {}
|
||||
|
||||
/// Called by the program when the microtasks from [onBeginFrame] have been
|
||||
/// flushed.
|
||||
|
||||
@ -0,0 +1,117 @@
|
||||
// Copyright 2020 The Flutter Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:vector_math/vector_math_64.dart';
|
||||
|
||||
import 'channel_util.dart';
|
||||
import 'scenario.dart';
|
||||
|
||||
/// A scenario that sends back messages when touches are received.
|
||||
class SendTextFocusScemantics extends Scenario {
|
||||
/// Constructor for `SendTextFocusScemantics`.
|
||||
SendTextFocusScemantics(Window window) : super(window);
|
||||
|
||||
@override
|
||||
void onBeginFrame(Duration duration) {
|
||||
// Doesn't matter what we draw. Just paint white.
|
||||
final SceneBuilder builder = SceneBuilder();
|
||||
final PictureRecorder recorder = PictureRecorder();
|
||||
final Canvas canvas = Canvas(recorder);
|
||||
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(0, 0, window.physicalSize.width, window.physicalSize.height),
|
||||
Paint()..color = const Color.fromARGB(255, 255, 255, 255),
|
||||
);
|
||||
final Picture picture = recorder.endRecording();
|
||||
|
||||
builder.addPicture(
|
||||
Offset.zero,
|
||||
picture,
|
||||
);
|
||||
final Scene scene = builder.build();
|
||||
window.render(scene);
|
||||
scene.dispose();
|
||||
|
||||
// On the first frame, also pretend that it drew a text field. Send the
|
||||
// corresponding semantics tree comprised of 1 node for the text field.
|
||||
window.updateSemantics((SemanticsUpdateBuilder()
|
||||
..updateNode(
|
||||
id: 0,
|
||||
// SemanticsFlag.isTextField.
|
||||
flags: 16,
|
||||
// SemanticsAction.tap.
|
||||
actions: 1,
|
||||
rect: const Rect.fromLTRB(0.0, 0.0, 414.0, 48.0),
|
||||
label: 'flutter textfield',
|
||||
textDirection: TextDirection.ltr,
|
||||
textSelectionBase: -1,
|
||||
textSelectionExtent: -1,
|
||||
platformViewId: -1,
|
||||
maxValueLength: -1,
|
||||
currentValueLength: 0,
|
||||
scrollChildren: 0,
|
||||
scrollIndex: 0,
|
||||
transform: Matrix4.identity().storage,
|
||||
elevation: 0.0,
|
||||
thickness: 0.0,
|
||||
childrenInTraversalOrder: Int32List(0),
|
||||
childrenInHitTestOrder: Int32List(0),
|
||||
additionalActions: Int32List(0),
|
||||
)).build()
|
||||
);
|
||||
}
|
||||
|
||||
// We don't really care about the touch itself. It's just a way for the
|
||||
// XCUITest to communicate timing to the mock framework.
|
||||
@override
|
||||
void onPointerDataPacket(PointerDataPacket packet) {
|
||||
// This mimics the framework which shows the FlutterTextInputView before
|
||||
// updating the TextInputSemanticsObject.
|
||||
sendJsonMethodCall(
|
||||
window: window,
|
||||
channel: 'flutter/textinput',
|
||||
method: 'TextInput.setClient',
|
||||
arguments: <dynamic>[
|
||||
1,
|
||||
// The arguments are text field configurations. It doesn't really matter
|
||||
// since we're just testing text field accessibility here.
|
||||
<String, dynamic>{ 'obscureText': false },
|
||||
]
|
||||
);
|
||||
|
||||
sendJsonMethodCall(
|
||||
window: window,
|
||||
channel: 'flutter/textinput',
|
||||
method: 'TextInput.show',
|
||||
);
|
||||
|
||||
window.updateSemantics((SemanticsUpdateBuilder()
|
||||
..updateNode(
|
||||
id: 0,
|
||||
// SemanticsFlag.isTextField and SemanticsFlag.isFocused.
|
||||
flags: 48,
|
||||
actions: 18433,
|
||||
rect: const Rect.fromLTRB(0.0, 0.0, 414.0, 48.0),
|
||||
label: 'focused flutter textfield',
|
||||
textDirection: TextDirection.ltr,
|
||||
textSelectionBase: 0,
|
||||
textSelectionExtent: 0,
|
||||
platformViewId: -1,
|
||||
maxValueLength: -1,
|
||||
currentValueLength: 0,
|
||||
scrollChildren: 0,
|
||||
scrollIndex: 0,
|
||||
transform: Matrix4.identity().storage,
|
||||
elevation: 0.0,
|
||||
thickness: 0.0,
|
||||
childrenInTraversalOrder: Int32List(0),
|
||||
childrenInHitTestOrder: Int32List(0),
|
||||
additionalActions: Int32List(0),
|
||||
)).build()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -12,9 +12,6 @@ class TouchesScenario extends Scenario {
|
||||
/// Constructor for `TouchesScenario`.
|
||||
TouchesScenario(Window window) : super(window);
|
||||
|
||||
@override
|
||||
void onBeginFrame(Duration duration) {}
|
||||
|
||||
@override
|
||||
void onPointerDataPacket(PointerDataPacket packet) {
|
||||
window.sendPlatformMessage(
|
||||
|
||||
@ -4,8 +4,12 @@ publish_to: none
|
||||
# These are for convenience during local development. Changing them will not
|
||||
# impact the build.
|
||||
dependencies:
|
||||
sky_engine:
|
||||
path: ../../../out/host_debug_unopt/gen/dart-pkg/sky_engine
|
||||
sky_services:
|
||||
path: ../../../out/host_debug_unopt/gen/dart-pkg/sky_services
|
||||
vector_math: ^2.0.8
|
||||
sky_engine:
|
||||
path: ../../../out/host_debug_unopt/gen/dart-pkg/sky_engine
|
||||
sky_services:
|
||||
path: ../../../out/host_debug_unopt/gen/dart-pkg/sky_services
|
||||
vector_math: ^2.0.8
|
||||
|
||||
dependency_overrides:
|
||||
meta:
|
||||
path: ../../../third_party/dart/pkg/meta
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user