Fix accessibility focus loss when first focusing on text field (flutter/engine#17803)

This commit is contained in:
xster 2020-04-24 15:44:54 -07:00 committed by GitHub
parent c4f46db590
commit 664700007c
19 changed files with 426 additions and 101 deletions

View File

@ -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];
}
}

View File

@ -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 {

View File

@ -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"

View File

@ -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>

View File

@ -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());

View File

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

View File

@ -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"

View File

@ -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];

View File

@ -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"

View File

@ -6,6 +6,8 @@
#import <XCTest/XCTest.h>
#import "ScreenBeforeFlutter.h"
FLUTTER_ASSERT_ARC
@interface XCAppLifecycleTestExpectation : XCTestExpectation
- (instancetype)initForLifecycle:(NSString*)expectedLifecycle forStep:(NSString*)step;

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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,
);
}

View File

@ -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,
);
}
}

View File

@ -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.

View File

@ -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()
);
}
}

View File

@ -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(

View File

@ -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