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

Bare-bones iOS FKA implementation #55964

Merged
merged 7 commits into from
Oct 24, 2024
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
2 changes: 2 additions & 0 deletions ci/licenses_golden/licenses_flutter
Original file line number Diff line number Diff line change
Expand Up @@ -44373,6 +44373,7 @@ ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterViewT
ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/IOKit.h + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/KeyCodeMap.g.mm + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/KeyCodeMap_Internal.h + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject+UIFocusSystem.mm + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.h + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm + ../../../flutter/LICENSE
ORIGIN: ../../../flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm + ../../../flutter/LICENSE
Expand Down Expand Up @@ -47261,6 +47262,7 @@ FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/FlutterViewTes
FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/IOKit.h
FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/KeyCodeMap.g.mm
FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/KeyCodeMap_Internal.h
FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject+UIFocusSystem.mm
FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.h
FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm
FILE: ../../../flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm
Expand Down
1 change: 1 addition & 0 deletions shell/platform/darwin/ios/BUILD.gn
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ source_set("flutter_framework_source_arc") {
"framework/Source/FlutterViewResponder.h",
"framework/Source/KeyCodeMap.g.mm",
"framework/Source/KeyCodeMap_Internal.h",
"framework/Source/SemanticsObject+UIFocusSystem.mm",
"framework/Source/SemanticsObject.h",
"framework/Source/SemanticsObject.mm",
"framework/Source/TextInputSemanticsObject.h",
Expand Down
27 changes: 27 additions & 0 deletions shell/platform/darwin/ios/framework/Source/FlutterView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// found in the LICENSE file.

#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterView.h"
#import "flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.h"

#include "flutter/fml/platform/darwin/cf_utils.h"

Expand Down Expand Up @@ -226,4 +227,30 @@ - (BOOL)isAccessibilityElement {
return NO;
}

// Enables keyboard-based navigation when the user turns on
// full keyboard access (FKA), using existing accessibility information.
//
// iOS does not provide any API for monitoring or querying whether FKA is on,
// but it does call isAccessibilityElement if FKA is on,
// so the isAccessibilityElement implementation above will be called
// when the view appears and the accessibility information will most likely
// be available by the time the user starts to interact with the app using FKA.
//
// See SemanticsObject+UIFocusSystem.mm for more details.
- (NSArray<id<UIFocusItem>>*)focusItemsInRect:(CGRect)rect {
NSObject* rootAccessibilityElement =
[self.accessibilityElements count] > 0 ? self.accessibilityElements[0] : nil;
return [rootAccessibilityElement isKindOfClass:[SemanticsObjectContainer class]]
? @[ [rootAccessibilityElement accessibilityElementAtIndex:0] ]
: nil;
}

- (NSArray<id<UIFocusEnvironment>>*)preferredFocusEnvironments {
// Occasionally we add subviews to FlutterView (text fields for example).
// These views shouldn't be directly visible to the iOS focus engine, instead
// the focus engine should only interact with the designated focus items
// (SemanticsObjects).
return nil;
}

@end
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright 2013 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 "SemanticsObject.h"
#import "flutter/shell/platform/darwin/common/framework/Headers/FlutterMacros.h"

FLUTTER_ASSERT_ARC

// The SemanticsObject class conforms to UIFocusItem and UIFocusItemContainer
// protocols, so the SemanticsObject tree can also be used to represent
// interactive UI components on screen that can receive UIFocusSystem focus.
//
// Typically, physical key events received by the FlutterViewController is
// first delivered to the framework, but that stopped working for navigation keys
// since iOS 15 when full keyboard access (FKA) is on, because those events are
// consumed by the UIFocusSystem and never dispatched to the UIResponders in the
// application (see
// https://developer.apple.com/documentation/uikit/uikeycommand/3780513-wantspriorityoversystembehavior
// ). FKA relies on the iOS focus engine, to enable FKA on iOS 15+, we use
// SemanticsObject to provide the iOS focus engine with the required hierarchical
// information and geometric context.
//
// The focus engine focus is different from accessibility focus, or even the
// currentFocus of the Flutter FocusManager in the framework. On iOS 15+, FKA
// key events are dispatched to the current iOS focus engine focus (and
// translated to calls such as -[NSObject accessibilityActivate]), while most
// other key events are dispatched to the framework.
@interface SemanticsObject (UIFocusSystem) <UIFocusItem, UIFocusItemContainer>
@end

@implementation SemanticsObject (UIFocusSystem)

#pragma mark - UIFocusEnvironment Conformance

- (void)setNeedsFocusUpdate {
}

- (void)updateFocusIfNeeded {
}

- (BOOL)shouldUpdateFocusInContext:(UIFocusUpdateContext*)context {
return YES;
}

- (void)didUpdateFocusInContext:(UIFocusUpdateContext*)context
withAnimationCoordinator:(UIFocusAnimationCoordinator*)coordinator {
}

- (id<UIFocusEnvironment>)parentFocusEnvironment {
// The root SemanticsObject node's parent is the FlutterView.
return self.parent ?: self.bridge->view();
}

- (NSArray<id<UIFocusEnvironment>>*)preferredFocusEnvironments {
return nil;
}

- (id<UIFocusItemContainer>)focusItemContainer {
return self;
}

#pragma mark - UIFocusItem Conformance

- (BOOL)canBecomeFocused {
if ((self.node.flags & static_cast<int32_t>(flutter::SemanticsFlags::kIsHidden)) != 0) {
return NO;
}
// Currently only supports SemanticsObjects that handle
// -[NSObject accessibilityActivate].
return self.node.HasAction(flutter::SemanticsAction::kTap);
}

- (CGRect)frame {
return self.accessibilityFrame;
}

#pragma mark - UIFocusItemContainer Conformance

- (NSArray<id<UIFocusItem>>*)focusItemsInRect:(CGRect)rect {
// It seems the iOS focus system relies heavily on focusItemsInRect
// (instead of preferredFocusEnvironments) for directional navigation.
//
// The order of the items seems to be important, menus and dialogs become
// unreachable via FKA if the returned children are organized
// in hit-test order.
//
// This method is only supposed to return items within the given
// rect but returning everything in the subtree seems to work fine.
NSMutableArray<SemanticsObject*>* reversedItems =
[[NSMutableArray alloc] initWithCapacity:self.childrenInHitTestOrder.count];
for (NSUInteger i = 0; i < self.childrenInHitTestOrder.count; ++i) {
[reversedItems
addObject:self.childrenInHitTestOrder[self.childrenInHitTestOrder.count - 1 - i]];
}
return reversedItems;
Copy link
Member

Choose a reason for hiding this comment

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

Returning a reversed array seems very unintuitive, are you doing this just to make menus and dialogs reachable?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes it's undocumented. I tested in an example app, popup menus couldn't be focused (instead the model barrier is the only thing focusable when a popup menu is present) if the items were in hit test order.

}

- (id<UICoordinateSpace>)coordinateSpace {
return self.bridge->view();
}
@end
54 changes: 54 additions & 0 deletions shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

const float kFloatCompareEpsilon = 0.001;

@interface SemanticsObject (UIFocusSystem) <UIFocusItem, UIFocusItemContainer>
@end

@interface TextInputSemanticsObject (Test)
- (UIView<UITextInput>*)textInputSurrogate;
@end
Expand Down Expand Up @@ -1152,4 +1155,55 @@ - (void)testTextInputSemanticsObject_editActions {
[self waitForExpectationsWithTimeout:1 handler:nil];
}

- (void)testUIFocusItemConformance {
fml::WeakPtrFactory<flutter::AccessibilityBridgeIos> factory(
new flutter::testing::MockAccessibilityBridge());
fml::WeakPtr<flutter::AccessibilityBridgeIos> bridge = factory.GetWeakPtr();
SemanticsObject* parent = [[SemanticsObject alloc] initWithBridge:bridge uid:0];
SemanticsObject* child = [[SemanticsObject alloc] initWithBridge:bridge uid:1];
parent.children = @[ child ];

// parentFocusEnvironment
XCTAssertTrue([parent.parentFocusEnvironment isKindOfClass:[UIView class]]);
XCTAssertEqual(child.parentFocusEnvironment, child.parent);

// canBecomeFocused
flutter::SemanticsNode childNode;
childNode.flags = static_cast<int32_t>(flutter::SemanticsFlags::kIsHidden);
childNode.actions = static_cast<int32_t>(flutter::SemanticsAction::kTap);
[child setSemanticsNode:&childNode];
XCTAssertFalse(child.canBecomeFocused);
childNode.flags = 0;
[child setSemanticsNode:&childNode];
XCTAssertTrue(child.canBecomeFocused);
childNode.actions = 0;
[child setSemanticsNode:&childNode];
XCTAssertFalse(child.canBecomeFocused);

CGFloat scale = ((bridge->view().window.screen ?: UIScreen.mainScreen)).scale;

childNode.rect = SkRect::MakeXYWH(0, 0, 100 * scale, 100 * scale);
[child setSemanticsNode:&childNode];
flutter::SemanticsNode parentNode;
parentNode.rect = SkRect::MakeXYWH(0, 0, 200, 200);
[parent setSemanticsNode:&parentNode];

XCTAssertTrue(CGRectEqualToRect(child.frame, CGRectMake(0, 0, 100, 100)));
}

- (void)testUIFocusItemContainerConformance {
fml::WeakPtrFactory<flutter::AccessibilityBridgeIos> factory(
new flutter::testing::MockAccessibilityBridge());
fml::WeakPtr<flutter::AccessibilityBridgeIos> bridge = factory.GetWeakPtr();
SemanticsObject* parent = [[SemanticsObject alloc] initWithBridge:bridge uid:0];
SemanticsObject* child1 = [[SemanticsObject alloc] initWithBridge:bridge uid:1];
SemanticsObject* child2 = [[SemanticsObject alloc] initWithBridge:bridge uid:2];
parent.childrenInHitTestOrder = @[ child1, child2 ];

// focusItemsInRect
NSArray<id<UIFocusItem>>* itemsInRect = [parent focusItemsInRect:CGRectMake(0, 0, 100, 100)];
XCTAssertEqual(itemsInRect.count, (unsigned long)2);
XCTAssertTrue([itemsInRect containsObject:child1]);
XCTAssertTrue([itemsInRect containsObject:child2]);
}
@end