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 | :downScroll — 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 deadzoneTier 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 IMECommit-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 list | Tier 2 — on_end_reached |
| Show a "back to top" button after 600 px | Tier 2 — on_scrolled_past |
| Hide a navbar while user is actively scrolling | Tier 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 px | Tier 3 — fade_on_scroll |
| Parallax a hero image | Tier 3 — parallax |
| Animate something as a function of scroll position | Tier 3 |
| You really need raw scroll deltas | Tier 1 (and explain why in code review) |
| Scroll-driven game / drawing canvas | Tier 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.0disables throttling.debounce: ms— only emit aftermsms 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. Defaulttrue.trailing: bool— emit the final event after debounce window expires. Defaulttruefor most events;falseforpointer_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
endWidget 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_sendper 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_sendper 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)}
endInfinite 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
endShow "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
endCard 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)}
endHero 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"
endAnti-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
event_model.md— design contract, address shape, ID rulesevent_audit.md— current native emitters, migration planPLAN.md— roadmap; what's done, what's coming