Repository navigation
Conversation
Device testing showed VoiceOver still speaks the page-scrolled announcement when the existing layout-changed notification follows it, so there is no need to change focus handling. The scroll announcement is now purely additive.
…l-announcement # Conflicts: # engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge.h # engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm
After merging master, notifications are only posted when the view is loaded, so the new tests need to stub viewIfLoaded like the existing ones.
- Format UIKit's scroll strings with +stringWithValidatedFormat:, which returns nil if a string's placeholders don't match, instead of hand-rolled substitution. English fallbacks are compiler-checked format literals. - Reset the scroll announcement dedupe on each VoiceOver scroll action, so every swipe is announced even if the status is unchanged, matching native UIScrollView behavior. Frames within one scroll are still deduplicated. - Comment rewrites.
There was a problem hiding this comment.
Code Review
This pull request updates the iOS accessibility bridge to improve scroll status announcements by tracking scroll states and utilizing UIKit's native accessibility strings. It also adds unit tests to verify scroll status calculations and deduplication. Feedback on the changes identifies a critical compatibility issue where the newly introduced stringWithValidatedFormat:validFormatSpecifiers:error: selector is only available on iOS 16.0 and later, which will cause runtime crashes on older supported iOS versions (iOS 13+). It is recommended to wrap these formatting calls in @available(iOS 16.0, *) checks.
| NSString* result = format ? [NSString stringWithValidatedFormat:format | ||
| validFormatSpecifiers:@"%@ %@ %@" | ||
| error:nil, first, last, total] | ||
| : nil; |
There was a problem hiding this comment.
The stringWithValidatedFormat:validFormatSpecifiers:error: selector was introduced in iOS 16.0. Since Flutter supports older iOS versions (iOS 13+), calling this selector directly on iOS 13, 14, or 15 will result in a runtime crash (NSInvalidArgumentException due to unrecognized selector). Wrap this call in an @available(iOS 16.0, *) check and fall back to the English string (or another safe formatting approach) on older iOS versions.
NSString* result = nil;
if (format) {
if (@available(iOS 16.0, *)) {
result = [NSString stringWithValidatedFormat:format
validFormatSpecifiers:@"%@ %@ %@"
error:nil, first, last, total];
}
}
There was a problem hiding this comment.
| int64_t last = 0; | ||
| int64_t total = 0; | ||
|
|
||
| bool operator==(const AccessibilityScrollStatus& other) const { |
There was a problem hiding this comment.
nit: can this use the default implementation?https://en.cppreference.com/cpp/language/default_comparisons
|
|
||
| Form form = Form::kNone; | ||
| int32_t uid = -1; | ||
| int64_t first = 0; |
There was a problem hiding this comment.
Consider giving these more descriptive names.
|
|
||
| namespace { | ||
|
|
||
| // Uses UIKit's accessibility strings, so scroll announcements match native |
There was a problem hiding this comment.
Are these private APIs?
There was a problem hiding this comment.
Discussed offline, it seems there are no known viable alternatives at the moment. Can't use a UIKit view to make the a11y announcement.
| NSBundle* UIKitAccessibilityBundle() { | ||
| static NSBundle* bundle = nil; | ||
| if (!bundle) { | ||
| bundle = [NSBundle bundleWithIdentifier:@(kUIKitAccessibilityBundleId)]; |
There was a problem hiding this comment.
nit: the documentation seems to indicate the value is already cached: https://developer.apple.com/documentation/foundation/bundle/init(identifier:)#return-value
| NSString* last = LocalizedCount(status.last); | ||
| NSString* total = LocalizedCount(status.total); | ||
| NSString* format = UIKitLocalizedFormat(kScrollRowStatusKey); | ||
| NSString* result = format ? [NSString stringWithValidatedFormat:format |
There was a problem hiding this comment.
nit: [NSBundle localizedStringForKey:value:table] already accepts a fallback: https://developer.apple.com/documentation/foundation/bundle/localizedstring(forkey:value:table:)#parameters.
or
NSString* format = UIKitLocalizedFormat(kScrollRowStatusKey) ?: @"rows %1$@ to %2$@ of %3$@"There was a problem hiding this comment.
Oh you're validating the format string UIKitLocalizedFormat returned but not for the fallback. Since we rarely hit the fallback, validating the fallback isn't that bad for performance I think?
| if (action == flutter::SemanticsAction::kScrollUp || | ||
| action == flutter::SemanticsAction::kScrollDown || | ||
| action == flutter::SemanticsAction::kScrollLeft || | ||
| action == flutter::SemanticsAction::kScrollRight) { |
There was a problem hiding this comment.
Should kScrollToOffset = 1 << 23 also reset the status (AFAIK it's only used by FKA)? Or strictly voice over scrolls?
| } | ||
| int64_t total = node.scrollChildren; | ||
| int64_t first = std::clamp<int64_t>(node.scrollIndex + 1, 1, total); | ||
| status.form = AccessibilityScrollStatus::Form::kRows; |
There was a problem hiding this comment.
So when scrollChildren > 0 is announced as kPage and if we know the total number of children in the scrollable container then it will say "Rows x to y"? I'm curious why scrollChildren > 0 distinguishes page scroll views from row scroll views?
| // A partial last screen counts as a page. The tolerance stops floating point | ||
| // error in the extents from adding a page. | ||
| constexpr double kPageTolerance = 1e-6; | ||
| const double range = std::max(extent_max - extent_min, 0.0); |
There was a problem hiding this comment.
uber nit: range -> scroll_range?
|
|
||
| const bool horizontal = node.HasAction(flutter::SemanticsAction::kScrollLeft) || | ||
| node.HasAction(flutter::SemanticsAction::kScrollRight); | ||
| const double viewport = horizontal ? node.rect.width() : node.rect.height(); |
There was a problem hiding this comment.
uber nit: viewport -> viewport_extent
| constexpr double kPageTolerance = 1e-6; | ||
| const double range = std::max(extent_max - extent_min, 0.0); | ||
| int64_t total_pages = std::max<int64_t>( | ||
| static_cast<int64_t>(std::ceil((range + viewport) / viewport - kPageTolerance)), 1); |
There was a problem hiding this comment.
according to the comment the kPageTolerance thing is for preventing an extraneous page? Then shouldn't it be viewport + kPageTolerance for denominator to reduce the value?

fixes #189285
There were two bugs that this PR fixes.
Since on every scroll, the layout will change, so the
elsebranch was unreachable.We were passing in the wrong type - we passed it a semantic element, but UIAccessibilityPageScrolledNotification needs an NSString, so VoiceOver ignored it.
Android didn't have this problem because TalkBack receives numbers and builds the sentence by itself. On iOS though, we have to supply the finished, localized sentence.
For localization, this PR doesnt add any new localization. Instead, we look up the native string supplied by UIKit, using a public NSBundle API. This means that the the wording matches native apps in every language iOS supports, including languages that reorder the numbers, for free.
However, the keys are undocumented. So if a lookup ever fails, the announcement falls back to English, which is still better than today's complete silence. We already do this elsewhere in the engine, public api with undocumented keys, so I feel the risk is low.
Longer term, it would be good if the framework could supply its own localized strings, to remove that dependency on an undocumented key.
English VoiceOver scroll announcement
english.voiceover.scroll.mov
Chinese VoiceOver scroll announcement
chinese.voiceover.scroll.mov
Pre-launch Checklist
///).