How do you make a custom-painted volume slider in a Flutter podcast player adjustable by TalkBack and VoiceOver users?
answer
- CustomPaint exposes nothing by itself
- slider: true plus a label
- value, increasedValue, decreasedValue
- onIncrease and onDecrease
- CustomSemanticsAction for extra commands
basics
~10 sWrap the painted control in Semantics(slider: true, label: 'Volume', value, increasedValue, decreasedValue, onIncrease, onDecrease). Screen readers then announce it as an adjustable slider, and their increase and decrease gestures step the volume.
solid answer
~40 sA `CustomPaint` driven by a `GestureDetector` puts nothing in the semantics tree, so TalkBack and VoiceOver cannot find the control or drag it. Wrap it in `Semantics(slider: true, label: 'Volume', value: '40%', increasedValue: '50%', decreasedValue: '30%', onIncrease: ..., onDecrease: ...)`. The screen reader announces 'Volume, 40%, slider'; a one-finger VoiceOver swipe up or down, or TalkBack's volume keys, fires `onIncrease`/`onDecrease`, which must move the value to exactly the `increasedValue`/`decreasedValue` you advertised. Rebuild with the new strings so the next announcement is correct. Use `excludeSemantics: true` if painted children would add noise. Commands a slider gesture cannot express, such as 'Mute', go in `customSemanticsActions: {CustomSemanticsAction(label: 'Mute'): mute}`, which TalkBack lists in its actions menu and VoiceOver in its rotor. If the stock `Slider` fits, prefer it and its `semanticFormatterCallback`.
code
dart · 47 linesimport 'dart:math' as math;
import 'package:flutter/widgets.dart';
class VolumeArc extends StatelessWidget {
const VolumeArc({super.key, required this.volume, required this.onChanged, required this.onMute});
final int volume; // 0..100, in steps of 10
final ValueChanged<int> onChanged;
final VoidCallback onMute;
String _pct(int v) => '$v%';
@override
Widget build(BuildContext context) {
final int up = math.min(volume + 10, 100);
final int down = math.max(volume - 10, 0);
return Semantics(
slider: true,
label: 'Volume',
value: _pct(volume),
increasedValue: _pct(up),
decreasedValue: _pct(down),
onIncrease: () => onChanged(up),
onDecrease: () => onChanged(down),
customSemanticsActions: <CustomSemanticsAction, VoidCallback>{
const CustomSemanticsAction(label: 'Mute'): onMute,
},
excludeSemantics: true,
child: CustomPaint(size: const Size(160, 160), painter: _ArcPainter(volume)),
);
}
}
class _ArcPainter extends CustomPainter {
_ArcPainter(this.volume);
final int volume;
@override
void paint(Canvas canvas, Size size) {
// Arc drawing omitted.
}
@override
bool shouldRepaint(_ArcPainter oldDelegate) => oldDelegate.volume != volume;
}go deeper
Recall that CustomPaint is invisible to screen readers and that a Semantics wrapper with slider, label and value makes it discoverable.
Explain the increasedValue and onIncrease contract, how VoiceOver and TalkBack trigger adjust actions, and why value text should be human-readable.
Build a custom control whose semantics are part of its design, with clamped steps, custom actions for extra commands and matchesSemantics tests.
Decide when a design system may ship custom-painted controls at all, and require a semantics spec for each one before it is built.
## Why a painted control is invisible Flutter's screen-reader support comes from the **semantics tree**, built from annotations that widgets provide. Material's `Slider` annotates itself; a hand-drawn control does not. A volume arc drawn with `CustomPaint` and driven by a `GestureDetector` produces pixels and gestures but **no semantics node**, so: - TalkBack and VoiceOver skip it while the user swipes through the player. - Even if found, a drag gesture cannot be performed reliably with a screen reader on, because touches are interpreted as navigation. The fix is to describe the control with a `Semantics` widget, giving it a **role**, a **name**, a **value** and **adjust actions**. ## The Semantics properties for an adjustable control | Property | Purpose | Example | |---|---|---| | `slider: true` | role; the platform says "slider" and offers adjust gestures | — | | `label` | the control's name | `'Volume'` | | `value` | the current value as text | `'40%'` | | `increasedValue` | what the value becomes after one increase | `'50%'` | | `decreasedValue` | what the value becomes after one decrease | `'30%'` | | `onIncrease` / `onDecrease` | handlers for the platform's adjust actions | step by 10% | | `excludeSemantics: true` | drop descendant semantics under this node | hide painted tick labels | The API docs set two contracts: if `value` is set and `onIncrease` is provided, `increasedValue` must be provided too, and `onIncrease` **must set the value to `increasedValue`** (likewise for decrease). A screen reader may announce the advertised next value, so a handler that jumps by a different step makes the announcement wrong. ## How users trigger the actions Per the `onIncrease` documentation: - **VoiceOver:** swipe up with one finger to increase, down to decrease, while the slider has focus. - **TalkBack:** press the device's volume-up and volume-down keys while the slider has accessibility focus. For a volume control the TalkBack volume-key behaviour is a nice fit, but the handlers must still step the app's value, not the system volume. ## Step size and value text 1. Pick a step that matches the control's precision; ten steps of 10% is typical for volume. 2. Format values as the user should hear them: `'40%'`, not `'0.4'`. 3. Clamp at the ends: at 100%, either leave both `onIncrease` and `increasedValue` null, or keep `increasedValue` equal to the current value so the user hears that nothing changed. 4. Rebuild after each change so `value`, `increasedValue` and `decreasedValue` are recomputed. ## Custom actions for extra commands Some commands have no slider gesture: **Mute**, **Reset to 50%**. `customSemanticsActions` maps `CustomSemanticsAction(label: 'Mute')` to a callback. Per the API docs, Android presents them in the local context menu (TalkBack's actions menu) and iOS in the rotor. `CustomSemanticsAction.overridingAction(hint: ..., action: SemanticsAction.tap)` instead changes the hint spoken for a standard action. Localization and text direction are not applied to these labels automatically, so pass already-translated strings, and create the actions as `const` or cache them. Keep custom actions for real extra commands. Every one lengthens the list the user scrolls, and the primary adjustment must stay on `onIncrease`/`onDecrease` where users expect it. ## When not to build it Material's `Slider` already provides all of this, and its `semanticFormatterCallback` turns the raw `double` into the announced string. A custom semantics layer is warranted when the visual design cannot be reached by theming the stock widget — and then it is part of the control, not an afterthought. ## Verifying Turn on VoiceOver or TalkBack and adjust the control, check `SemanticsDebugger` shows one node labelled "Volume" with the current value, and in widget tests assert the node with `matchesSemantics(label: 'Volume', value: '40%', isSlider: true, hasIncreaseAction: true, hasDecreaseAction: true)`.
- Why must a Flutter onIncrease handler move the value to exactly increasedValue?The `Semantics` API documents it as a contract: `increasedValue` is what the value becomes after one increase, and screen readers may announce it. A handler that jumps by another step, or ignores the clamp, leaves the user hearing a value the control does not have, which is worse than no announcement.
- Where do Flutter CustomSemanticsAction labels appear for screen-reader users?They are exposed as custom accessibility actions: Android shows them in TalkBack's local context (actions) menu and iOS in the VoiceOver rotor. Selecting one calls the mapped callback. Use them for commands without a standard gesture, like Mute, not as a replacement for `onIncrease`/`onDecrease`.
The Semantics wrapper is like the braille and tactile markings on a physical volume knob: the knob works the same, but the markings say what it is, where it is set, and what one click up or down will do. A marking that says '50%' after one click while the knob actually jumps to 60% misleads the user.
saying these in an interview costs you the question
- A GestureDetector's drag handler makes the control usable with a screen reader
- Setting slider: true alone lets users adjust the value
- increasedValue is only a display hint and can differ from what onIncrease sets
- Custom actions should replace onIncrease and onDecrease on a slider
- The value should be announced as the raw double, such as 0.4