An animated toggle switch widget with on/off states.
The slider thumb animates between positions when the state changes: a toggle puts
the widget into :animating_on or :animating_off and schedules a :tick
message every 30 ms, each moving :slider_position by 0.25 until it reaches
1.0 or 0.0 and the state settles at :on or :off. :on_change fires only
once the animation has finished.
Component tag
Tag :switch, built by Drafter.App as {:switch, opts}:
switch(opts)
switch(value, opts)The two-argument form puts value into opts under :value; there is no
positional prop. :enabled and :on_change go through the binding layer, so
passing bind: :some_key reads the current state from that app-state key and
writes the new one back when the switch settles. :width and :height
default to the rect the parent allocated.
Options
:enabled-boolean/0initial state,truefor on. Defaultfalse.:bind- app-state key atom for two-way binding of the on/off state. Default: none. With it set,:enabledis read from that key of the app state instead of fromopts.:label-String.t/0drawn to the left of the switch track, ornil. Defaultnil.:on_change- the app callback name fired once the animation settles, with the new boolean as its data. Defaultnil. Through the element this is set to the one-argument function the framework builds, which the switch passes on as a callback name rather than calling.:size-:normal | :small | :compact. Default:normal, an 8-column track with a 4-column thumb;:smallis 6 and 2,:compactis 4 and 2. Any other value is treated as:normal.:width-pos_integer/0. Default12when mounting directly, and the width ofopts[:__rect__]through the element. Held on the state and never read byrender/2, which uses the rect it is given.:height-pos_integer/0. Default1when mounting directly, and the height ofopts[:__rect__]through the element. Held on the state and never read byrender/2.:on_color-{r, g, b}for the thumb when on. Defaultnil, which uses{100, 200, 100}. Read bymount/1only — theswitch/1element does not forward it.:off_color-{r, g, b}for the thumb when off. Defaultnil, which uses{150, 150, 150}. Read bymount/1only — theswitch/1element does not forward it.:focused-boolean/0read bymount/1. Defaultfalse.:hovered-boolean/0read bymount/1. Defaultfalse.
update/2 applies :label, :on_change, :width, :height, :size and
:enabled, and silently drops every other key, so :focused, :hovered,
:on_color and :off_color are mount-only. Through the component tree
update_props_from_mount/3 narrows that further to :on_change, :label and
:size, plus :enabled only when :bind is set and the bound value differs
from the current one.
Key bindings
enter,space- toggleright- turn onleft- turn off
A mouse up toggles and also focuses the switch.
Widget value
Drafter.get_widget_value/1 returns true while the state is :on and false
while it is :off. Mid-animation the state is :animating_on or
:animating_off, which reads as nil.
Usage
switch(enabled: true, label: "Dark mode", on_change: :toggle_theme)
Summary
Functions
The component tag this widget registers under.
Builds the props map for a {:switch, opts} element.
Handles the switch's own events, replacing the dispatch use Drafter.Widget
would otherwise generate.
Builds the widget state from props.
The number of rows the element asks for: always 3. There is no :height
override.
Draws the switch into rect, returning exactly rect.height strips.
Callback implementation for Drafter.Widget.unmount/1.
Folds props into widget_state, applying :label, :on_change, :width,
:height and :size in every case and dropping every other key.
Narrows a re-render to :on_change, :label and :size.
Types
@type rgb() :: {0..255, 0..255, 0..255}
@type t() :: %Drafter.Widget.Switch{ focused: boolean(), height: pos_integer(), hovered: boolean(), label: String.t() | nil, off_color: rgb() | nil, on_change: term(), on_color: rgb() | nil, size: :normal | :small | :compact, slider_position: float(), state: :on | :off | :animating_on | :animating_off, width: pos_integer() }
Functions
@spec component_tag() :: :switch
The component tag this widget registers under.
iex> Drafter.Widget.Switch.component_tag()
:switch
@spec from_component_opts( term(), keyword() ) :: Drafter.Widget.props()
Builds the props map for a {:switch, opts} element.
The positional argument is ignored. :enabled is the bound value for that key, so bind: :key reads it from
opts[:__app_state__] and plain enabled: is used otherwise, defaulting to
false. :on_change is the binding's writer, a one-argument function or nil.
:width and :height fall back to opts[:__rect__], itself defaulting to
%{width: 12, height: 1}. :on_color and :off_color are not forwarded.
iex> props = Drafter.Widget.Switch.from_component_opts(nil, label: "Dark")
iex> {props.enabled, props.label, props.size, props.width, props.height, props.on_change}
{false, "Dark", :normal, 12, 1, nil}
iex> opts = [bind: :dark_mode, __app_state__: %{dark_mode: true}]
iex> Drafter.Widget.Switch.from_component_opts(nil, opts).enabled
true
Handles the switch's own events, replacing the dispatch use Drafter.Widget
would otherwise generate.
Recognised events:
:activate,{:key, :enter},{:key, :" "}- toggle{:key, :right}- turn on;{:key, :left}- turn off{:mouse, %{type: :mouse_up}}- focus and toggle:hover/:unhover- set or clear:hovered{:focus}/{:blur}- set or clear:focused:tick- advance the animation
Starting an animation returns {:ok, state} and schedules the next :tick on
the calling process. A toggle that has nothing to do — turning on a switch that
is already on, or any event during an animation other than :tick — returns
{:noreply, state}, and so does every unrecognised event.
iex> state = Drafter.Widget.Switch.mount(%{})
iex> {:ok, toggled} = Drafter.Widget.Switch.handle_event({:key, :enter}, state)
iex> {toggled.state, toggled.slider_position}
{:animating_on, 0.0}
iex> state = Drafter.Widget.Switch.mount(%{})
iex> Drafter.Widget.Switch.handle_event({:key, :left}, state)
...> |> elem(0)
:noreply
iex> state = Drafter.Widget.Switch.mount(%{})
iex> {:ok, hovered} = Drafter.Widget.Switch.handle_event(:hover, state)
iex> hovered.hovered
true
@spec mount(Drafter.Widget.props()) :: t()
Builds the widget state from props.
:enabled decides both :state (:on or :off) and :slider_position
(1.0 or 0.0); there is no way to mount mid-animation.
iex> state = Drafter.Widget.Switch.mount(%{})
iex> {state.state, state.slider_position, state.size, state.width, state.height}
{:off, 0.0, :normal, 12, 1}
iex> state = Drafter.Widget.Switch.mount(%{enabled: true, label: "Dark mode"})
iex> {state.state, state.slider_position, state.label}
{:on, 1.0, "Dark mode"}
The number of rows the element asks for: always 3. There is no :height
override.
iex> Drafter.Widget.Switch.preferred_height(nil, height: 1)
3
@spec render(t() | Drafter.Widget.props(), Drafter.Widget.rect()) :: [ Drafter.Draw.Strip.t() ]
Draws the switch into rect, returning exactly rect.height strips.
state may be a plain props map, in which case it is passed through mount/1
first. The first strip holds the label followed by the track, padded with spaces
to rect.width; the rest are blank. The thumb is lightened while hovered, and by
half as much while focused.
Callback implementation for Drafter.Widget.unmount/1.
@spec update(Drafter.Widget.props(), t()) :: t()
Folds props into widget_state, applying :label, :on_change, :width,
:height and :size in every case and dropping every other key.
:enabled is compared with the current state rather than assigned:
- settled at
:onor:off— an:enabledthat differs jumps straight to the other state with no animation; one that matches changes nothing. Absent, it defaults to the current state, so the switch stays put. - mid-animation — an
:enabledthat agrees with where the animation is heading lets it finish; one that disagrees cancels it and snaps to that state. Absent, it defaults to the animation's destination, so the animation continues.
Examples
iex> state = Drafter.Widget.Switch.mount(%{})
iex> updated = Drafter.Widget.Switch.update(%{enabled: true, label: "On"}, state)
iex> {updated.state, updated.slider_position, updated.label}
{:on, 1.0, "On"}
iex> state = Drafter.Widget.Switch.mount(%{enabled: true})
iex> updated = Drafter.Widget.Switch.update(%{label: "Kept"}, state)
iex> {updated.state, updated.label}
{:on, "Kept"}
iex> state = %{Drafter.Widget.Switch.mount(%{}) | state: :animating_on, slider_position: 0.5}
iex> updated = Drafter.Widget.Switch.update(%{enabled: false}, state)
iex> {updated.state, updated.slider_position}
{:off, 0.0}
@spec update_props_from_mount(Drafter.Widget.props(), t(), keyword()) :: Drafter.Widget.props()
Narrows a re-render to :on_change, :label and :size.
:enabled is added only when opts carries :bind and the bound value differs
from the widget's current state, so an unbound switch keeps whatever the user
toggled it to and :width and :height are mount-only.
iex> props = Drafter.Widget.Switch.from_component_opts(nil, label: "Dark")
iex> state = Drafter.Widget.Switch.mount(props)
iex> Drafter.Widget.Switch.update_props_from_mount(props, state, []) |> Map.keys() |> Enum.sort()
[:label, :on_change, :size]
iex> opts = [bind: :dark_mode, __app_state__: %{dark_mode: true}]
iex> props = Drafter.Widget.Switch.from_component_opts(nil, opts)
iex> state = Drafter.Widget.Switch.mount(%{enabled: false})
iex> Drafter.Widget.Switch.update_props_from_mount(props, state, opts).enabled
true