Drafter.Widget.Switch (drafter v0.3.1)

Copy Markdown View Source

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/0 initial state, true for on. Default false.
  • :bind - app-state key atom for two-way binding of the on/off state. Default: none. With it set, :enabled is read from that key of the app state instead of from opts.
  • :label - String.t/0 drawn to the left of the switch track, or nil. Default nil.
  • :on_change - the app callback name fired once the animation settles, with the new boolean as its data. Default nil. 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; :small is 6 and 2, :compact is 4 and 2. Any other value is treated as :normal.

  • :width - pos_integer/0. Default 12 when mounting directly, and the width of opts[:__rect__] through the element. Held on the state and never read by render/2, which uses the rect it is given.
  • :height - pos_integer/0. Default 1 when mounting directly, and the height of opts[:__rect__] through the element. Held on the state and never read by render/2.
  • :on_color - {r, g, b} for the thumb when on. Default nil, which uses {100, 200, 100}. Read by mount/1 only — the switch/1 element does not forward it.
  • :off_color - {r, g, b} for the thumb when off. Default nil, which uses {150, 150, 150}. Read by mount/1 only — the switch/1 element does not forward it.
  • :focused - boolean/0 read by mount/1. Default false.
  • :hovered - boolean/0 read by mount/1. Default false.

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 - toggle
  • right - turn on
  • left - 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

rgb()

@type rgb() :: {0..255, 0..255, 0..255}

t()

@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

component_tag()

@spec component_tag() :: :switch

The component tag this widget registers under.

iex> Drafter.Widget.Switch.component_tag()
:switch

focused(state)

from_component_opts(args, opts)

@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

handle_event(event, state)

@spec handle_event(term(), t()) :: {:ok, t()} | {:noreply, t()}

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

mount(props)

@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"}

preferred_height(args, opts)

@spec preferred_height(
  term(),
  keyword()
) :: 3

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

render(state, rect)

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.

unmount(state)

Callback implementation for Drafter.Widget.unmount/1.

update(props, state)

@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 :on or :off — an :enabled that 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 :enabled that 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}

update_props_from_mount(mount_props, existing_state, opts)

@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