Floating window

v0.0.10

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

The Mob Floating Window in Mishka Chelekom is a titled panel that floats over a stage on iOS and Android screens built with Mob. A user drags it by its title bar or taps arrows to move it a step at a time. It is the native port of the headless Floating Window.

Overview of the Mob Floating Window component for iOS and Android

The drag is a real finger drag. The drag surface is one canvas the size of the whole stage, and it never moves. A canvas that rode on the window would slide under the finger, and the window would follow only a fraction of the gesture. So x and y are positions in stage coordinates, and bounds matters: it sizes the canvas and clamps the position. Without bounds there is no drag surface.

A drag starts only when the touch begins on the title bar. A touch anywhere else on the stage engages nothing.

The root is a box that stacks three layers, in this order:

  • The panel: background, border, title strip and label. It has no handlers.
  • The canvas: transparent, as large as the stage, and it carries the drag handler.
  • The controls: the body, the ✕ and the nudge arrows, each at its own offset.

Anything a finger must tap sits above the canvas, because the canvas consumes the touch that starts on it. Anything the drag must reach sits below it. That is why the title strip and its label take no taps, and why the ✕ is the only control in the title bar that does not begin a drag.

The window is a fixed rectangle, width by height, with a 48dp title bar and a 48dp row of nudge arrows. The layers are placed by arithmetic, not by layout, so drag/3 can decide whether a touch landed on the title bar without measuring anything.

The screen owns the position. drag/3 and nudge/2 fold a drag payload or an arrow tap into a new {x, y} that you assign back. drag/3 reads only x and y from the payload and ignores dx and dy, because those are cumulative on iOS and per sample on Android.

Size the window to its body

The body is what the chrome leaves: height - chrome_height/1. Its own :space_md padding (16dp at the default scale) comes out of the top and the bottom of that. Content taller than what remains is clipped at the edge of the body and is not scrolled. A 140dp window with the arrow row has 44dp of body and 12dp of room for content, which cuts a line of text in half.

Budget the height before you pick it: chrome_height/1, plus two paddings, plus the content. On Material a line of body text is 24dp even at a small text_size, unless the Text sets line_height, and a line that wraps counts again. Nothing here can measure the content, so a window that is too short stays too short.

Generate the floating window component

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

bash
mix mishka.ui.gen.mob floating_window

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

The Floating indicator appears on the same showcase page but has its own generator: mix mishka.ui.gen.mob floating_indicator.

Drag a floating window by its title bar

A real finger drag moves the window. The drag surface is one static canvas the size of the stage, which is what bounds is for, and it engages only when the touch starts on the title bar.

drag/3 anchors on the :began phase. Keep that anchor in an assign and hand it back with the next payload. It comes back nil when the finger lifts.

Drag a floating window by its title bar

Floating window example card titled Drag it by the title bar, with a window titled Inspector reading x 20 y 12, the readout win: 20, 12 and a Reset button
elixir
<MishkaFloatingWindow
  x={@x}
  y={@y}
  bounds={{240, 200}}
  width={160}
  height={100}
  label="Inspector"
  dragging={@grab != nil}
  show_nudges={false}
  on_move={:move}
  id="win"
>
  <Text text={"x #{round(@x)} · y #{round(@y)}"} text_size={:sm} />
</MishkaFloatingWindow>

# The canvas never moves, so x/y are POSITIONS in stage coordinates.
# drag/3 anchors on the :began phase — keep that anchor in an assign and
# hand it back; it comes back nil when the finger lifts.
def handle_info({:drag, :move, payload}, socket) do
  {position, grab} =
    MishkaFloatingWindow.drag(payload, socket.assigns.grab,
      x: elem(socket.assigns.drag_pos, 0),
      y: elem(socket.assigns.drag_pos, 1),
      width: 160,
      height: 100,
      bounds: {240, 200}
    )

  {:noreply, socket |> assign(:drag_pos, position) |> assign(:grab, grab)}
end

Nudge a floating window with arrows

The arrows are the alternative to dragging that WCAG 2.5.7 asks for, at 48dp each. They read step and bounds from the same props the window drew with. The clamp is the stage minus the window, so a tap past the edge stops the window instead of walking it out of view.

One tap moves one step. nudge/2 takes the window's own props, so it clamps against the geometry that was drawn.

The window is 160dp tall. The title bar and the arrow row take 96dp, the padding around the body takes 32dp, and the line of text needs 24dp, which leaves a little room for the device's font.

Nudge a floating window with arrows

Floating window example card titled Nudge, for precision, with a window titled Layers holding four arrow buttons and the line Tap an arrow, above step: 0, 0 and a Reset button
A gray stage with the Layers window moved to the right, a readout reading step: 20, 0, and four arrow buttons.

Left: the example as it loads. Right: after a tap. Arrow buttons move the window a step at a time.

elixir
<MishkaFloatingWindow
  x={@x}
  y={@y}
  bounds={{240, 200}}
  width={200}
  height={160}
  step={20}
  label="Layers"
  on_move={:step_move}
  id="step"
>
  <Text text="Tap an arrow." text_size={:sm} />
</MishkaFloatingWindow>

# One tap, one step. nudge/2 takes the window's own props, so it clamps
# against the geometry that was actually drawn.
def handle_info({:tap, {:step_move, direction}}, socket) do
  moved = MishkaFloatingWindow.nudge(window(socket.assigns), direction)

  {:noreply, assign(socket, :step_pos, moved)}
end

Floating window with a close button

Set on_close and a ✕ appears in the title bar. It is the one control in the bar that does not begin a drag.

Without on_move the window has no drag surface and no arrow row. The body sits above the drag surface, so the Ping button takes taps either way.

Floating window with a close button

Floating window example card titled A window that closes, with a Console window with an X, the line This body still takes taps and a Ping (0) button, above Pings: 0
An empty gray stage where the Console window used to be, with Pings: 0 and a Reopen button below.

Left: the example as it loads. Right: after a tap. The close mark removes the window.

elixir
<MishkaFloatingWindow label="Console" width={220} height={160} on_close={:close} id="closable">
  <Button text="Ping" on_tap={{self(), :ping}} />
</MishkaFloatingWindow>

def handle_info({:tap, :close}, socket), do: {:noreply, assign(socket, :open?, false)}

def handle_info({:tap, :ping}, socket),
  do: {:noreply, assign(socket, :pings, socket.assigns.pings + 1)}

Floating window with its own title bar

The <MishkaFloatingWindowHandle> slot replaces the label with a node of your own. It is decoration: it sits under the drag surface, so the whole bar stays draggable and the handle itself takes no taps.

The handle/1 function builds the same node when the bar comes from data, and the handle: prop takes one directly. A slot wins over the prop.

Floating window with its own title bar

Floating window example card titled Its own title bar, with a window whose title bar shows a black dot and build.log, and the body text No on_move, so no arrows and no drag surface
heex
<MishkaFloatingWindow width={240} height={136} id="custom">
  <MishkaFloatingWindowHandle>
    <Row align={:center}>
      <Box width={8} height={8} background={:primary} corner_radius={:radius_pill} />
      <Spacer size={8} />
      <Text text="build.log" text_size={:sm} max_lines={1} />
    </Row>
  </MishkaFloatingWindowHandle>
  <Text text="The window body goes here." text_size={:sm} />
</MishkaFloatingWindow>

Props

PropTypeDefaultDescription
x, ynumber0Position inside the stage, applied as offset_x and offset_y.
width, heightnumber260, 180The window is a fixed rectangle, so drag/3 can work out where the title bar is by arithmetic.
bounds{w, h}nilThe stage in dp. It sizes the canvas and clamps the position. No bounds, no drag.
stepnumber20How far one nudge moves, in dp.
labelstringnilTitle shown in the title bar.
handlenode or list of nodesnilTitle bar content instead of label. It is decoration and takes no taps. The slot wins over this prop.
draggingbooleanfalseSet it while a drag is in progress. It tints the title strip and adds -dragging to the strip's test tag.
show_nudgesbooleantrueThe arrow row. It shows only when on_move is set, because an unwired arrow does nothing.
on_moveevent tagnoneReceives {:drag, tag, payload} from the title bar and {:tap, {tag, direction}} from an arrow.
on_closeevent tagnoneReceives {:tap, tag} from the ✕, the one control in the bar that is not a drag.
idstringnilRoot of the test tags: <id>-window, <id>-handle (<id>-handle-dragging while dragging), <id>-drag, <id>-body, <id>-close and <id>-nudge-<direction>.

Functions

  • drag(payload, grab, props) folds a drag payload into {position, grab}. grab is nil between drags and for a touch that began anywhere but the title bar.
  • nudge(props, direction) returns the position after one step, clamped inside the stage. direction is :up, :down, :left or :right, and :up subtracts from y.
  • handle(content) builds the same node as the <MishkaFloatingWindowHandle> tag.
  • chrome_height(props) returns the height of everything above the body: 48dp for the title bar, and 96dp when the window also has the arrow row.

Events

on_move carries both ways to move the window. The title bar sends {:drag, tag, payload}, and the payload holds phase (:began, :dragging or :ended), x and y. An arrow sends {:tap, {tag, direction}}, where direction is :left, :up, :down or :right. on_close sends {:tap, tag}. Your screen receives all of them in handle_info/2.

elixir
def handle_info({:drag, :move, payload}, socket) do
  {position, grab} = MishkaFloatingWindow.drag(payload, socket.assigns.grab, window(socket))
  {:noreply, socket |> assign(:pos, position) |> assign(:grab, grab)}
end

def handle_info({:tap, {:move, direction}}, socket) do
  {:noreply, assign(socket, :pos, MishkaFloatingWindow.nudge(window(socket), direction))}
end

Here window/1 is a helper in your screen that returns the props the window renders with. Both folds read the same props, so step and bounds stay live.

Differences from the web component

The Mob Floating Window does not port these parts of the headless Floating Window:

  • handle_label. Mob exposes no accessibility semantics to hang a role or a label on.
  • class, handle_class and body_class. Mob has no CSS.
  • The 1px step of Shift plus an arrow key. A touch screen has no modifier keys, so step is the one increment.