skip to content

How do you make a custom-painted volume slider in a Flutter podcast player adjustable by TalkBack and VoiceOver users?

level: seniorimportance: should knowfreq 26%

answer

  1. CustomPaint exposes nothing by itself
  2. slider: true plus a label
  3. value, increasedValue, decreasedValue
  4. onIncrease and onDecrease
  5. CustomSemanticsAction for extra commands

basics

~10 s

Wrap 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 s

A `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 lines
dart
import '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

for a junior

Recall that CustomPaint is invisible to screen readers and that a Semantics wrapper with slider, label and value makes it discoverable.

for a middle

Explain the increasedValue and onIncrease contract, how VoiceOver and TalkBack trigger adjust actions, and why value text should be human-readable.

for a senior

Build a custom control whose semantics are part of its design, with clamped steps, custom actions for extra commands and matchesSemantics tests.

for a principal

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