Behaviour for native view components.
A component is a stateful Elixir process paired with a platform-native view registered by name on iOS/Android. The BEAM owns the state; the native side owns the rendering.
Lifecycle
- The parent screen declares
Mob.UI.native_view(MyComponent, id: :my_id, ...) - On first render, a
Mob.ComponentServerprocess is started andmount/2is called render/1is called to get the props map forwarded to the native factory- On subsequent renders,
update/2is called with new props from the parent - When the native view fires an event,
handle_event/3is called - After any state change,
render/1is re-called and native props are updated - When the component leaves the tree, the process is stopped and
terminate/2is called
Usage
defmodule MyApp.ChartComponent do
use Mob.Component
def mount(props, socket) do
{:ok, Mob.Socket.assign(socket, :data, props[:data])}
end
def render(assigns) do
%{data: assigns.data}
end
def handle_event("segment_tapped", %{"index" => i}, socket) do
{:noreply, Mob.Socket.assign(socket, :selected, i)}
end
endNative registration
Register the view factory at app startup:
# iOS (Swift) — strip "Elixir." prefix and replace "." with "_":
MobNativeViewRegistry.shared.register("MyApp_ChartComponent") { props, send in
AnyView(ChartView(data: props["data"]) { index in
send("segment_tapped", ["index": index])
})
}
# Android (Kotlin):
MobNativeViewRegistry.register("MyApp_ChartComponent") { props, send ->
ChartView(data = props["data"]) { index ->
send("segment_tapped", mapOf("index" to index))
}
}Declaration
Mob.UI.native_view(MyApp.ChartComponent, id: :revenue_chart, data: @points)The :id must be unique per screen. Duplicate ids on the same screen raise at render time.
Stateless components
If a component has no internal state, omit mount/2 and handle_info/2. The
default handle_event/3 raises for any event — add clauses for the events your
native view fires, or delegate to the parent screen by forwarding via send/2.
Summary
Functions
Walk a node tree, expanding :native_view nodes into serialisable form.
Callbacks
@callback handle_event(event :: String.t(), payload :: map(), socket :: Mob.Socket.t()) :: {:noreply, Mob.Socket.t()}
@callback handle_info(message :: term(), socket :: Mob.Socket.t()) :: {:noreply, Mob.Socket.t()}
@callback mount(props :: map(), socket :: Mob.Socket.t()) :: {:ok, Mob.Socket.t()} | {:error, term()}
@callback terminate(reason :: term(), socket :: Mob.Socket.t()) :: term()
@callback update(props :: map(), socket :: Mob.Socket.t()) :: {:ok, Mob.Socket.t()}
Functions
Walk a node tree, expanding :native_view nodes into serialisable form.
Starts or updates component processes, collects their rendered props, and
injects the NIF handle. Returns {expanded_tree, active_keys} where
active_keys is a MapSet of {id, module} pairs seen in this render —
used by the screen to stop components that have left the tree.
Every node this expands is marked with the component that drew it:
__mob_expanded__: {screen_pid, id, module, component_pid}, a key of the
node beside props (never sent to the native side). A node another
process marked passes through untouched and isn't counted as active, once
Mob.ComponentRegistry confirms that process still runs that very
component; one it doesn't confirm (forged, or from a component its owner
has stopped or replaced since, in a tree drawn after the owner moved on) is
drawn as an empty column. That's how a process that renders a subtree for
a screen (say, isolating untrusted screen code) uses native views in it:
it calls expand/3 with itself as screen_pid, calls
Mob.ComponentRegistry.reconcile/2 the same way, repaints on
{:component_changed, id, module}, and hands the screen the expanded tree.
If that subtree comes from code you don't trust, strip :__mob_expanded__
nodes from its render before expanding: the check proves a component
exists, not that the code may draw it.