Skip to content

[iOS] Announce scroll position to VoiceOver - #193384

Draft
LouiseHsu wants to merge 8 commits into
flutter:masterfrom
LouiseHsu:a11y-189285-scroll-announcement
Draft

LouiseHsu wants to merge 8 commits into
flutter:masterfrom
LouiseHsu:a11y-189285-scroll-announcement

Conversation

@LouiseHsu

@LouiseHsu LouiseHsu commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

fixes #189285

There were two bugs that this PR fixes.

  1. Since on every scroll, the layout will change, so the else branch was unreachable.

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

  • I read the [Contributor Guide] and followed the process outlined there for submitting PRs.
  • I read the [AI contribution guidelines] and understand my responsibilities, or I am not using AI tools.
  • I read the [Tree Hygiene] wiki page, which explains my responsibilities.
  • I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement].
  • I signed the [CLA].
  • I listed at least one issue that this PR fixes in the description above.
  • I updated/added relevant in-code documentation (doc comments with ///).
  • If this PR introduces a new feature or capability, I created and linked a website documentation issue or PR in [flutter/website] (or verified none is needed).
  • I added new tests to check the change I am making, or this PR is [test-exempt].
  • I followed the [breaking change policy] and added [Data Driven Fixes] where supported.
  • All existing and new tests are passing.

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.
@github-actions github-actions Bot added platform-ios iOS applications specifically engine flutter/engine related. See also e: labels. a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) team-ios Owned by iOS platform team labels Sep 26, 2026
…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.
@LouiseHsu LouiseHsu added the CICD Run CI/CD label Sep 28, 2026
LouiseHsu and others added 3 commits September 28, 2026 12:30
- 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.
@LouiseHsu
LouiseHsu marked this pull request as ready for review September 30, 2026 01:28
@LouiseHsu
LouiseHsu requested a review from a team as a code owner September 30, 2026 01:28

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +224 to +227
NSString* result = format ? [NSString stringWithValidatedFormat:format
validFormatSpecifiers:@"%@ %@ %@"
error:nil, first, last, total]
: nil;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

int64_t last = 0;
int64_t total = 0;

bool operator==(const AccessibilityScrollStatus& other) const {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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


Form form = Form::kNone;
int32_t uid = -1;
int64_t first = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Consider giving these more descriptive names.


namespace {

// Uses UIKit's accessibility strings, so scroll announcements match native

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are these private APIs?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NSString* last = LocalizedCount(status.last);
NSString* total = LocalizedCount(status.total);
NSString* format = UIKitLocalizedFormat(kScrollRowStatusKey);
NSString* result = format ? [NSString stringWithValidatedFormat:format

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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;

@LongCatIsLooong LongCatIsLooong Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

@vashworth
vashworth marked this pull request as draft October 14, 2026 21:48
@LouiseHsu LouiseHsu mentioned this pull request Nov 3, 2026
10 of 11 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) CICD Run CI/CD engine flutter/engine related. See also e: labels. platform-ios iOS applications specifically team-ios Owned by iOS platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[iOS][a11y] VoiceOver three-finger scroll never announces scroll status ("Page X of Y")

2 participants