Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
180 changes: 141 additions & 39 deletions shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,75 @@

} // namespace

/// A proxy class for SemanticsObject and UISwitch. For most Accessibility and
/// SemanticsObject methods it delegates to the semantics object, otherwise it
/// sends messages to the UISwitch.
@interface FlutterSwitchSemanticsObject : UISwitch
Copy link
Contributor

Choose a reason for hiding this comment

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

I definitely lack some context here which is making it hard for me to review this. Why is this object a switch subclass? It is not going to be ever used as a switch. That is, it is not in the view hierarchy and you have already overridden most of ally methods. If you wanted to delegate to the base classes implementation of the a11y methods, the responses will be based on a switch in its default state (and not how it appears in the Flutter view hierarchy). Towards that end, shouldn't it be on the implementation to override all the a11y methods as necessary here?

Again. I could be missing something but the base class and the forwarding I don't get the point of.

Copy link
Member Author

Choose a reason for hiding this comment

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

No problem, I can explain. There is magic that happens on iOS for UISwitch with respect to VoiceOver. If you look at the results of a UISwitch's accessibility methods, it doesn't match up to what VoiceOver actually says. In order to get the magic the SemanticsObject has to be a UISwitch (it has undocumented methods or there is some reflection happening). We do something similar for text fields.

@end

@implementation FlutterSwitchSemanticsObject {
SemanticsObject* _semanticsObject;
}

- (instancetype)initWithSemanticsObject:(SemanticsObject*)semanticsObject {
self = [super init];
if (self) {
_semanticsObject = [semanticsObject retain];
}
return self;
}

- (void)dealloc {
[_semanticsObject release];
[super dealloc];
}

- (NSMethodSignature*)methodSignatureForSelector:(SEL)sel {
NSMethodSignature* result = [super methodSignatureForSelector:sel];
if (!result) {
result = [_semanticsObject methodSignatureForSelector:sel];
}
return result;
}

- (void)forwardInvocation:(NSInvocation*)anInvocation {
[anInvocation setTarget:_semanticsObject];
[anInvocation invoke];
}

- (CGRect)accessibilityFrame {
return [_semanticsObject accessibilityFrame];
}

- (id)accessibilityContainer {
return [_semanticsObject accessibilityContainer];
}

- (NSString*)accessibilityLabel {
return [_semanticsObject accessibilityLabel];
}

- (NSString*)accessibilityHint {
return [_semanticsObject accessibilityHint];
}

- (NSString*)accessibilityValue {
if ([_semanticsObject node].HasFlag(flutter::SemanticsFlags::kIsToggled) ||
[_semanticsObject node].HasFlag(flutter::SemanticsFlags::kIsChecked)) {
self.on = YES;
} else {
self.on = NO;
}

if (![_semanticsObject isAccessibilityBridgeAlive]) {
return nil;
} else {
return [super accessibilityValue];
}
}

@end // FlutterSwitchSemanticsObject

@implementation FlutterCustomAccessibilityAction {
}
@end
Expand Down Expand Up @@ -265,9 +334,23 @@ - (NSString*)accessibilityHint {
- (NSString*)accessibilityValue {
if (![self isAccessibilityBridgeAlive])
return nil;
if ([self node].value.empty())
return nil;
return @([self node].value.data());

if (![self node].value.empty()) {
return @([self node].value.data());
}

// FlutterSwitchSemanticsObject should supercede these conditionals.
if ([self node].HasFlag(flutter::SemanticsFlags::kHasToggledState) ||
Copy link
Member

Choose a reason for hiding this comment

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

Can we remove this code and just continue to use the UIAccessibilityTraitSelected as a fallback? (That code should also get a comment explaining why it should never trigger)

Copy link
Member Author

Choose a reason for hiding this comment

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

What was there was wrong for 3 reasons:

  1. 'Selected' has wholly different semantics for users of VoiceOver
  2. The VoiceOver never explained the negative state, only the positive
  3. There was no indication that you could interact with the object

The way things are setup this should never happen, but {false: ("Autosave", "0", "Button") true: ("Autosave", "1", "Button")} is more informative and correct than {false:("Autosave") true:{"Selected", "Autosave"}

Copy link
Member Author

Choose a reason for hiding this comment

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

Added comments.

[self node].HasFlag(flutter::SemanticsFlags::kHasCheckedState)) {
if ([self node].HasFlag(flutter::SemanticsFlags::kIsToggled) ||
[self node].HasFlag(flutter::SemanticsFlags::kIsChecked)) {
return @"1";
} else {
return @"0";
}
}

return nil;
}

- (CGRect)accessibilityFrame {
Expand Down Expand Up @@ -423,10 +506,12 @@ - (UIAccessibilityTraits)accessibilityTraits {
[self node].HasAction(flutter::SemanticsAction::kDecrease)) {
traits |= UIAccessibilityTraitAdjustable;
}
// TODO(jonahwilliams): switches should have a value of "on" or "off"
if ([self node].HasFlag(flutter::SemanticsFlags::kIsSelected) ||
[self node].HasFlag(flutter::SemanticsFlags::kIsToggled) ||
[self node].HasFlag(flutter::SemanticsFlags::kIsChecked)) {
// FlutterSwitchSemanticsObject should supercede these conditionals.
if ([self node].HasFlag(flutter::SemanticsFlags::kHasToggledState) ||
[self node].HasFlag(flutter::SemanticsFlags::kHasCheckedState)) {
traits |= UIAccessibilityTraitButton;
}
if ([self node].HasFlag(flutter::SemanticsFlags::kIsSelected)) {
traits |= UIAccessibilityTraitSelected;
}
if ([self node].HasFlag(flutter::SemanticsFlags::kIsButton)) {
Expand Down Expand Up @@ -755,49 +840,66 @@ - (BOOL)accessibilityScroll:(UIAccessibilityScrollDirection)direction {
platform_view_->DispatchSemanticsAction(uid, action, std::move(args));
}

static void ReplaceSemanticsObject(SemanticsObject* oldObject,
SemanticsObject* newObject,
NSMutableDictionary<NSNumber*, SemanticsObject*>* objects) {
// `newObject` should represent the same id as `oldObject`.
assert(oldObject.node.id == newObject.node.id);
NSNumber* nodeId = @(oldObject.node.id);
NSUInteger positionInChildlist = [oldObject.parent.children indexOfObject:oldObject];
SemanticsObject* parent = oldObject.parent;
[objects removeObjectForKey:nodeId];
newObject.parent = parent;
[newObject.parent.children replaceObjectAtIndex:positionInChildlist withObject:newObject];
objects[nodeId] = newObject;
}

static SemanticsObject* CreateObject(const flutter::SemanticsNode& node,
fml::WeakPtr<AccessibilityBridge> weak_ptr) {
if (node.HasFlag(flutter::SemanticsFlags::kIsTextField) &&
!node.HasFlag(flutter::SemanticsFlags::kIsReadOnly)) {
// Text fields are backed by objects that implement UITextInput.
return [[[TextInputSemanticsObject alloc] initWithBridge:weak_ptr uid:node.id] autorelease];
} else if (node.HasFlag(flutter::SemanticsFlags::kHasToggledState) ||
node.HasFlag(flutter::SemanticsFlags::kHasCheckedState)) {
SemanticsObject* delegateObject = [[FlutterSemanticsObject alloc] initWithBridge:weak_ptr
uid:node.id];
return (SemanticsObject*)[[[FlutterSwitchSemanticsObject alloc]
initWithSemanticsObject:delegateObject] autorelease];
[delegateObject release];
} else {
return [[[FlutterSemanticsObject alloc] initWithBridge:weak_ptr uid:node.id] autorelease];
}
}

static bool DidFlagChange(const flutter::SemanticsNode& oldNode,
const flutter::SemanticsNode& newNode,
SemanticsFlags flag) {
return oldNode.HasFlag(flag) != newNode.HasFlag(flag);
}

SemanticsObject* AccessibilityBridge::GetOrCreateObject(int32_t uid,
flutter::SemanticsNodeUpdates& updates) {
SemanticsObject* object = objects_.get()[@(uid)];
if (!object) {
// New node case: simply create a new SemanticsObject.
flutter::SemanticsNode node = updates[uid];
if (node.HasFlag(flutter::SemanticsFlags::kIsTextField) &&
!node.HasFlag(flutter::SemanticsFlags::kIsReadOnly)) {
// Text fields are backed by objects that implement UITextInput.
object = [[[TextInputSemanticsObject alloc] initWithBridge:GetWeakPtr() uid:uid] autorelease];
} else {
object = [[[FlutterSemanticsObject alloc] initWithBridge:GetWeakPtr() uid:uid] autorelease];
}

object = CreateObject(updates[uid], GetWeakPtr());
objects_.get()[@(uid)] = object;
} else {
// Existing node case
auto nodeEntry = updates.find(object.node.id);
if (nodeEntry != updates.end()) {
// There's an update for this node
flutter::SemanticsNode node = nodeEntry->second;
BOOL isTextField = node.HasFlag(flutter::SemanticsFlags::kIsTextField);
BOOL wasTextField = object.node.HasFlag(flutter::SemanticsFlags::kIsTextField);
BOOL isReadOnly = node.HasFlag(flutter::SemanticsFlags::kIsReadOnly);
BOOL wasReadOnly = object.node.HasFlag(flutter::SemanticsFlags::kIsReadOnly);
if (wasTextField != isTextField || isReadOnly != wasReadOnly) {
// The node changed its type from text field to something else, or vice versa. In this
// case, we cannot reuse the existing SemanticsObject implementation. Instead, we replace
// it with a new instance.
NSUInteger positionInChildlist = [object.parent.children indexOfObject:object];
SemanticsObject* parent = object.parent;
[objects_ removeObjectForKey:@(node.id)];
if (isTextField && !isReadOnly) {
// Text fields are backed by objects that implement UITextInput.
object = [[[TextInputSemanticsObject alloc] initWithBridge:GetWeakPtr()
uid:uid] autorelease];
} else {
object = [[[FlutterSemanticsObject alloc] initWithBridge:GetWeakPtr()
uid:uid] autorelease];
}
object.parent = parent;
[object.parent.children replaceObjectAtIndex:positionInChildlist withObject:object];
objects_.get()[@(node.id)] = object;
if (DidFlagChange(object.node, node, flutter::SemanticsFlags::kIsTextField) ||
DidFlagChange(object.node, node, flutter::SemanticsFlags::kIsReadOnly) ||
DidFlagChange(object.node, node, flutter::SemanticsFlags::kHasCheckedState) ||
DidFlagChange(object.node, node, flutter::SemanticsFlags::kHasToggledState)) {
// The node changed its type. In this case, we cannot reuse the existing
// SemanticsObject implementation. Instead, we replace it with a new
// instance.
SemanticsObject* newSemanticsObject = CreateObject(node, GetWeakPtr());
ReplaceSemanticsObject(object, newSemanticsObject, objects_.get());
object = newSemanticsObject;
}
}
}
Expand Down