import 'dart:ui' show PlatformDispatcher;
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
void main() {
runApp(const MyApp());
}
void announceForAccessibility(String message) {
final views = PlatformDispatcher.instance.views;
if (views.isEmpty) {
return;
}
SemanticsService.sendAnnouncement(views.first, message, TextDirection.ltr);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Accessibility Crash Repro',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const AccessibilityCrashReproScreen(),
);
}
}
class AccessibilityCrashReproScreen extends StatelessWidget {
const AccessibilityCrashReproScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Accessibility Crash Repro')),
body: ListView(
padding: const EdgeInsets.only(bottom: 24),
children: const [
_IntroCard(),
_SectionHeader('Switch tiles'),
AccessibleSwitchSettingsTile(
title: 'Speak words',
defaultValue: true,
),
AccessibleSwitchSettingsTile(
title: 'Speak characters',
defaultValue: true,
),
AccessibleSwitchSettingsTile(
title: 'Play click on keyboard entry',
subtitle: 'When speech is off, play a click instead of speaking.',
),
AccessibleSwitchSettingsTile(title: 'Play sound on newline'),
AccessibleSwitchSettingsTile(
title: 'Always use screen reader voice',
subtitle:
'Keep the native screen reader voice even when content changes.',
leading: Icon(Icons.record_voice_over),
),
AccessibleSwitchSettingsTile(
title: 'Undo with L gesture',
subtitle:
'Swipe left then up in one motion instead of a multi-finger gesture.',
leading: Icon(Icons.undo),
),
_SectionHeader('Slider tiles'),
AccessibleSliderSettingsTile(
title: 'Speech rate',
defaultValue: 5,
min: 1,
max: 10,
step: 0.5,
leading: Icon(Icons.speed),
),
AccessibleSliderSettingsTile(
title: 'Swipe sensitivity',
subtitle: 'Higher values require longer swipes.',
defaultValue: 30,
min: 25,
max: 60,
step: 1,
leading: Icon(Icons.swipe),
),
_SectionHeader('Radio groups'),
AccessibleRadioSettingsTile<String>(
title: 'Input mode',
selected: 'standard',
leading: Icon(Icons.input),
values: {
'standard': 'Braille on-screen keyboard',
'one_handed': 'One-handed braille keyboard',
'external': 'External keyboard',
},
),
AccessibleRadioSettingsTile<String>(
title: 'Cloud provider',
selected: 'google_drive',
leading: Icon(Icons.cloud_queue),
values: {
'google_drive': 'Google Drive',
'dropbox': 'Dropbox',
'onedrive': 'OneDrive',
},
),
_SectionHeader('Extra rows for scrolling'),
AccessibleSwitchSettingsTile(
title: 'Spell out suggested words',
subtitle: 'Announce correction suggestions letter by letter.',
),
AccessibleSwitchSettingsTile(
title: 'Clear buffer after message send',
),
AccessibleSwitchSettingsTile(
title: 'Enable cloud sync',
leading: Icon(Icons.cloud),
),
AccessibleSwitchSettingsTile(
title: 'Use native screen reader',
subtitle:
'Use the system screen reader instead of built-in speech.',
leading: Icon(Icons.accessibility_new),
),
],
),
);
}
}
class _IntroCard extends StatelessWidget {
const _IntroCard();
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
'This screen is a reduced testcase derived from lib/settings.dart. '
'It intentionally combines MergeSemantics, Semantics(excludeSemantics: true), '
'interactive controls, and accessibility announcements inside a scrollable list.',
style: Theme.of(context).textTheme.bodyMedium,
),
),
);
}
}
class _SectionHeader extends StatelessWidget {
const _SectionHeader(this.title);
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
);
}
}
class AccessibleSwitchSettingsTile extends StatefulWidget {
const AccessibleSwitchSettingsTile({
super.key,
required this.title,
this.subtitle,
this.leading,
this.defaultValue = false,
});
final bool defaultValue;
final String title;
final String? subtitle;
final Widget? leading;
@override
State<AccessibleSwitchSettingsTile> createState() =>
_AccessibleSwitchSettingsTileState();
}
class _AccessibleSwitchSettingsTileState
extends State<AccessibleSwitchSettingsTile> {
late bool _value;
@override
void initState() {
super.initState();
_value = widget.defaultValue;
}
void _handleValueChange(bool value) {
setState(() {
_value = value;
});
final stateText = value ? 'On' : 'Off';
announceForAccessibility('${widget.title}, $stateText');
}
void _toggleValue() {
_handleValueChange(!_value);
}
@override
Widget build(BuildContext context) {
final hint = widget.subtitle;
return MergeSemantics(
child: Semantics(
label: widget.title,
hint: (hint != null && hint.isNotEmpty) ? hint : null,
toggled: _value,
enabled: true,
onTap: _toggleValue,
excludeSemantics: true,
child: ListTile(
leading: widget.leading,
title: Text(widget.title),
subtitle: widget.subtitle != null ? Text(widget.subtitle!) : null,
trailing: Switch(value: _value, onChanged: _handleValueChange),
onTap: _toggleValue,
),
),
);
}
}
class AccessibleSliderSettingsTile extends StatefulWidget {
const AccessibleSliderSettingsTile({
super.key,
required this.title,
this.subtitle,
this.leading,
required this.defaultValue,
required this.min,
required this.max,
required this.step,
});
final double defaultValue;
final Widget? leading;
final double max;
final double min;
final double step;
final String? subtitle;
final String title;
@override
State<AccessibleSliderSettingsTile> createState() =>
_AccessibleSliderSettingsTileState();
}
class _AccessibleSliderSettingsTileState
extends State<AccessibleSliderSettingsTile> {
late double _value;
@override
void initState() {
super.initState();
_value = widget.defaultValue.clamp(widget.min, widget.max);
}
String _formatValue(double value) => value.toStringAsFixed(1);
String _semanticValueText(double value) =>
_formatValue(value).replaceAll('.', ' point ');
void _onChanged(double newValue) {
final snapped = (newValue / widget.step).round() * widget.step;
final clamped = snapped.clamp(widget.min, widget.max);
setState(() {
_value = clamped;
});
}
void _increase() {
_onChanged((_value + widget.step).clamp(widget.min, widget.max));
}
void _decrease() {
_onChanged((_value - widget.step).clamp(widget.min, widget.max));
}
@override
Widget build(BuildContext context) {
final divisions = ((widget.max - widget.min) / widget.step).round();
return MergeSemantics(
child: Semantics(
label: widget.title,
value: _semanticValueText(_value),
hint: widget.subtitle,
slider: true,
enabled: true,
onIncrease: _increase,
onDecrease: _decrease,
increasedValue: _semanticValueText(
(_value + widget.step).clamp(widget.min, widget.max),
),
decreasedValue: _semanticValueText(
(_value - widget.step).clamp(widget.min, widget.max),
),
excludeSemantics: true,
child: ExcludeSemantics(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: widget.leading,
title: Text(widget.title),
subtitle: Text(_formatValue(_value)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Slider(
value: _value,
min: widget.min,
max: widget.max,
divisions: divisions,
label: _formatValue(_value),
semanticFormatterCallback: _semanticValueText,
onChanged: _onChanged,
),
),
],
),
),
),
);
}
}
class AccessibleRadioSettingsTile<T> extends StatefulWidget {
const AccessibleRadioSettingsTile({
super.key,
required this.title,
required this.values,
required this.selected,
this.leading,
});
final Widget? leading;
final T selected;
final String title;
final Map<T, String> values;
@override
State<AccessibleRadioSettingsTile<T>> createState() =>
_AccessibleRadioSettingsTileState<T>();
}
class _AccessibleRadioSettingsTileState<T>
extends State<AccessibleRadioSettingsTile<T>> {
late T _selected;
@override
void initState() {
super.initState();
_selected = widget.selected;
}
void _select(T value) {
setState(() {
_selected = value;
});
final label = widget.values[value] ?? value.toString();
announceForAccessibility('$label, selected');
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Row(
children: [
if (widget.leading != null) ...[
widget.leading!,
const SizedBox(width: 16),
],
Text(
widget.title,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
),
RadioGroup<T>(
groupValue: _selected,
onChanged: (value) {
if (value != null) {
_select(value);
}
},
child: Column(
children: [
for (final entry in widget.values.entries)
MergeSemantics(
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
title: Text(entry.value),
leading: Radio<T>(value: entry.key),
onTap: () => _select(entry.key),
),
),
],
),
),
],
);
}
}
Steps to reproduce
Start TalkBack, start the attached main.dart test app, navigate the scrollable screen for a while swiping left and right.
Soon the app will disappear (crash).
According to users, VoiceOver also behaves erratically when the screen starts to scroll, which I suspect is for the same underlying reason.
Expected results
App does not crash, TalkBack and VoiceOver navigation should work reliably.
Actual results
Application crashes when navigating with TalkBack
Code sample
Code sample
Screenshots or Video
Screenshots / Video demonstration
[Upload media here]
Logs
Logs, somewhat abridged as this was over 64kb, which seems to be the max allowed.
Flutter Doctor output
Doctor output