Splitter

v0.0.10

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

The Mishka Chelekom Mob Splitter divides a fixed extent between two panes and lets you resize them by dragging the divider, on iOS and Android. It is the native port of the headless Splitter and runs on Mob.

Overview of the Mob Splitter component for iOS and Android

Splitter sizes its panes and does not weight them. Compose has weight and SwiftUI does not, so a proportional split built from weighted boxes would work on Android and collapse on iOS. Instead Splitter takes an explicit extent, the total width or height in dp that it divides, and computes each pane in dp from the percentage. sizes/1 does that arithmetic and is public, so a screen that knows its own dimensions can ask for the numbers without rendering.

The divider is the handle. Grab the bar between the panes and slide it. Only a :canvas node carries on_drag, so the drag surface is one canvas laid over both panes, and the grip is a pill drawn on it at the split. The canvas covers the whole extent, so the position it reports maps straight to a percentage. A touch that misses the divider does nothing, so tapping inside a pane never moves the split.

The children are the two panes. Anything past the second is ignored, and a missing pane renders as an empty box.

The screen owns the split value and the drag anchor. It keeps both in assigns, folds each drag message into a new value with drag/3 and passes the value back in. See the Mob documentation for how screens work.

Generate the splitter component

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

bash
mix mishka.ui.gen.mob splitter

The generator registers the tag, so you can use it in ~MOB markup. It also copies the shared Color and Event helper modules that Splitter uses. The examples on this page use the mishka_ prefix of the showcase app (--module-prefix mishka_), so the tag is <MishkaSplitter>. Without a prefix, the tag is <Splitter>.

Side by side splitter

Drag the divider between the panes. The divider is the handle, and there is no separate slider. The screen keeps the drag anchor in an assign and hands it back to drag/3 with the next payload.

Side by side splitter

Side by side card: a splitter with an Editor pane and a Preview pane divided by a gray grip, and the gray text First pane: 55% below
elixir
<MishkaSplitter
  value={@split}
  extent={300}
  on_change={:split}
  id="panes"
>{[left(), right()]}</MishkaSplitter>

# A canvas reports canvas-LOCAL coordinates, so the arithmetic is
# relative: drag/3 anchors on the "began" phase and adds the distance
# travelled since. Keep the anchor in an assign and hand it back.
def handle_info({:drag, :split, payload}, socket) do
  {value, grab} =
    MishkaSplitter.drag(payload, socket.assigns.grab,
      value: socket.assigns.split,
      extent: 300
    )

  {:noreply, socket |> assign(:split, value) |> assign(:grab, grab)}
end

Stacked splitter

The same grip divides height instead of width. Drag it up and down. orientation: :vertical also makes drag/3 read y instead of x.

Stacked splitter

Stacked card: a splitter with an Output pane above a Console pane, each a title over two pale bars, divided by a short gray horizontal grip at the left edge
elixir
<MishkaSplitter
  value={@vsplit}
  orientation={:vertical}
  extent={200}
  on_change={:vsplit}
>{[top(), bottom()]}</MishkaSplitter>

# orientation: :vertical makes drag/3 read y instead of x.
def handle_info({:drag, :vsplit, payload}, socket) do
  {value, grab} =
    MishkaSplitter.drag(payload, socket.assigns.grab,
      value: socket.assigns.vsplit,
      extent: 200,
      orientation: :vertical
    )

  {:noreply, socket |> assign(:vsplit, value) |> assign(:grab, grab)}
end

Bounded and disabled splitter

min and max stop a pane from vanishing, and a value outside them is pulled back inside. A disabled splitter grays the grip and removes the drag handler, and the panes keep their sizes.

Bounded and disabled splitter

Bounded, and disabled card: two splitters, the first with panes Min 30% and Max 70% and a dark grip, the second with panes Frozen and Inert and a faded grip
heex
<MishkaSplitter value={@bounded} min={30} max={70} extent={300} on_change={:bounded} />
<MishkaSplitter value={50} extent={300} disabled={true} />

Helper functions

Splitter exports helpers for the arithmetic, so a screen can use the numbers without rendering.

FunctionReturnsExample
drag/3Folds a drag payload into {value, grab}.See the examples above.
sizes/1The two pane sizes in dp, clamped into min..max.sizes(%{value: 50, extent: 300}) returns {150.0, 150.0}.
split/1The split percentage after clamping.split(%{value: 95, max: 90}) returns 90.
pane_id/2The test tag of a pane, from the splitter id and the pane number.pane_id("panes", 1) returns "panes-pane-1".
grip_id/1The test tag of the grip, from the splitter id.grip_id("panes") returns "panes-grip".

Props

PropTypeDefaultDescription
valuenumber, 0 to 10050The first pane's share, as a percentage.
orientation:horizontal or :vertical:horizontalSide by side, or stacked.
extentnumber320Total dp to divide.
minnumber, 0 to 10010Lower bound of the split.
maxnumber, 0 to 10090Upper bound of the split.
disabledbooleanfalseThe grip grays and stops responding. The panes keep their sizes.
gripnumber24Thickness of the drag target across the divider, in dp.
grip_colorARGB intGrayThe color of the pill. An int and not a token, because canvas draw ops take ints.
on_changeevent tagNoneSends {:drag, tag, payload} as the divider moves.
idstringnilTest tag. The panes get <id>-pane-1 and <id>-pane-2, and the grip gets <id>-grip.

Events

on_change sends {:drag, tag, payload} to your screen's handle_info/2 while the divider moves. The payload has a phase of :began, :dragging or :ended, and the position as x and y. Fold it into a new percentage with drag/3. It returns {value, grab}. Keep grab in an assign and hand it back with the next payload. It is nil between drags, and also for a touch that missed the divider.

Pass drag/3 the same options you gave the component, including extent and, for a stacked splitter, orientation: :vertical. It reads only x and y. It never reads dx or dy, because those are cumulative translation on iOS and per sample deltas on Android, so anything built on them behaves differently on each platform.

elixir
def handle_info({:drag, :split, payload}, socket) do
  {value, grab} =
    MishkaSplitter.drag(payload, socket.assigns.grab,
      value: socket.assigns.split,
      extent: 300
    )

  {:noreply, socket |> assign(:split, value) |> assign(:grab, grab)}
end

Differences from the web component

  • Keyboard resizing with the arrow, Home and End keys is not ported, because a Mob canvas has no focus ring to receive keys.
  • The *_class attributes are not ported.