Sliders & Value Pickers
Coming from Buttons, let's move from "pick one of a few options" to "pick — or show — a value somewhere along a range."
Most controls here are built on ConfinedValueModel, which keeps a value clamped within a range. That's the same model a Behavior styles, so NativeTheme (Web, Desktop)/BasicTheme cover all of them.
You can further restrict values using a Marker and SnappingPolicy.
Which one do you actually want?
| If you need to... | Reach for |
|---|---|
| Pick one value along a range | Slider (bar) or CircularSlider (dial) |
| Pick a range — two bounding values | RangeSlider or CircularRangeSlider |
| Show a value, with no user interaction | ProgressBar |
| Pick a value as a star rating | StarRater |
| Step through a fixed, discrete set of values | SpinButton |
Straight vs. circular is purely presentation — pick whichever reads more naturally for what the value represents (a volume level suits a bar; a compass heading suits a dial).
Slider
Holds a strongly typed value within a specified range, and lets the user drag to change it — the straight-line version everyone already knows.
Holds a strongly typed value within a specified range, and lets the user drag to change it — the straight-line version everyone already knows. Give it a range by passing a ClosedRange or a ConfinedValueModel in the constructor, and it'll notify you whenever the value or the range itself changes.
- Horizontal
- Vertical
- Usage
package controls
//import io.nacular.doodle.controls.range.snap
import io.nacular.doodle.controls.range.Slider
import io.nacular.doodle.controls.range.alwaysSnap
import io.nacular.doodle.controls.range.evenMarker
import io.nacular.doodle.controls.range.mark
import io.nacular.doodle.controls.range.snapWithin
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.utils.Orientation.Vertical
import io.nacular.measured.units.Time.Companion.minutes
import io.nacular.measured.units.Time.Companion.seconds
import io.nacular.measured.units.times
//sampleStart
val horizontalSlider = Slider(0 * seconds .. 2 * minutes).apply {
suggestSize(Size(200, 15))
mark(15 * seconds, 1.5 * minutes)
snappingPolicy = snapWithin(5 * seconds)
// snappingPolicy = snap { to, from ->
// when {
// // ...
// else -> false
// }
// }
}
val verticalSlider = Slider(0 .. 100, orientation = Vertical).apply {
suggestSize(Size(15, 200))
marker = evenMarker(intermediateMarks = 8)
snappingPolicy = alwaysSnap()
}
//sampleEndEvery Slider variant on this page is strongly typed — build an Int slider, and it snaps to whole numbers automatically, with no extra setup. Want something more specific than "every integer" — a slider that only lands on 5, 10, or 25, say? Supply a Marker (the set of values to snap to) and a SnappingPolicy (how the handle attaches to them) to restrict the range further.
Non-linearity
A Slider's value doesn't have to map to its handle position in a straight line — supply a custom InvertibleFunction to bias the range however your data calls for (logarithmic, say).
package controls
import io.nacular.doodle.controls.range.InvertibleFunction
import io.nacular.doodle.controls.range.Slider
import kotlin.math.log
import kotlin.math.pow
//sampleStart
/**
* Logarithmic function and inverse https://www.desmos.com/calculator/qq59ey0bub
*/
private object LogFunction: InvertibleFunction {
override fun invoke (value: Float) = log((10f - 1) * value + 1, 10f)
override fun inverse(value: Float) = (10f.pow(value) - 1)/(10 - 1)
}
val logarithmicSlider = Slider(0.0 .. 1.0, function = LogFunction)
//sampleEnd
Animation
Want the handle to glide to a new value instead of jumping there? AbstractSliderBehavior and AbstractRangeSliderBehavior support handle-movement animations through the animateHandleMove parameter that decides how a handle travels to its new value. They also have hooks that report when the pointer has pressed or released in a way that moves the handles, which provide a clean moment to react visually to a click.
- Music Player
- PlayerSliderBehavior
package io.nacular.doodle.docs.apps
import io.nacular.doodle.animation.Animator
import io.nacular.doodle.animation.after
import io.nacular.doodle.animation.invoke
import io.nacular.doodle.animation.transition.cubicBezier
import io.nacular.doodle.animation.transition.easeInOutCubic
import io.nacular.doodle.animation.tweenFloat
import io.nacular.doodle.controls.range.Slider
import io.nacular.doodle.controls.theme.range.AbstractSliderBehavior
import io.nacular.doodle.drawing.Canvas
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.Paint
import io.nacular.doodle.drawing.opacity
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.focus.FocusManager
import io.nacular.doodle.geometry.Circle
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.inset
import io.nacular.doodle.system.Cursor.Companion.Pointer
import io.nacular.doodle.utils.Cancelable
import io.nacular.doodle.utils.Completable
import io.nacular.doodle.utils.NoOpCompletable
import io.nacular.doodle.utils.Orientation.Horizontal
import io.nacular.doodle.utils.autoCanceling
import io.nacular.doodle.utils.lerp
import io.nacular.measured.units.Time.Companion.milliseconds
import io.nacular.measured.units.Time.Companion.minutes
import io.nacular.measured.units.Time.Companion.seconds
import io.nacular.measured.units.times
import kotlin.math.max
import kotlin.math.min
//sampleStart
fun usage(animate: Animator, focusManager: FocusManager) {
val seekEase = tweenFloat(cubicBezier(0.47f, 1.64f, 0.41f, 0.8f), 300 * milliseconds)
val volumeEase = tweenFloat(easeInOutCubic, 150 * milliseconds)
val seek = Slider(0 * seconds .. 3 * minutes + 20 * seconds, value = 32 * seconds).apply {
behavior = PlayerSliderBehavior(
focusManager = focusManager,
containsPoint = { slider, point -> point in slider.bounds.inset(-15.0) },
animateKnobMove = { animate(0f to 1f, seekEase, it) }, // expand hit detection by 15px
animateKnobSize = { animate(0f to 1f, after(24 * milliseconds, seekEase), it) }, // delay size animation
grooveThicknessRatio = 0.5f,
)
}
val volume = Slider(0f..1f, 0.3f).apply {
behavior = PlayerSliderBehavior(
focusManager = focusManager,
animateKnobMove = { animate(0f to 1f, volumeEase, it) },
grooveThicknessRatio = 1f / 6
)
}
}
/**
* Custom Behavior used for simple music player demo.
*
* @property barFill used to render the slider's entire bar
* @property knobFill used to render the slider's handle
* @property rangeFill used to render the section from `0` to `value` on the slider's bar
* @param focusManager allows focus management
* @property containsPoint allows control of hit detection
* @param animateKnobMove controls how the knob animates as it moves
* @property animateKnobSize controls how the knob animates as it changes size
* @param grooveThicknessRatio how thick the bar is relative to the slider's size
*/
class PlayerSliderBehavior<T>(
private val barFill : Paint = White.paint opacity 0.38f,
private val knobFill : Paint = White.paint,
private val rangeFill : Paint? = knobFill,
private val focusManager : FocusManager? = null,
private val containsPoint : (Slider<T>, Point) -> Boolean = { slider, point -> point in slider.bounds },
private val animateKnobMove : (block: (progress: Float) -> Unit) -> Completable = { NoOpCompletable.also { it(1f) } },
private val animateKnobSize : (block: (progress: Float) -> Unit) -> Completable = { NoOpCompletable.also { it(0f) } },
grooveThicknessRatio: Float = 0.6f,
): AbstractSliderBehavior<T>(focusManager, animateKnobMove) where T: Comparable<T> {
/**
* Tracks animation for a given Slider, since this behavior
* could be used with multiple sliders at once.
*/
private class AnimationInfo(var progress: Float) {
var started = false
var animation: Cancelable? by autoCanceling(null)
}
private val grooveThicknessRatio = grooveThicknessRatio.coerceIn(0f..1f)
/** Tracks ongoing handle size animations for Sliders */
private val handleSizeAnimations = mutableMapOf<Slider<T>, AnimationInfo>()
/** Allows overriding of hit detection */
override fun contains(view: Slider<T>, point: Point) = containsPoint(view, point)
/** Enables shadows to show beyond bounds */
override fun clipCanvasToBounds(view: Slider<T>) = false
/** Initiates custom animation for knob size if applicable */
override fun handlePressed(slider: Slider<T>) {
handleSizeAnimations.getOrPut(slider) { AnimationInfo(0f) }.apply {
animation = animateKnobSize {
started = true
progress = it
slider.rerender()
}.apply { canceled += {
started = false
} }
}
}
/** Reverses custom animation for knob size if applicable */
override fun handleReleased(slider: Slider<T>) {
handleSizeAnimations[slider]?.apply {
when {
started -> animation = animateKnobSize {
progress = 1 - it
slider.rerender()
}.apply {
completed += { handleSizeAnimations.remove(slider) }
}
else -> {
animation?.cancel()
handleSizeAnimations.remove(slider)
}
}
}
}
override fun install(view: Slider<T>) {
super.install(view)
view.cursor = Pointer
}
/** Renders a Slider, with support for both horizontal and vertical orientation for completeness. */
override fun render(view: Slider<T>, canvas: Canvas) {
val orientation = view.orientation
val handleSize = handleSize(view)
val offset = handleSize / 2
val grooveInset = (1 - grooveThicknessRatio) * when (orientation) {
Horizontal -> view.height
else -> view.width
}
val grooveRect = when (orientation) {
Horizontal -> Rectangle(offset, grooveInset / 2, max(0.0, view.width - handleSize ), max(0.0, view.height - grooveInset))
else -> Rectangle(grooveInset / 2, offset, max(0.0, view.width - grooveInset), max(0.0, view.height - handleSize ))
}
val rangeRect = rangeFill?.let {
val handlePosition = handlePosition(view)
when (orientation) {
Horizontal -> grooveRect.run { Rectangle(x, y, handlePosition, height ) }
else -> grooveRect.run { Rectangle(x, handlePosition, width, view.height - handlePosition - handleSize / 2) }
}
}
val grooveRadius = min(grooveRect.width, grooveRect.height) / 2
canvas.rect(grooveRect, grooveRadius, barFill)
rangeRect?.let { canvas.rect(rangeRect, grooveRadius, rangeFill) }
canvas.outerShadow(vertical = 4.0, blurRadius = 8.0, color = Black opacity 0.4f) {
val handleRect = handleBounds(view)
val radius = min(handleRect.width, handleRect.height) / 2
canvas.circle(
Circle(handleRect.center, radius * lerp(1f, 3f, handleSizeAnimations[view]?.progress ?: 0f)),
knobFill
)
}
}
}
//sampleEndOne thing worth knowing up front: only the handle's travel is animated. The slider's value itself still updates instantly.
CircularSlider
The same idea as Slider, wrapped around a circle instead of a line — a natural fit whenever a dial reads more clearly than a bar.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.range.CircularSlider
import io.nacular.doodle.geometry.Size
//sampleStart
val circularSlider = CircularSlider(1 .. 100).apply {
suggestSize(Size(100, 100))
// marker = evenMarker(10)
// snappingPolicy = snapWithin( 3)
}
//sampleEndRangeSlider
Holds a strongly typed inner range within a specified outer range, and lets the user drag either end to change it — think "select a price range" rather than "pick one number." Same constructor pattern as Slider: pass ClosedRanges or a ConfinedRangeModel, and the same Marker/SnappingPolicy combination works here too, for confining either end to a predefined set of values.
- Demo
- Vertical
- Usage
package controls
import io.nacular.doodle.controls.range.RangeSlider
import io.nacular.doodle.controls.range.mark
import io.nacular.doodle.controls.range.snapWithin
import io.nacular.doodle.geometry.Size
//sampleStart
val rangeSlider = RangeSlider(value = 10 .. 30, limits = 1 .. 100).apply {
suggestSize(Size(100))
mark(15, 25)
snappingPolicy = snapWithin(5)
}
//sampleEndCircularRangeSlider
RangeSlider, bent into a circle — the range-picking counterpart to CircularSlider.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.range.CircularRangeSlider
import io.nacular.doodle.controls.range.mark
import io.nacular.doodle.controls.range.snapWithin
import io.nacular.doodle.geometry.Size
//sampleStart
val circularRangeSlider = CircularRangeSlider(value = 10 .. 30, limits = 1 .. 100).apply {
suggestSize(Size(100, 100))
mark(15, 25)
snappingPolicy = snapWithin(5)
}
//sampleEndProgressBar
The read-only sibling in this family — shows a value within a range, but nothing the user can drag. Reach for this whenever you're reporting progress rather than asking for input. Same constructor pattern as the rest of the family: a ClosedRange or ConfinedValueModel, with change notifications built in.
- Horizontal
- Vertical
- Usage
package controls
import io.nacular.doodle.controls.ProgressBar
import io.nacular.doodle.utils.Orientation.Vertical
//sampleStart
// creates bars with ranges form 0 - 100
val horizontal = ProgressBar( )
val vertical = ProgressBar(orientation = Vertical)
//sampleEndProgressBar is actually a specialization of the more general ProgressIndicator — reach for that one directly if a straight bar isn't the shape you want. A circular presentation is available via basicCircularProgressIndicatorBehavior.
- Demo
- Usage
package controls
import io.nacular.doodle.application.Application
import io.nacular.doodle.application.application
import io.nacular.doodle.controls.ProgressIndicator
import io.nacular.doodle.core.Display
import io.nacular.doodle.docs.apps.CircularProgressApp
import io.nacular.doodle.docs.utils.BlueColor
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.theme.Theme
import io.nacular.doodle.theme.ThemeManager
import io.nacular.doodle.theme.basic.BasicTheme.Companion.basicCircularProgressIndicatorBehavior
import org.kodein.di.instance
//sampleStart
fun main() {
// include basicCircularProgressIndicatorBehavior
application(modules = listOf(
basicCircularProgressIndicatorBehavior(foreground = BlueColor.paint, thickness = 5.0)
)) {
CircularProgressApp(display = instance(), themeManager = instance(), theme = instance())
}
}
class CircularProgressApp(display: Display, themeManager: ThemeManager, theme: Theme): Application {
init {
// Theme with basicCircularProgressIndicatorBehavior
themeManager.selected = theme
val circularProgressIndicator = object: ProgressIndicator() {
init {
suggestSize(Size(100))
progress = 0.25
accessibilityLabel = "circular progress widget"
}
}
// ...
}
override fun shutdown() {}
}
//sampleEndPathProgressIndicatorBehavior draws progress along an arbitrary Path instead — handy if progress should trace something other than a bar or a ring.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.ProgressIndicator
import io.nacular.doodle.docs.utils.BlueColor
import io.nacular.doodle.drawing.Color.Companion.Black
import io.nacular.doodle.drawing.Color.Companion.Lightgray
import io.nacular.doodle.drawing.LinearGradientPaint
import io.nacular.doodle.drawing.paint
import io.nacular.doodle.geometry.PathMetrics
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Point.Companion.Origin
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.geometry.path
import io.nacular.doodle.theme.PathProgressIndicatorBehavior
fun usage(pathMetrics: PathMetrics) {
//sampleStart
val pathProgress = object: ProgressIndicator() {
init {
suggestSize(Size(200, 100))
progress = 0.25
behavior = PathProgressIndicatorBehavior(
pathMetrics, // injected
path = path("M10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80")!!,
foreground = LinearGradientPaint(Black, BlueColor, Origin, Point(width, 0.0)),
foregroundThickness = 5.0,
background = Lightgray.paint,
backgroundThickness = 5.0
)
}
}
//sampleEnd
}Updating a ProgressBar's value by hand jumps abruptly. Drive it through by animate(...) from Animation instead, and each update glides to the new value rather than snapping.
StarRater
A value picker dressed as star ratings — a highly customizable way to display and collect a value somewhere in [0, n], just with a presentation everyone already recognizes from a hundred other apps.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.StarRater
import io.nacular.doodle.geometry.Rectangle
//sampleStart
val stars = StarRater(displayRounded = 0f, max = 5, /*pathMetrics = pathMetrics*/).apply {
suggestBounds(Rectangle(200, 50))
minSpacing = 15.0
innerRadiusRatio = 0.6f
starPointRounding = 0.25f
}
//sampleEndSpinButton
Steps through a set of values one at a time — think a "±" number stepper, or cycling through a list of named options. It works well when the options are few, or when the input is naturally incremental, like a quantity to purchase. The set isn't necessarily fixed, either: SpinButton takes a SpinButtonModel that behaves like an Iterator, so it can represent an open-ended list of values that don't all need to be loaded up front.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.spinbutton.SpinButton
//sampleStart
val spinButton1 = SpinButton(1..9 step 2)
val spinButton2 = SpinButton(listOf("Monday", "Tuesday", "Wednesday"))
//sampleEndMutableSpinButton lets you change its values directly, or by updating its underlying model — click into it and hit enter to commit an edit, or click away to cancel it.
- Demo
- Usage
package controls
import io.nacular.doodle.controls.ItemVisualizer
import io.nacular.doodle.controls.itemVisualizer
import io.nacular.doodle.controls.spinbutton.MutableListSpinButtonModel
import io.nacular.doodle.controls.spinbutton.MutableSpinButton
import io.nacular.doodle.controls.spinbutton.SpinButton
import io.nacular.doodle.controls.spinbutton.spinButtonEditor
import io.nacular.doodle.controls.text.Label
import io.nacular.doodle.drawing.Color.Companion.Transparent
import io.nacular.doodle.event.PointerListener.Companion.pressed
import io.nacular.doodle.focus.FocusManager
import io.nacular.doodle.layout.constraints.fill
import io.nacular.doodle.system.Cursor.Companion.Text
import io.nacular.doodle.theme.basic.spinbutton.SpinButtonTextEditOperation
import io.nacular.doodle.utils.ToStringIntEncoder
fun mutableSpinButton(focusManager: FocusManager) {
// custom visualizer to initiate edit on pointer press
val itemVisualizer: ItemVisualizer<Int, SpinButton<Int, *>> = itemVisualizer { item, previous, button ->
when (previous) {
is Label -> previous.also { it.text = "$item" }
else -> Label("$item").apply {
this.font = previous?.font
cursor = Text
foregroundColor = previous?.foregroundColor
backgroundColor = previous?.backgroundColor ?: Transparent
// Begin edit on pointer press
pointerFilter += pressed { event ->
(button as MutableSpinButton).startEditing()
event.consume()
}
}
}
}
//sampleStart
MutableSpinButton(
MutableListSpinButtonModel((1 .. 9).toMutableList()),
itemVisualizer
).apply {
cellAlignment = fill
editor = spinButtonEditor { spinButton, value, current ->
SpinButtonTextEditOperation(
focusManager,
ToStringIntEncoder,
spinButton,
value,
current
)
}
}
//sampleEnd
}Recap
Slider/CircularSliderpick one value;RangeSlider/CircularRangeSliderpick two, bounding a range.ProgressBaris the read-only member of the family — use it for reporting a value, not collecting one.StarRaterandSpinButtonare both value pickers wearing a more specific presentation — a rating, or a step-through list.- Reach for a circular variant when a dial genuinely reads more naturally than a bar; otherwise, the straight-line versions are the simpler default.
Next: on to Selection & Text for labels, text input, and picking from a list.