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_event/3 (for UI events) or handle_info/2 (for device API results). Two shapes:

# UI events (most common) — arrive via handle_event/3:
:save
{:change, :email_changed, value}
{:compose, :ime, %{phase: :committed, text: text}}  # IME composition

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

Use simple atoms/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: :save

text_field text: @email, on_change: :email_changed,
                    on_focus:  :email_focused,
                    on_blur:   :email_blurred,
                    on_submit: :email_submitted

# In handle_event/3:
def handle_event(:save, _params, socket), do: ...
def handle_event({:change, :email_changed, value}, _params, socket), do: ...

Selection (pickers, menus)

picker items: @options, on_select: :picked

def handle_event({:select, :picked, index}, _params, socket), do: ...

Gestures

button "Avatar",
  on_long_press: :show_menu,
  on_double_tap: :zoom

column on_swipe_left: :delete,
       on_swipe_right: :archive,
       on_swipe: :any_swipe  # also fires; payload includes direction

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

Scroll — three tiers

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

scroll on_scroll: :feed

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

Override the throttle when you need higher fidelity:

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

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

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

def handle_event({:scroll_began, :feed_began}, _params, socket), do: ...
def handle_event({:scrolled_past, :show_back_to_top}, _params, 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 text: @text,
  on_change:  :text,      # fires on every change (including composing)
  on_compose: :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_event({:compose, _id, %{phase: :began}}, _params, socket),
  do: {:noreply, assign(socket, :composing, true)}

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

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

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

def handle_event({:change, _id, value}, _params, 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: :refresh,
     on_scroll: {:feed, throttle: 100} do
  text "Content"
end

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

Infinite scroll

scroll on_end_reached: :load_more do
  text "Content"
end

def handle_event({:end_reached, :load_more}, _params, 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: {:show_back_to_top, 600},
    on_top_reached:   :hide_back_to_top) do
    text "Long content"
  end

  if @show_back_to_top, do: button "↑", on_tap: :scroll_to_top
end

Card stack with swipe-to-dismiss

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

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

Photo viewer with pinch-to-zoom and pan

image src: @url,
      on_pinch: :zoom,
      on_drag:  :pan

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

def handle_event({:pinch, :zoom, %{scale: scale, phase: :dragging}}, _params, 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: :hide_chrome do
  image "hero.jpg",
    parallax: %{ratio: 0.5, container: :main}  # NEVER hits BEAM during scroll

  text "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: String.to_atom("contact_#{contact.id}")

# ✅ safe
on_tap: {:contact, contact.id}
on_tap: "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