flutter / flutter/flutter

[iOS][Accessibility] VoiceOver focus defaults to top-left (0,0) when opening PopupMenuButton

Open
#186,544 7 comments 0 reactions 0 assignees View on GitHub
a: accessibility has reproducible steps p: material_ui P3 package platform-ios team-accessibility triaged-accessibility
Dominant language
Dart
Stars
179k
Forks
31.1k
PR merge metrics
PR metrics pending

Description

### Steps to reproduce

-Run the provided minimal reproduction code on an iOS device or simulator with VoiceOver enabled.
-Tap either the standalone PopupMenuButton or the custom wrapped Dropdown Field Target button to trigger the menu.
-Observe where the screen reader focus indicator settles immediately upon the initial opening tap.

### Expected results

VoiceOver focus should immediately land onto the newly visible layout context (either the menu container itself or the first active semantic element inside the pushed PopupRoute) for all platforms (Android + iOS)

### Actual results

For iOS only, VoiceOver encounters a momentary void at the relative opening coordinate and cleanly defaults back to the top-left screen origin (0,0), creating a frustrating accessibility break.

Happens across all instances of PopupMenuButton

One theory: PopupMenuButton pushes a PopupRoute that immediately claims VoiceOver focus (scopesRoute: true). Because the menu's entrance animation starts at zero scale, the menu items have no physical size for the first few frames. VoiceOver, finding no "hit area" at the target location, defaults to the screen origin (0,0). Basically, Flutter's _PopupMenuRoute claims focus the millisecond it is pushed, but it doesn't have a "landing spot" yet because of the scale-in animation.

### Code sample
``` dart
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(
theme: ThemeData(useMaterial3: true),
home: const AccessibilityReproPage(),
);
}
}

class AccessibilityReproPage extends StatefulWidget {
const AccessibilityReproPage({super.key});

@override
State createState() => _AccessibilityReproPageState();
}

class _AccessibilityReproPageState extends State {
String _selectedItem = 'Option Alpha';

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('VoiceOver Menu Repro'),
),
body: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Test Case 1: Standalone PopupMenuButton',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
PopupMenuButton(
initialValue: _selectedItem,
onSelected: (value) => setState(() => _selectedItem = value),
itemBuilder: (context) => const [
PopupMenuItem(value: 'Option Alpha', child: Text('Option Alpha')),
PopupMenuItem(value: 'Option Beta', child: Text('Option Beta')),
PopupMenuItem(value: 'Option Gamma', child: Text('Option Gamma')),
],
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(_selectedItem),
const Icon(Icons.arrow_drop_down),
],
),
),
),
const SizedBox(height: 48),
const Text(
'Test Case 2: Generic Wrapped Component (Simulating Field Layout)',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
GenericPopupFieldWrapper(
valueText: _selectedItem,
itemBuilder: (context) => const [
PopupMenuItem(value: 'Option Alpha', child: Text('Option Alpha')),
PopupMenuItem(value: 'Option Beta', child: Text('Option Beta')),
PopupMenuItem(value: 'Option Gamma', child: Text('Option Gamma')),
],
onSelected: (value) => setState(() => _selectedItem = value),
),
],
),
),
);
}
}

/// A fully generic wrapper mimicking the layout behaviors of common production text field/dropdown pickers.
class GenericPopupFieldWrapper extends StatelessWidget {
final String valueText;
final List> Function(BuildContext) itemBuilder;
final ValueChanged onSelected;

const GenericPopupFieldWrapper({
super.key,
required this.valueText,
required this.itemBuilder,
required this.onSelected,
});

@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return MergeSemantics(
child: PopupMenuButton(
position: PopupMenuPosition.over,
constraints: BoxConstraints(minWidth: constraints.maxWidth),
itemBuilder: itemBuilder,
onSelected: onSelected,
child: IgnorePointer(
child: Semantics(
excludeSemantics: true,
value: valueText,
button: true,
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(valueText),
const Icon(Icons.arrow_drop_down),
],
),
),
),
),
),
);
},
);
}
}

```

### Screenshots or Video

Screenshots / Video demonstration

[Upload media here]

### Logs

N/A

### Flutter Doctor output

Nothing

Contributor guide

Open the contributing guide

Research direction

Start by running the provided minimal reproduction on an iOS simulator or device with VoiceOver enabled, then trace PopupMenuButton and _PopupMenuRoute during the menu-opening transition. Compare focus behavior on iOS and Android; done means VoiceOver lands on the menu container or first active semantic element instead of (0,0) while preserving the reported behavior across both examples.

Written by the indexing model from the issue text.

Assessment

Tech stack
dart, flutter
Domain
accessibility, mobile
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.