Current section

Files

Jump to
dala lib dala ui native_view.ex
Raw

lib/dala/ui/native_view.ex

defmodule Dala.Ui.NativeView 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 `Dala.Ui.Widgets.native_view(MyComponent, id: :my_id, ...)`
2. On first render, a `Dala.Ui.NativeView.Server` 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 Dala.Ui.NativeView
def mount(props, socket) do
{:ok, Dala.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, Dala.Socket.assign(socket, :selected, i)}
end
end
## Native registration
Register the view factory at app startup:
# iOS (Swift) — strip "Elixir." prefix and replace "." with "_":
dalaNativeViewRegistry.shared.register("MyApp_ChartComponent") { props, send in
AnyView(ChartView(data: props["data"]) { index in
send("segment_tapped", ["index": index])
})
}
# Android (Kotlin):
dalaNativeViewRegistry.register("MyApp_ChartComponent") { props, send ->
ChartView(data = props["data"]) { index ->
send("segment_tapped", mapOf("index" to index))
}
}
## Declaration
Dala.Ui.Widgets.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`.
## Plugin Components
Plugin components are registered via `Dala.Plugin` and use string type names
instead of atoms. They are automatically expanded using the same lifecycle
as native_view components, but without requiring a custom Elixir module.
Example:
%{type: "video", props: %{source: "...", autoplay: true}, children: []}
"""
@callback mount(props :: map(), socket :: Dala.Socket.t()) ::
{:ok, Dala.Socket.t()} | {:error, term()}
@callback update(props :: map(), socket :: Dala.Socket.t()) ::
{:ok, Dala.Socket.t()}
@callback render(assigns :: map()) :: map()
@callback handle_event(event :: String.t(), payload :: map(), socket :: Dala.Socket.t()) ::
{:noreply, Dala.Socket.t()}
@callback handle_info(message :: term(), socket :: Dala.Socket.t()) ::
{:noreply, Dala.Socket.t()}
@callback terminate(reason :: term(), socket :: Dala.Socket.t()) :: term()
@optional_callbacks [update: 2, handle_event: 3, handle_info: 2, terminate: 2]
defmacro __using__(_opts) do
quote do
@behaviour Dala.Ui.NativeView
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.
Also expands plugin-based components (registered via Dala.Plugin).
"""
@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,
"Dala.Ui.Widgets.native_view requires :module and :id as atoms, got: #{inspect(props)}"
end
component_pid = ensure_started(screen_pid, id, module, props, platform)
rendered_props = Dala.Ui.NativeView.Server.render_props(component_pid)
handle = Dala.Ui.NativeView.Server.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(%{type: type, props: props} = node, screen_pid, platform, active)
when is_binary(type) do
# Check if this is a plugin component
case Dala.Plugin.Registry.lookup_component(type) do
{:ok, _plugin} ->
# Plugin component - treat as native_view with auto-generated module
id =
props[:id] ||
raise ArgumentError,
"Plugin component #{type} requires :id prop"
# Use a tuple key instead of dynamic atom to avoid atom table pollution
module = {:plugin_component, type}
component_pid = ensure_started(screen_pid, id, module, props, platform)
rendered_props = Dala.Ui.NativeView.Server.render_props(component_pid)
handle = Dala.Ui.NativeView.Server.get_handle(component_pid)
enriched =
Map.merge(rendered_props, %{
module: type,
id: to_string(id),
component_handle: handle
})
active = MapSet.put(active, {id, module})
{%{node | props: enriched}, active}
{:error, :not_found} ->
# Not a plugin component, continue normal walk
walk_children(node, screen_pid, platform, active)
end
end
defp walk(%{children: _children} = node, screen_pid, platform, active) do
walk_children(node, screen_pid, platform, active)
end
defp walk(node, _screen_pid, _platform, active), do: {node, active}
defp walk_children(%{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 ensure_started(screen_pid, id, module, props, platform) do
case Dala.Ui.NativeView.Registry.lookup(screen_pid, id, module) do
{:ok, pid} ->
Dala.Ui.NativeView.Server.update(pid, props)
pid
{:error, :not_found} ->
{:ok, pid} =
Dala.Ui.NativeView.Server.start(
module: module,
id: id,
screen_pid: screen_pid,
props: props,
platform: platform
)
pid
end
end
# "Elixir.MyApp.ChartComponent" → "MyApp_ChartComponent"
# Cached per-module since the same module appears on every render.
defp module_name(module) do
case :ets.lookup(:dala_module_names, module) do
[{^module, name}] -> name
[] ->
name =
module
|> Atom.to_string()
|> String.replace_prefix("Elixir.", "")
|> String.replace(".", "_")
:ets.insert(:dala_module_names, {module, name})
name
end
end
@doc false
@spec init_module_name_cache() :: :ok
def init_module_name_cache do
case :ets.whereis(:dala_module_names) do
:undefined ->
:ets.new(:dala_module_names, [:set, :public, :named_table, read_concurrency: true])
_ ->
:ok
end
:ok
end
end