[iOS][Accessibility] VoiceOver focus defaults to top-left (0,0) when opening PopupMenuButton
- 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
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