Segmented control

v0.0.10

v0.0.10StableMobStatelessApache-2.0
Installation mix mishka.ui.gen.mob segmented_control
Source View Source

The Mishka Chelekom Mob Segmented Control is a joined strip of options where exactly one is always selected, for iOS and Android apps built with Mob. It is the native port of the headless Segmented Control.

Overview of the Mob Segmented Control component for iOS and Android

MishkaSegmentedControl draws one continuous track with the selection sitting inside it. The selection can never be empty. Tapping the selected segment does nothing, and select/2 states that rule: it returns the tapped id. When value matches no segment, the control selects the first one.

A toggle group can clear its selection, so use Mob Toggle Group when an empty selection is meaningful. A Mob Radio Group also keeps one option selected, but it draws separate circles instead of a joined strip.

Write segments as <MishkaSegmentedControlOption> children, the native version of Chelekom's <:option> slot. Each tag takes id, label and disabled. When the segments come from data, option/3 builds the identical node. The control keeps the handler, so a segment has no on_* prop of its own.

Segments are sized by their labels, and the track hugs them. Equal widths need layout weight, which Compose implements and Mob's iOS mapping does not, so a strip that looked right on Android would collapse on iOS. Set fill_width={true} to make the track span its parent. The segments still sit together at the leading edge. On Android, segment_weight gives the segments equal widths.

The headless original ships no colors or spacing, and there is no stylesheet here, so every visual is a prop with a default. A disabled selected segment is gray, without the accent color.

Give the control an id to name each segment for device tests. With id="view", the :week segment is tagged view-week-selected or view-week-idle. Selection is shown by fill color alone, and color is not in the accessibility tree, so these tags are the only thing a device test can read.

Generate the segmented control component

Run this from your Mob app's root to add the component to lib/<app>/components/:

bash
mix mishka.ui.gen.mob segmented_control

The generator registers the <SegmentedControl> tag so you can use it in ~MOB markup. It also copies the shared Event support module. The examples on this page use the mishka_ prefix of the showcase app (--module-prefix mishka_), so the tag is <MishkaSegmentedControl>. Without a prefix the tag is <SegmentedControl>.

To generate it with that prefix, run:

bash
mix mishka.ui.gen.mob segmented_control --module-prefix mishka_

Segmented control with one segment always selected

Tapping the selected segment is a no-op, so the selection cannot be cleared. select/2 states the rule: it always returns the tapped id, so the next value is never nil. A toggle group would clear the selection here.

Segmented control with one segment always selected

Always one selected card with a segmented control of Day, Week and Month, Day selected in dark fill, and the line Value: day
A segmented control with Day, Week and Month and Week selected, and the line Value: week below.

Left: the example as it loads. Right: after a tap. A segmented control always has exactly one selection.

elixir
# Segments are slot tags, like Chelekom's <:option>. Each one carries its
# id and its label; the control keeps the handler.
<MishkaSegmentedControl value={@view} on_change={:view} id="view">
  <MishkaSegmentedControlOption id={:day} label="Day" />
  <MishkaSegmentedControlOption id={:week} label="Week" />
  <MishkaSegmentedControlOption id={:month} label="Month" />
</MishkaSegmentedControl>

# select/2 is the rule, and it is deliberately boring: always the tapped
# id. A toggle group would clear here; a radio group would keep. Neither
# is what a segmented control does.
def handle_info({:tap, {:view, id}}, socket) do
  next = MishkaSegmentedControl.select(socket.assigns.view, id)
  {:noreply, Mob.Socket.assign(socket, :view, next)}
end

def handle_info(_msg, socket), do: {:noreply, socket}

Segmented control with a label

Set label to put a heading above the strip.

Segmented control with a label

With a label card with a segmented control headed THEME, with Light, Dark and System, System selected in dark fill, in the MishkaMob showcase app on Android
heex
<MishkaSegmentedControl label="THEME" value={@theme} on_change={:theme}>
  <MishkaSegmentedControlOption id={:light} label="Light" />
  <MishkaSegmentedControlOption id={:dark} label="Dark" />
  <MishkaSegmentedControlOption id={:system} label="System" />
</MishkaSegmentedControl>

Segmented control segments from data

When the segments come from a list, option/3 builds the same node that the tag does, so the control cannot tell the two apart. Use the tag when you write the segments out and the function when you map over a list. text_size sets the size of the labels.

Segmented control segments from data

Segments from data card with a compact segmented control of Owner, Admin and Member, Admin selected, above a gray note that the control cannot tell tag from function
elixir
# Written as a LIST here rather than as <MishkaSegmentedControlOption>
# tags, because the segments come from data. option/3 builds exactly the
# node the tag does — %{type: :mishka_segmented_control_option, props:
# %{id:, label:, disabled:}} — so the two forms are interchangeable. Use
# the tag when you are writing the segments out, the function when you
# are mapping over a list.
@roles [{:owner, "Owner"}, {:admin, "Admin"}, {:member, "Member"}]

<MishkaSegmentedControl value={@role} on_change={:role} text_size={:sm}>
  {Enum.map(@roles, fn {id, label} -> option(id, label) end)}
</MishkaSegmentedControl>

Hugging and full-width segmented control

The strip is as wide as its labels unless you set fill_width. With fill_width={true} the track spans its parent. The segments stay sized by their labels either way.

Hugging and full-width segmented control

It hugs, or it spans card with two segmented controls of S, M and L, M selected in both, the first hugging its labels and the second spanning the width
heex
<MishkaSegmentedControl fill_width={true} value={@v}>
  <MishkaSegmentedControlOption id={:s} label="S" />
  <MishkaSegmentedControlOption id={:m} label="M" />
  <MishkaSegmentedControlOption id={:l} label="L" />
</MishkaSegmentedControl>

Styled segmented control

Like the headless original, the control ships no look of its own, so every visual is a prop. color fills the selected segment, background paints the track, and corner_radius, segment_radius and padding shape them.

Styled segmented control

Styled by props, not by a stylesheet card with two Day, Week and Month controls: violet on pale lavender, then black on white with square corners
heex
<MishkaSegmentedControl
  value={@v}
  color={0xFF7C3AED}
  background={0xFFEFEDF7}
  corner_radius={20}
  segment_radius={17}
  padding={12}
>
  <MishkaSegmentedControlOption id={:day} label="Day" />
  <MishkaSegmentedControlOption id={:week} label="Week" />
  <MishkaSegmentedControlOption id={:month} label="Month" />
</MishkaSegmentedControl>

Disabled segmented control

Put disabled on a segment tag to disable one segment. Put it on the control to disable every segment. A locked control is gray, without the accent color.

Disabled segmented control

Disabled card with two segmented controls, ONE SEGMENT OFF with a muted Archive (off) and WHOLE CONTROL OFF with all segments muted and Mid (off) selected
elixir
# One segment off — disabled sits on the option tag.
<MishkaSegmentedControl value={@v} on_change={:pick}>
  <MishkaSegmentedControlOption id={:inbox} label="Inbox" />
  <MishkaSegmentedControlOption id={:archive} label="Archive" disabled={true} />
</MishkaSegmentedControl>

# The whole control off — disabled on the control cascades to every one.
<MishkaSegmentedControl disabled={true} value={@v}>…</MishkaSegmentedControl>

Props

PropTypeDefaultDescription
valueoption idThe first optionThe selected segment. An id that matches no segment falls back to the first one.
labelstringnilHeading above the strip.
disabledbooleanfalseDisables every segment.
on_changeevent tagNoneSent as {:tap, {tag, option_id}}.
colorcolor token or ARGB integer:primaryFill of the selected segment.
text_colorcolor token or ARGB integer:on_primaryLabel of the selected segment.
label_colorcolor token or ARGB integer:on_surfaceLabel of an unselected segment.
backgroundcolor token or ARGB integer:surface_raisedThe track behind the segments.
paddingspacing token or number:space_smPadding inside each segment.
track_paddingnumber3Inset between the track and its segments.
corner_radiusradius token or number:radius_mdCorners of the track.
segment_radiusradius token or number:radius_smCorners of a segment.
border_color and border_widthcolor and numbernil and 0An optional border around the track.
text_sizetext size token:baseSize of the segment labels.
fill_widthbooleanfalseThe track spans its parent instead of hugging its labels.
idstringnilPrefix for each segment's test tag, such as view-week-selected.
<MishkaSegmentedControlOption>slot tagNoneOne segment, with id, label and disabled. The control reads it and draws the segment.
option/3helperNoneoption(id, label, disabled: false) builds the same node, for segments that come from data.
select/2helperNoneReturns the next value for a tap: always the tapped id, never nil.

More text and layout props

PropTypeDefaultDescription
padding_top, padding_right, padding_bottom, padding_leftspacing token or numberNot setOne edge of a segment. It overrides padding on that side only.
font_weight:regular, :medium, :semibold, :bold, :light or :thinNot set (regular)Weight of every segment label.
selected_weightsame as font_weightThe value of font_weightWeight of the selected label only, for a strip that bolds its selection.
letter_spacingnumber (sp)Not setTracking on every segment label.
line_heightnumberNot setMultiplier of text_size. Ignored without one.
max_linesnumberNot set (unlimited)Caps a segment label and ends the rest with an ellipsis.
widthnumberNot setExact width of the track.
heightnumberNot setExact height of the track.
align:center, :leading and so onNot set (top leading)Where the segments sit when height leaves them room.
shadowshadow stringNot setShadow on the track.
segment_widthnumberNot setExact width of a segment.
segment_heightnumberNot setExact height of a segment.
segment_align:center, :leading and so on:centerWhere a label sits inside its segment.
segment_weightnumberNot setRow weight per segment, for equal widths. Android only. Pair it with fill_width={true}.
selected_shadowshadow stringNot setShadow on the selected segment only.
heading_sizetext size token or number:smSize of the label heading.
heading_colorcolor token or ARGB integer:mutedColor of the heading.
heading_weightsame as font_weightNot set (regular)Weight of the heading.
heading_gapnumber10Space between the heading and the track.

These props leave the control as it was when you do not set them. Three notes help when you pin sizes:

  • Padding applies before width and height, so height measures the box inside the padding. A 36 pt track with a 3 pt inset is height: 30, track_padding: 3, and a 28 pt segment inside it is segment_height: 28 with no vertical padding.
  • width and height take plain numbers. Tokens are not resolved for them. Never pass a negative padding, because it throws on the native side.
  • shadow and selected_shadow take a string in CSS box-shadow order, dx dy blur spread #AARRGGBB, with further layers after a pipe. A numeric text_size is in sp and stays put when the user scales their type.

Events

Your screen receives {:tap, {tag, option_id}} in handle_info/2 from on_change. The control pairs its tag with the id of the tapped segment, so one clause serves every segment:

elixir
def handle_info({:tap, {:view, id}}, socket) do
  next = MishkaSegmentedControl.select(socket.assigns.view, id)
  {:noreply, Mob.Socket.assign(socket, :view, next)}
end

def handle_info(_msg, socket), do: {:noreply, socket}

Differences from the web component

  • name is not ported. It is HTML form plumbing.
  • The *_class attributes are not ported. Every visual is a prop instead.