Events in Dala — User Guide

Copy Markdown View Source

Comprehensive guide to receiving events from widgets, gestures, and the device. For the underlying design, see event_model.md.

TL;DR

All events arrive as messages to handle_info/2. Two shapes:

# Simple (most common):
{:tap, tag}
{:change, tag, value}
{:compose, tag, %{phase: :committed, text: text}}  # IME composition

# Canonical envelope (includes screen/component context):
{:dala_event, %Dala.Event.Address{}, event_atom, payload}

Use simple tuples for taps, text, gestures. Use canonical for advanced routing. Dala.Event.Bridge converts simple → canonical automatically.

Quick reference

Taps and text input

button("Save", on_tap: {self(), :save})

text_field(value: @email, on_change: {self(), :email_changed},
                          on_focus:  {self(), :email_focused},
                          on_blur:   {self(), :email_blurred},
                          on_submit: {self(), :email_submitted})

# In handle_info/2:
def handle_info({:tap, :save}, socket), do: ...
def handle_info({:change, :email_changed, value}, socket), do: ...

Selection (pickers, menus)

picker(items: @options, on_select: {self(), :picked})

def handle_info({:select, :picked}, socket), do: ...

Gestures

button("Avatar",
  on_long_press: {self(), :show_menu},
  on_double_tap: {self(), :zoom})

card(
  on_swipe_left:  {self(), :delete},
  on_swipe_right: {self(), :archive},
  on_swipe:       {self(), :any_swipe})  # also fires; payload includes direction

def handle_info({:long_press, :show_menu}, socket), do: ...
def handle_info({:swipe, :any_swipe, direction}, socket), do: ...   # :left | :right | :up | :down

Scroll — three tiers

Tier 1 — raw deltas (rarely needed; throttle defaults to 30 Hz):

scroll(on_scroll: {self(), :feed})

def handle_info({:scroll, :feed, %{y: y, dy: dy, phase: phase}}, socket), do: ...

Override the throttle when you need higher fidelity:

scroll(on_scroll: {self(), :feed, throttle: 16})         # 60 Hz
scroll(on_scroll: {self(), :feed, throttle: 0})          # raw firing rate (escape hatch)
scroll(on_scroll: {self(), :feed, debounce: 200})        # only after stillness
scroll(on_scroll: {self(), :feed, throttle: 50, delta: 8})  # 20 Hz + 8px deadzone

Tier 2 — semantic events (use these by default):

scroll(
  on_scroll_began:    {self(), :feed_began},
  on_scroll_ended:    {self(), :feed_ended},
  on_scroll_settled:  {self(), :feed_settled},   # fires after deceleration
  on_top_reached:     {self(), :pull_to_refresh},
  on_end_reached:     {self(), :load_more},      # already wired pre-Batch 5
  on_scrolled_past:   {self(), :show_back_to_top, 600})  # threshold = 600 px

def handle_info({:scroll_began, :feed_began}, socket), do: ...
def handle_info({:scrolled_past, :show_back_to_top}, socket), do: ...

Tier 3 — native-side, no BEAM round-trip (parallax, sticky headers, fades):

image(src: "hero.jpg",
  parallax: %{ratio: 0.5, container: :main_scroll})

navbar(
  fade_on_scroll: %{container: :main_scroll, fade_after: 100, fade_over: 60})

header(
  sticky_when_scrolled_past: %{container: :main_scroll, threshold: 200})

These never deliver events to BEAM — they're computed natively at display refresh rate.

IME composition (CJK / Korean / Vietnamese / accent input)

Languages with multi-stage input show a "marked" or "composing" region before the user picks a final character. Apps that read text mid-keystroke (search-as-you-type, network sync) need to know about composition state to avoid sending partial input.

text_field(value: @text,
  on_change:  {self(), :text},      # fires on every change (including composing)
  on_compose: {self(), :ime})       # fires on composition phase changes

# Events:
# {:compose, :ime, %{phase: :began,     text: "n"}}     # composition started
# {:compose, :ime, %{phase: :updating,  text: "ni"}}    # composing
# {:compose, :ime, %{phase: :committed, text: "你"}}    # user picked candidate
# {:compose, :ime, %{phase: :cancelled, text: ""}}      # user dismissed IME

Commit-only handler pattern (the typical use case):

def handle_info({:compose, _id, %{phase: :began}}, socket),
  do: {:noreply, assign(socket, :composing, true)}

def handle_info({:compose, _id, %{phase: :committed, text: text}}, socket) do
  # Real commit replaces whatever raw text we got during composition.
  {:noreply, assign(socket, composing: false, text: text)}
end

def handle_info({:compose, _id, %{phase: :cancelled}}, socket),
  do: {:noreply, assign(socket, :composing, false)}

def handle_info({:change, _id, value}, %{assigns: %{composing: true}} = socket),
  do: {:noreply, socket}                 # ignore raw text while composing

def handle_info({:change, _id, value}, socket),
  do: {:noreply, assign(socket, :text, value)}

For most apps (forms that read the final value on submit), you don't need on_compose at all — UIKit/Compose handle IME natively and the committed text ends up in value correctly. Only opt in when partial input matters.

Device lifecycle

# Subscribe in mount/2 (or anywhere — process is monitored, auto-cleaned):
Dala.Device.Device.subscribe()                  # default: :app, :display, :audio, :memory
Dala.Device.Device.subscribe(:all)              # all categories
Dala.Device.Device.subscribe([:thermal, :power])

# Receive events:
def handle_info({:dala_device, :did_enter_background}, socket), do: ...
def handle_info({:dala_device, :thermal_state_changed, :serious}, socket), do: ...
def handle_info({:dala_device, :battery_level_changed, pct}, socket), do: ...

See event_model.md for the full event vocabulary.

When to use Tier 1 vs Tier 2 vs Tier 3 (scroll)

You want to...Use
Trigger pagination at the bottom of a listTier 2 — on_end_reached
Show a "back to top" button after 600 pxTier 2 — on_scrolled_past
Hide a navbar while user is actively scrollingTier 2 — on_scroll_began / on_scroll_settled
Run analytics on "user reached product N"Tier 2 — on_scrolled_past
Smoothly fade a header from 100 % to 0 % over 60 pxTier 3 — fade_on_scroll
Parallax a hero imageTier 3 — parallax
Animate something as a function of scroll positionTier 3
You really need raw scroll deltasTier 1 (and explain why in code review)
Scroll-driven game / drawing canvasTier 1 with throttle: 0

The rule of thumb from React Native's experience: if your code looks like "compute a transform from scroll position," it belongs in Tier 3, not Tier 1.

Throttle / debounce / delta semantics

on_scroll: {pid, tag, opts}

opts accepts:

  • throttle: ms — minimum interval between emissions. Default 33 ms (≈ 30 Hz) for scroll, 16 ms (60 Hz) for drag/pinch/rotate, 33 ms for pointer move. 0 disables throttling.
  • debounce: ms — only emit after ms ms of no events. Default 0 (off).
  • delta: number — minimum change in x or y (or scale, or degrees) to trigger an emit. Default 1 px for scroll/drag, 0.01 (1 %) for pinch, 1° for rotate, 4 px for pointer.
  • leading: bool — emit the first event of a burst. Default true.
  • trailing: bool — emit the final event after debounce window expires. Default true for most events; false for pointer_move.

Phase-boundary events always fire, regardless of throttle. So {:scroll, tag, %{phase: :began}} and {:scroll, tag, %{phase: :ended}} are guaranteed to deliver even if throttle: 1000.

Event payload reference

{:scroll, tag, payload}

%{
  x: 0.0,          # current x offset in px
  y: 1240.0,       # current y offset in px
  dx: 0.0,         # delta since last emitted event (px)
  dy: 12.0,
  velocity_x: 0.0, # px/sec
  velocity_y: 720.0,
  phase: :began | :dragging | :decelerating | :ended,
  ts: 18472,       # ms since boot (monotonic; safe for diffs)
  seq: 891         # monotonic counter per handle, detects drops
}

{:drag, tag, payload}

Same shape minus velocity_x/velocity_y.

{:pinch, tag, payload} / {:rotate, tag, payload}

%{scale: 1.25,   velocity: 0.3,  phase: ..., ts: ..., seq: ...}     # pinch
%{degrees: 45.0, velocity: 0.1,  phase: ..., ts: ..., seq: ...}     # rotate

{:pointer_move, tag, payload}

%{x: 320.0, y: 480.0, ts: ..., seq: ...}

{:swipe, tag, direction}

direction is :left | :right | :up | :down.

Tier 2 single-fire events

{:scroll_began, tag}, {:scroll_ended, tag}, {:scroll_settled, tag}, {:top_reached, tag}, {:end_reached, tag}, {:scrolled_past, tag} — no payload. The tag identifies the source widget.

The canonical envelope

For new code, prefer the canonical envelope. Use Dala.Event.Bridge:

def handle_info(msg, socket) do
  case Dala.Event.Bridge.legacy_to_canonical(msg, __MODULE__) do
    {:ok, {:dala_event, addr, event, payload}} ->
      handle_canonical(addr, event, payload, socket)

    :passthrough ->
      # Not a recognised event — handle normally.
      ...
  end
end

defp handle_canonical(%Address{widget: :button, id: :save}, :tap, _, socket) do ...
defp handle_canonical(%Address{widget: :scroll, id: list_id},
                       :scroll, %{y: y, phase: :ended}, socket) do ...
defp handle_canonical(%Address{widget: :list, id: list_id, instance: index},
                       :select, _, socket) do ...

The address gives you screen, component_path, widget, id, instance, render_id — which is much richer matching power than the legacy 2-tuple.

Targeting events to non-screen processes

For Phase 4+ widgets (gestures and beyond), you can target a specific process other than the screen:

button("Pause", on_tap: :pause, target: MyApp.AudioPlayer)
button("Sync",  on_tap: :sync,  target: {:via, Registry, {:workers, "sync"}})
button("Cancel", on_tap: :cancel, target: :screen)        # explicit
button("Save",  on_tap: :save,   target: :parent)         # default
button("Use",   on_tap: :use,    target: {:component, :outer_form})

In-tree targets (:parent, :screen, {:component, _}) get framework guarantees: render-id staleness check, auto-cleanup on widget unmount. External targets (registered atom, pid, {:via, ...}) are best-effort — the framework just sends the message and trusts the recipient exists.

(Note: target: is currently in the design phase; the renderer landing in a follow-up batch will wire it up. Today, every widget's events go to the pid you put in on_tap: {pid, ...}.)

Stateful components own their subtree's events

If you write a reusable component (e.g., a date picker, an autocomplete, a chart), declare it as Dala.Event.Component:

defmodule MyApp.Form do
  use Dala.Event.Component

  def mount(props, state), do: {:ok, Map.put(state, :email, "")}

  def render(state) do
    column(...)  # contains text fields, buttons
  end

  def handle_event(%Address{id: :email}, :change, value, state) do
    {:noreply, %{state | email: value}}    # internal — screen doesn't see
  end

  def handle_event(%Address{id: :submit}, :tap, _, state) do
    send(state.parent, {:form_submitted, state.email})  # escalate semantic event
    {:noreply, state}
  end
end

Widget events inside the component default to landing here, not the screen. The screen sees only :form_submitted — clean encapsulation, regardless of how many widgets the component contains internally.

Debugging — Dala.Event.Trace

Live-watch every event in IEx:

Dala.Event.Trace.start()
Dala.Event.Trace.subscribe()             # all events
# or with a filter:
Dala.Event.Trace.subscribe(fn addr -> addr.widget == :scroll end)

# Now in your IEx session:
flush()
# {:dala_trace, %Address{widget: :scroll, id: :feed},
#               :scroll, %{y: 240.0, dy: 8.0, phase: :dragging, seq: 12}}
# ...

Dala.Event.Trace.unsubscribe()

When no tracers are registered, Dala.Event.dispatch/4 does one ETS lookup (~50 ns) and returns. Zero impact on production performance.

Performance notes

  • Tap-family events (tap, change, focus, blur, submit, select): one enif_send per event. ~1–10 µs. Negligible.
  • Gestures (long-press, double-tap, swipe): same — single user-level events.
  • High-frequency events (scroll, drag, pinch, rotate, pointer move): throttled and delta-thresholded native-side before the BEAM crossing. Default 30 Hz cap means at most 30 enif_send per active scroll session, even if the underlying scroll is 120 Hz.
  • Tier 3 native primitives (parallax, fade, sticky): zero BEAM involvement during the scroll. Animation runs at display refresh rate natively.

Migration — from register_tap to canonical

The framework still uses register_tap (returning integer handles) under the hood. The visible API has not changed: continue to write on_tap: {pid, tag}. As you migrate screens to use Dala.Event.Bridge or stateful components, the legacy shapes keep working — both arrive at the same handler.

When/if Dala.List is migrated to a stateful component, its row-tap shape ({:tap, {:list, id, :select, idx}}) will change to a canonical envelope emitted from the list's pid. The bridge already handles this conversion transparently for screens that opt in.

Common patterns

Pull-to-refresh

scroll(on_top_reached: {self(), :refresh},
       on_scroll: {self(), :feed, throttle: 100}) do
  ...rows...
end

def handle_info({:top_reached, :refresh}, socket) do
  Task.async(fn -> reload_feed() end)
  {:noreply, assign(socket, :refreshing, true)}
end

Infinite scroll

scroll(on_end_reached: {self(), :load_more}) do ...end

def handle_info({:end_reached, :load_more}, socket) do
  if !socket.assigns.loading do
    Task.async(fn -> load_next_page() end)
    {:noreply, assign(socket, :loading, true)}
  else
    {:noreply, socket}
  end
end

Show "back to top" button

column(spacing: 0) do
  scroll(
    on_scrolled_past: {self(), :show_back_to_top, 600},
    on_top_reached:   {self(), :hide_back_to_top}) do
    ...long content...
  end

  if @show_back_to_top, do: floating_button("↑", on_tap: {self(), :scroll_to_top})
end

Card stack with swipe-to-dismiss

for {card, idx} <- Enum.with_index(@cards) do
  card(id: card.id,
       on_swipe_left:  {self(), {:dismiss, card.id}},
       on_swipe_right: {self(), {:save, card.id}}) do
    ...card contents...
  end
end

def handle_info({:swipe_left, {:dismiss, id}}, socket), do: ...
def handle_info({:swipe_right, {:save, id}}, socket), do: ...

Photo viewer with pinch-to-zoom and pan

image(src: @url,
      on_pinch: {self(), :zoom},
      on_drag:  {self(), :pan})

def handle_info({:pinch, :zoom, %{scale: scale, phase: :ended}}, socket) do
  # Final zoom level — commit it.
  {:noreply, assign(socket, :zoom, scale)}
end

def handle_info({:pinch, :zoom, %{scale: scale, phase: :dragging}}, socket) do
  # Live update — typically you'd render with this on the way to the final.
  {:noreply, assign(socket, :live_zoom, scale)}
end

Hero parallax with native-only animation

scroll(id: :main, on_scroll_began: {self(), :hide_chrome}) do
  image(
    src: "hero.jpg",
    parallax: %{ratio: 0.5, container: :main})  # NEVER hits BEAM during scroll

  ...content...
end

Anti-patterns

Don't put on_scroll with no throttle and synchronous work in the handler. A slow handler at 60 Hz will overflow the screen GenServer's mailbox and lag the app. If you really need every frame, use Tier 3.

Don't use String.to_atom/1 to derive id from user data. Atoms are not GC'd. Use binaries for data-derived IDs:

# ❌ leaks
on_tap: {self(), String.to_atom("contact_#{contact.id}")}

# ✅ safe
on_tap: {self(), {:contact, contact.id}}
on_tap: {self(), "contact:#{contact.id}"}

Don't compute layout from scroll deltas in BEAM. The frame budget is ~16 ms; a BEAM round-trip plus computation can easily exceed it. Use Tier 3.

Don't override target: to a process you don't control its lifecycle of. External targets are best-effort — if the target dies, your event is silently dropped (logged in dev). For "fire and forget" that's fine; for "must receive" it's a footgun.

Where to find more