In Flutter 3.47, how do you mark a section heading and announce a status change, and why is header: true no longer enough?
answer
- headings let users jump by section
- header is a no-op on iOS and Android
- headingLevel 1 to 6
- liveRegion: polite, focus stays put
- announce deprecated for sendAnnouncement
basics
~10 sUse Semantics(headingLevel: 1..6) for headings: since Flutter 3.47, header: true is a no-op on iOS and Android. For status changes, mark the text Semantics(liveRegion: true) so updates are announced politely without moving focus.
solid answer
~40 sScreen-reader users jump between headings, so section titles need the heading trait. Since Flutter 3.47, `Semantics(header: true)` is a **no-op on iOS and Android**; `headingLevel` greater than 0 maps to Android's `setHeading(true)` and iOS's header trait, and on the web levels 1–6 become `h1`–`h6`. For a status change that should not steal focus — 'Episode downloaded' — wrap the status text in `Semantics(liveRegion: true)`; when its content updates, Android and iOS make a polite announcement, and `SnackBar` already does this. For announcements with no widget to anchor them, `SemanticsService.sendAnnouncement(View.of(context), message, textDirection)` replaced the deprecated `announce`, but Android has deprecated announcement events, so check `MediaQuery.supportsAnnounceOf(context)` and prefer a live region.
code
dart · 19 linesimport 'package:flutter/material.dart';
class EpisodeSections extends StatelessWidget {
const EpisodeSections({super.key, required this.downloadStatus});
final String downloadStatus; // e.g. 'Downloading 40%' then 'Episode downloaded'
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Semantics(headingLevel: 2, child: const Text('Chapters')),
// Updates are announced politely without moving focus.
Semantics(liveRegion: true, child: Text(downloadStatus)),
],
);
}
}go deeper
Recall that headings use headingLevel and that liveRegion: true makes changing status text speak without moving focus.
Explain the 3.47 change that made header a no-op on mobile, how a live region is announced politely, and why SnackBar needs no extra work.
Audit an app for header: true after upgrading, replace announce with live regions, and know Android's stance on announcement events.
Own the upgrade policy for accessibility breaking changes, so a silent behaviour change like header is caught by tests rather than users.
## Headings: why screen-reader users need them Sighted users skim a screen by its visual hierarchy. Screen-reader users skim by **headings**: VoiceOver's rotor and TalkBack's reading controls can jump from heading to heading, skipping the content in between. A podcast episode page with "Show notes", "Chapters" and "Related episodes" titles is quick to navigate only if those titles are exposed as headings in Flutter's **semantics tree**. ## header versus headingLevel in Flutter 3.47 Flutter used to mark headings with the boolean `header` property. The Flutter 3.47 breaking-change note changed that: - `header` **now behaves as a no-op on iOS and Android.** It stays in the API, but sets nothing on those platforms. The reason given: on the platforms, "header" often means a banner or app bar, not a section heading. - `headingLevel` with a value **greater than 0** now maps to `View.setHeading(true)` on Android and the `UIAccessibilityTraitHeader` trait on iOS, and on iOS 13+ to `accessibilityHeadingLevel`. - On the **web**, levels 1 to 6 map to `<h1>` through `<h6>`; `SemanticsProperties` asserts the level is between 1 and 6. Migration is mechanical: `Semantics(header: true, child: Text('Chapters'))` becomes `Semantics(headingLevel: 2, child: Text('Chapters'))`. Code that still uses `header: true` compiles and silently loses its headings on mobile, which makes it a good audit target. ## Live regions: announcing without moving focus Some changes happen away from where the user is: a download finishes, a sleep timer starts, a sync fails. Moving accessibility focus to the message would interrupt what the user was doing. A **live region** tells the platform that updates to a node matter: - `Semantics(liveRegion: true, child: Text(status))` marks the node. - On Android and iOS, an update to the node triggers a **polite** announcement even though it does not have accessibility focus. - A polite announcement may be dropped if the screen reader is already speaking, for example reading the focused item. - `SnackBar` sets `liveRegion: true` itself, so a snack bar message is announced without extra work. Put the live region on a widget that stays mounted and whose text changes. A node whose content never changes gives the platform nothing to announce. ## SemanticsService announcements For messages with no visible widget, `SemanticsService` can send an announcement event: | API | Status in 3.47 | |---|---| | `SemanticsService.announce(message, textDirection)` | deprecated after v3.35.0-0.1.pre; incompatible with multiple windows | | `SemanticsService.sendAnnouncement(view, message, textDirection)` | the replacement; pass `View.of(context)` | | `assertiveness: Assertiveness.assertive` | only the web engine honours it today | Two caveats from the API docs: 1. **Android has deprecated announcement events**, because they force TalkBack to clear its speech queue. Flutter's docs recommend triggering announcements implicitly through `Semantics`, such as a live region. 2. Not every platform supports announcements; check `MediaQuery.supportsAnnounceOf(context)` before relying on one. ## Choosing the tool - Text that **titles a section** → `headingLevel`. - Visible text that **changes** and matters → `liveRegion: true`. - A **transient** message → a `SnackBar`, which is already a live region. - No visible text at all → `sendAnnouncement`, as a last resort, knowing Android may not speak it. ## Verifying `SemanticsDebugger` shows node labels but not every flag, so confirm headings with the screen reader's heading navigation, or in a widget test by reading `tester.getSemantics(finder).headingLevel`, and trigger the status change while another item has focus to hear whether the live region speaks.
- Why did Flutter make Semantics header a no-op on iOS and Android instead of keeping both properties?On those platforms the heading trait means a section heading, while 'header' often means an app bar or banner, so a boolean `header` mapped to the heading trait caused confusion. Flutter moved heading behaviour to `headingLevel`, which also carries a level on iOS 13+ and the web, and kept `header` in the API as a no-op for possible future platform support.
- Why can a Flutter live region stay silent after its text changes?Live-region announcements are polite, so the platform may drop one while the screen reader is already speaking, for example reading the focused item. The region also needs a real change in its content: rebuilding with the same text gives the platform nothing new to announce. Test by triggering the change while accessibility focus rests on another element.
saying these in an interview costs you the question
- Semantics(header: true) still marks headings for VoiceOver in Flutter 3.47
- A live region moves accessibility focus to the updated text
- SemanticsService.announce is the recommended way to report status on Android
- headingLevel accepts any positive integer on every platform
- Assertiveness.assertive interrupts the screen reader on every platform