Skip to content

LayrzSlider

Kenny Mochizuki Escalona edited this page Aug 27, 2026 · 1 revision

LayrzSlider

A Material-free, hand-rolled single-value slider control for picking a numeric value from a continuous or quantised range by dragging, tapping, or using the keyboard.

Metadata Mirrors: None (new component for layrz_ui) Phase: M3 (Inputs) Domain: Inputs Primitive: GestureDetector + CustomPaint (via LayrzSliderPainter) Status: Shipped.


Overview

There is no RawSlider in the Flutter SDK, and Material's Slider is unusable in this Material-free codebase, so LayrzSlider builds its track, thumb, hit-testing, drag handling, quantisation, and keyboard interaction entirely from scratch on GestureDetector and CustomPaint.

LayrzSlider does not compose LayrzTextInput and does not compose LayrzInputChrome. Per decision D63, a slider is a control with a label, not a bordered field — the same excluded category as LayrzCheckboxInput, LayrzSwitchInput, and LayrzRadioInput. Those three controls own their visual state directly instead of wrapping another input, and LayrzSlider follows the same structural template: a StatefulWidget holding a mutable Set<WidgetState> for interaction-state colour resolution, with GestureDetectorFocusSemantics nesting. This is the single most misunderstood thing about this widget family — do not reach for LayrzInputChrome or expect a bordered field shape here.


API Structure

class LayrzSlider extends StatefulWidget {
  /// The label text displayed above the slider track.
  ///
  /// If null, no label row is rendered above the track.
  final String? labelText;

  /// The current value of the slider.
  ///
  /// Always treated as clamped to `[min, max]` and, when [divisions] is set,
  /// quantised to the nearest step before being painted or announced.
  final double value;

  /// The minimum value the slider can represent.
  ///
  /// Must be less than or equal to [max]. When equal to [max], the slider
  /// represents a single degenerate point and the thumb is fixed at the
  /// track's start. Defaults to `0.0`.
  final double min;

  /// The maximum value the slider can represent.
  ///
  /// Must be greater than or equal to [min]. Defaults to `100.0`.
  final double max;

  /// The number of discrete steps the value snaps to, or `null` for a
  /// continuous range.
  ///
  /// When set to `2` or greater, the track is divided into this many equal
  /// steps and the value snaps to the nearest one. Values of `null`, `0`, or
  /// `1` are all treated as "no quantisation" — a single division has no
  /// intermediate step to snap to.
  final int? divisions;

  /// Callback fired when the user drags, taps, or uses the keyboard to change
  /// the value.
  ///
  /// The callback receives the new value already clamped to `[min, max]` and
  /// quantised per [divisions]. Fires continuously during a drag (not only on
  /// release). If null, the slider is disabled and does not respond to user
  /// input.
  final ValueChanged<double>? onChanged;

  /// Whether to show the current value as a label above the track.
  ///
  /// Defaults to `true`. A visible current value is a required affordance for
  /// this control, since the fixed-size thumb (per D15) cannot itself
  /// confirm that a drag registered.
  final bool showValueLabel;

  /// Formats [value] for display in the value label and in the slider's
  /// semantics announcement.
  ///
  /// Defaults to a formatter that renders whole numbers without a decimal
  /// point and otherwise shows up to two decimal places.
  final String Function(double value)? valueFormatter;

  /// The focus node for the slider control.
  ///
  /// If null, a focus node is created and disposed by the widget.
  /// Caller-supplied focus nodes are never disposed.
  final FocusNode? focusNode;

  /// The list of error messages to display below the control. Defaults to
  /// an empty list.
  final List<String> errors;

  /// Whether to hide the error message block. Defaults to `false`.
  final bool hideDetails;

  /// Whether the slider is disabled (read-only and non-interactive).
  /// Defaults to `false`.
  final bool disabled;

  /// Whether this slider should request focus as soon as it is inserted into
  /// the widget tree, provided no other node is currently focused. Defaults
  /// to `false`.
  final bool autofocus;

  const LayrzSlider({
    super.key,
    this.labelText,
    required this.value,
    this.min = 0.0,
    this.max = 100.0,
    this.divisions,
    this.onChanged,
    this.showValueLabel = true,
    this.valueFormatter,
    this.focusNode,
    this.errors = const [],
    this.hideDetails = false,
    this.disabled = false,
    this.autofocus = false,
  });
}

Usage Examples

Continuous Slider

LayrzSlider(
  labelText: 'Volume',
  value: _volume,
  onChanged: (v) => setState(() => _volume = v),
)

Quantised Slider (divisions)

LayrzSlider(
  labelText: 'Rating',
  value: _rating,
  min: 0,
  max: 100,
  divisions: 4,
  onChanged: (v) => setState(() => _rating = v),
)

Disabled

LayrzSlider(
  labelText: 'Locked value',
  value: 40,
  disabled: true,
)

Passing disabled: true or leaving onChanged null both disable the control; the widget treats _isDisabled as widget.disabled || widget.onChanged == null.

With Errors

LayrzSlider(
  labelText: 'Budget allocation',
  value: _allocation,
  errors: const ['Must be at least 10%'],
  onChanged: (v) => setState(() => _allocation = v),
)

Behavior

divisions Quantisation

When divisions is 2 or greater, the track's [min, max] range is divided into that many equal steps, and any value — from a drag, a tap, or a keyboard step — snaps to the nearest one via quantizeLayrzSliderValue.

Worked example: min: 0, max: 100, divisions: 4 produces five reachable values, one step apart: 0, 25, 50, 75, 100. A drag landing at, say, 62 snaps to 50 (nearest of the two neighbouring steps).

divisions of null, 0, or 1 are all treated as "no quantisation" — a single division has no intermediate step to snap to, so the slider behaves as a continuous range, only clamped to [min, max].

Keyboard Interaction

Once focused, LayrzSlider responds to:

  • Left / Down — decrease the value by one step
  • Right / Up — increase the value by one step
  • Home — jump to min
  • End — jump to max

All four are suppressed when the slider is disabled. The step size used by Left/Right/Up/Down is (max - min) / divisions when divisions is set (2 or greater), or 1% of the total range otherwise — a conventional default that gives roughly 100 keyboard steps across the full range of a continuous slider.

Live Value Feedback and Label Placement

Per decision D15, the thumb never changes size on hover, press, or focus — only colour, border colour, and shadow may vary. Since the thumb cannot grow to confirm that a drag registered, LayrzSlider instead shows the current value in a label, driven by showValueLabel (defaults to visible), that updates live on every drag delta rather than only on release.

This value label is placed above the track, deliberately — not overlapping or below it — so that a dragging finger on a touch device does not cover the very feedback it is meant to read.

Hit-Slop

The invisible gesture-detection region is 44px tall, while the painted track and thumb occupy roughly 20px (an 8px thumb radius doubled, on a 4px track). A touch slightly above or below the thin painted line still registers, because hit-testing is not limited to the drawn pixels.

This is compatible with D15: D15 forbids visual geometry changes tied to interaction state (e.g., a thumb that visibly grows on hover), not an invisible hit target that is simply larger than what is drawn. The painted geometry — track thickness, thumb radius, border width — stays constant across every interaction state; only the surrounding hit region is oversized relative to it, and that oversizing itself never changes with state.

Accessibility

LayrzSlider exposes a Semantics node with slider: true, plus:

  • value — the current value, formatted via valueFormatter (or the default formatter)
  • increasedValue / decreasedValue — the value one step above/below the current one, clamped to [min, max]
  • onIncrease / onDecrease — actions a screen reader can invoke in place of a drag or keyboard press, wired to the same step logic as the arrow keys

A screen reader user can therefore operate the entire control through the increase/decrease semantics actions, without a pointer.

The value label rendered above the track is wrapped in ExcludeSemantics — its content is already announced through the Semantics node's own value field, so it is deliberately excluded to avoid a duplicate announcement of the same number. The optional labelText row above it is likewise excluded from semantics directly (it is instead passed as the Semantics.label), for the same reason.


v1 Scope Limits

The following are explicit non-goals for this v1, not omissions:

  • Single value only. There is no range/dual-thumb variant. A future LayrzRangeSlider is the intended home for a two-thumb range control, following the same precedent as the LayrzDatePicker / LayrzDateRangePicker split (one component for a single value, a separate component for a range).
  • No tick marks. The track paints only the filled/unfilled segments and the thumb — no discrete tick glyphs along the track, even when divisions is set.
  • No draggable value bubble. The current value is shown as a static label above the track (see Live Value Feedback above), not as a bubble that tracks the thumb's horizontal position during a drag.

Design Notes

  • Interaction states never change geometry (D15). Hover, press, focus, and disabled states vary colour, border colour, and shadow only. Track thickness, thumb radius, and thumb border width are identical across every state.
  • Light mode only (D7). Like the rest of layrz_ui, LayrzSlider targets a single light palette; there is no dark-mode variant of its colours.
  • State precedence. Colours resolve in the same order as LayrzCheckboxInput / LayrzSwitchInput: disabled > error > pressed (including an active drag) > hover/focused > default.

Related


Last updated: 2026-08-27 Status: Shipped

Clone this wiki locally