Packages
mob
0.5.1
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.26
0.6.25
0.6.24
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.2
0.6.1
0.6.0
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.11
0.5.10
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.0
BEAM-on-device mobile framework for Elixir
Current section
Files
Jump to
Current section
Files
lib/mob/component.ex
defmodule Mob.Component do
@moduledoc """
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
1. The parent screen declares `Mob.UI.native_view(MyComponent, id: :my_id, ...)`
2. On first render, a `Mob.ComponentServer` process is started and `mount/2` is called
3. `render/1` is called to get the props map forwarded to the native factory
4. On subsequent renders, `update/2` is called with new props from the parent
5. When the native view fires an event, `handle_event/3` is called
6. After any state change, `render/1` is re-called and native props are updated
7. When the component leaves the tree, the process is stopped and `terminate/2` is 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
end
## Native 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`.
"""
@callback mount(props :: map(), socket :: Mob.Socket.t()) ::
{:ok, Mob.Socket.t()} | {:error, term()}
@callback update(props :: map(), socket :: Mob.Socket.t()) ::
{:ok, Mob.Socket.t()}
@callback render(assigns :: map()) :: map()
@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 terminate(reason :: term(), socket :: Mob.Socket.t()) :: term()
@optional_callbacks [update: 2, handle_event: 3, handle_info: 2, terminate: 2]
defmacro __using__(_opts) do
quote do
@behaviour Mob.Component
def mount(props, socket), do: {:ok, socket}
def update(props, socket), do: mount(props, socket)
def handle_event(event, _payload, _socket) do
raise RuntimeError,
"unhandled component event #{inspect(event)} in #{inspect(__MODULE__)}. " <>
"Add a handle_event/3 clause to handle it."
end
def handle_info(_message, socket), do: {:noreply, socket}
def terminate(_reason, _socket), do: :ok
defoverridable mount: 2, update: 2, handle_event: 3, handle_info: 2, terminate: 2
end
end
# ── Tree expansion ────────────────────────────────────────────────────────
@doc """
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.
"""
@spec expand(map(), pid(), atom()) :: {map(), MapSet.t()}
def expand(tree, screen_pid, platform) do
active = MapSet.new()
walk(tree, screen_pid, platform, active)
end
defp walk(%{type: :native_view, props: props} = node, screen_pid, platform, active) do
module = props[:module]
id = props[:id]
unless is_atom(module) and is_atom(id) do
raise ArgumentError,
"Mob.UI.native_view requires :module and :id as atoms, got: #{inspect(props)}"
end
component_pid = ensure_started(screen_pid, id, module, props, platform)
rendered_props = Mob.ComponentServer.render_props(component_pid)
handle = Mob.ComponentServer.get_handle(component_pid)
enriched =
Map.merge(rendered_props, %{
module: module_name(module),
id: Atom.to_string(id),
component_handle: handle
})
active = MapSet.put(active, {id, module})
{%{node | props: enriched}, active}
end
defp walk(%{children: children} = node, screen_pid, platform, active) do
{new_children, active} =
Enum.map_reduce(children, active, fn child, acc ->
walk(child, screen_pid, platform, acc)
end)
{%{node | children: new_children}, active}
end
defp walk(node, _screen_pid, _platform, active), do: {node, active}
defp ensure_started(screen_pid, id, module, props, platform) do
case Mob.ComponentRegistry.lookup(screen_pid, id, module) do
{:ok, pid} ->
Mob.ComponentServer.update(pid, props)
pid
{:error, :not_found} ->
{:ok, pid} =
Mob.ComponentServer.start(
module: module,
id: id,
screen_pid: screen_pid,
props: props,
platform: platform
)
pid
end
end
# "Elixir.MyApp.ChartComponent" → "MyApp_ChartComponent"
defp module_name(module) do
module
|> Atom.to_string()
|> String.replace_prefix("Elixir.", "")
|> String.replace(".", "_")
end
end