Skip to content

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

Description

@davefaliskie

Steps to reproduce

  1. Create any scrollable list (ListView.builder or CustomScrollView + SliverList) with more content than fits one screen.
  2. Run on a physical iOS device, enable VoiceOver.
  3. Three-finger swipe up/down to scroll.

Expected results

VoiceOver announces the scroll status after the scroll settles, "Page 2 of 5" (or "Rows 6 to 10 of 43"), as it does in every native iOS scroll view (Messages, Settings, Mail). Blind users rely on this to know where they are in a list; without it a paginated feed is disorienting: scrolls happen silently and there is no positional feedback at all.

Actual results

The scroll happens, but nothing is announced. (TalkBack on Android announces correctly.)

Root cause (traced through engine source)

When a VoiceOver scroll action completes, the iOS embedder posts UIAccessibilityPageScrolledNotification with no announcement string — there is a TODO where the string should be, in AccessibilityBridge::UpdateSemantics (engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge.mm):

} else if (scrollOccured) {
  // TODO(chunhtai): figure out what string to use for notification. At this
  // point, it is guarantee the previous focused object is still in the tree
  // so that we don't need to worry about focus lost. (e.g. "Screen 0 of 3")
  ios_delegate_->PostAccessibilityNotification(
      UIAccessibilityPageScrolledNotification,
      FindNextFocusableIfNecessary().nativeAccessibility);
}

Per Apple's contract for custom accessibilityScroll: implementations, the app must post UIAccessibilityPageScrolledNotification with a string describing the new scroll position; posting an element yields no speech.

Android doesn't have this problem because the Android embedder emits TYPE_VIEW_SCROLLED events populated with scrollY/maxScrollY/itemCount/fromIndex/toIndex (AccessibilityBridge.java), and TalkBack composes the announcement itself. iOS has no equivalent event schema — the string must be supplied by the app/engine, and the iOS embedder has no localization infrastructure for UI strings, which is presumably why the TODO has remained.

Possible fix directions

Option A — reuse UIKit's native scroll machinery (preferred?). The engine already backs every scrollable semantics node with a real UIScrollView (FlutterSemanticsScrollView) whose contentSize/contentOffset are kept in sync with the Flutter scrollable. It also already supports the OS scrolling that scroll view natively: SemanticsAction.kScrollToOffset forwards a system-driven setContentOffset back to the framework (added for UIFocusItemScrollableContainer support, see SemanticsObject+UIFocusSystem.mm). If accessibilityScroll: let UIKit's built-in UIScrollView accessibility scrolling run (with isDoingSystemScrolling set) instead of dispatching kScrollUp/kScrollDown to Dart, UIKit would page the scroll view and produce its own system-localized scroll-status announcement — no engine localization needed. This might also be a path toward exposing the native VoiceOver "vertical scroll bar" element, i.e. #79355. Open questions: UIKit pages by full viewport vs the framework's 0.8-viewport convention (RenderSemanticsGestureHandler.scrollFactor); interaction with infinite/estimated extents (the engine already substitutes a large finite contentSize for infinite scrollExtentMax); the engine's manual PageScrolledNotification post would need suppressing on this path.

Option B — implement the TODO literally. Compute the string at the posting site from the scrolled node's scrollPosition/scrollExtentMin/scrollExtentMax and viewport rect (or scrollChildren/scrollIndex for "rows x to y of z"). Blocker: needs localized strings in the engine, or plumbing a localized template from the framework through the semantics update.

Context / impact

I build an audio app with a substantial blind user base. Multiple VoiceOver users have independently reported this as their top navigation obstacle, explicitly comparing it to the "Page 1 of 27" behavior they get in native iOS apps.

Related but distinct: #79355 tracks the missing VoiceOver "vertical scroll bar" element; #164483 (fixed by #184155) covered the silent focusable element before scroll views.

Flutter version

Reproduced on Flutter 3.44.5 (stable) / iOS 18–26 devices; the relevant engine code is unchanged on master as of this filing.

Activity

  1. added
    in triagePresently being triaged by the triage team
    platform-iosiOS applications specifically
    a: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    fyi-iosFor the attention of iOS platform team
    team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)
    and removed
    in triagePresently being triaged by the triage team
    on Jul 11, 2026
  2. mbcorona commented on Jul 11, 2026

    @mbcorona
    Member

    Thanks for the detailed report and source analysis.

    Reproduced on an iPhone 15 Pro Max with VoiceOver: a three-finger scroll on a plain ListView.builder scrolls the list but announces no position — no "Page X of Y" / "Rows X to Y". In my repro (list under an AppBar) focus also landed on the header after the scroll and VoiceOver read that instead, which lines up with the notification posting the next focusable element rather than a scroll-status string.

    The scrollOccured branch in accessibility_bridge.mm still has the TODO in place of the position string on master.

    Routing to the accessibility team. #79355 and #164483 look separate from this.

  3. added
    P2Important issues not at the top of the work list
    on Jul 15, 2026
  4. zywek123 commented on Jul 23, 2026

    @zywek123

    @davefaliskie issues related, not fixed yet
    #22747 #64279 #64924 #98572 #141607
    Each of them concerns the same issue: the lack of notifications when scrolling with three fingers.

  5. haashem commented on Sep 11, 2026

    @haashem
    Actual (Flutter) Expected (iOS)
    flutter.MP4
    ios.MP4

    Sample app:

    import 'dart:math';
    
    import 'package:flutter/material.dart';
    
    void main() {
      runApp(const MyApp());
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'List View',
          theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue)),
          home: const ContentView(),
        );
      }
    }
    
    class ContentView extends StatefulWidget {
      const ContentView({super.key});
    
      @override
      State<ContentView> createState() => _ContentViewState();
    }
    
    class _ContentViewState extends State<ContentView> {
      late final List<double> _heights = List.generate(
        _categories.length,
        (_) => 44 + Random().nextDouble() * (200 - 44),
      );
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(title: const Text('Categories')),
          body: CustomScrollView(
            slivers: [
              SliverList.separated(
                itemCount: _categories.length,
                itemBuilder: (context, index) =>
                    CategoryTile(name: _categories[index], height: _heights[index]),
                separatorBuilder: (context, index) => const Divider(height: 1),
              ),
            ],
          ),
        );
      }
    }
    
    class CategoryTile extends StatelessWidget {
      const CategoryTile({super.key, required this.name, required this.height});
    
      final String name;
      final double height;
    
      @override
      Widget build(BuildContext context) {
        return Container(
          height: height,
          alignment: Alignment.centerLeft,
          padding: const EdgeInsets.symmetric(horizontal: 16),
          child: Text(name),
        );
      }
    }
    
    const List<String> _categories = [
      'Electronics', 'Clothing', 'Books', 'Toys', 'Sports', 'Home & Garden',
      'Automotive', 'Health & Beauty', 'Grocery', 'Music', 'Movies',
      'Video Games', 'Office Supplies', 'Furniture', 'Jewelry', 'Pet Supplies',
      'Baby Products', 'Tools & Hardware', 'Outdoor & Camping', 'Kitchen & Dining',
      'Art & Crafts', 'Travel', 'Fitness Equipment', 'Photography', 'Shoes',
      'Bags & Luggage', 'Watches', 'Software', 'Musical Instruments',
      'Garden Tools', 'Lighting', 'Bedding', 'Bath', 'Cleaning Supplies',
      'Party Supplies', 'Stationery', 'Plants & Seeds', 'Aquarium',
      'Bird Supplies', 'Cycling', 'Swimming', 'Yoga & Pilates', 'Cooking & Baking',
      'Skincare', 'Hair Care', 'Vitamins & Supplements', 'Medical Supplies',
      'Educational', 'Collectibles', 'Seasonal',
    ];
  6. chunhtai commented on Sep 14, 2026

    @chunhtai
    Contributor

    not entirely sure how to properly handle this, but here is some options

    1. figure out if we can implement this API somehow https://developer.apple.com/documentation/uikit/uiscrollviewaccessibilitydelegate?language=objc

    2. If 1 doesn't work, we can provide our own custom string in

  7. added
    team-iosOwned by iOS platform team
    and removed
    P2Important issues not at the top of the work list
    team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)
    on Sep 14, 2026
  8. added
    P1High-priority issues at the top of the work list
    triaged-iosTriaged by iOS platform team
    on Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P1High-priority issues at the top of the work lista: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)customer: bluehas reproducible stepsThe issue has been confirmed reproducible and is ready to work onplatform-iosiOS applications specificallyteam-iosOwned by iOS platform teamtriaged-iosTriaged by iOS platform team

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions