Interactive SwiftUI Views
notebook_path = __ENV__.file |> String.split("#") |> hd()
Mix.install(
[
{:kino_live_view_native, github: "liveview-native/kino_live_view_native"}
],
config: [
server: [
{ServerWeb.Endpoint,
[
server: true,
url: [host: "localhost"],
adapter: Phoenix.Endpoint.Cowboy2Adapter,
render_errors: [
formats: [html: ServerWeb.ErrorHTML, json: ServerWeb.ErrorJSON],
layout: false
],
pubsub_server: Server.PubSub,
live_view: [signing_salt: "JSgdVVL6"],
http: [ip: {0, 0, 0, 0}, port: 4000],
check_origin: false,
secret_key_base: String.duplicate("a", 64),
live_reload: [
patterns: [
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg|styles)$",
~r/#{notebook_path}$/
]
]
]}
],
kino: [
group_leader: Process.group_leader()
],
phoenix: [
template_engines: [neex: LiveViewNative.Engine]
],
phoenix_template: [format_encoders: [swiftui: Phoenix.HTML.Engine]],
mime: [
types: %{"text/swiftui" => ["swiftui"], "text/styles" => ["styles"]}
],
live_view_native: [plugins: [LiveViewNative.SwiftUI]],
live_view_native_stylesheet: [
attribute_parsers: [
style: [
livemd: &Server.AttributeParsers.Style.parse/2
]
],
content: [
swiftui: [
"lib/**/*swiftui*",
notebook_path
]
],
pretty: true,
output: "priv/static/assets"
]
],
force: true
)
Overview
In this guide, you’ll learn how to build interactive LiveView Native applications using event bindings.
This guide assumes some existing familiarity with Phoenix Bindings and how to set/access state stored in the LiveView’s socket assigns. To get the most out of this material, you should already understand the assign/3
/assign/2
function, and how event bindings such as phx-click
interact with the handle_event/3
callback function.
We’ll use the following LiveView and define new render component examples throughout the guide.
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
Hello, from LiveView Native!
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
@impl true
def render(assigns), do: ~H""
end
|> Server.SmartCells.LiveViewNative.register("/")
import Server.Livebook, only: []
import Kernel
:ok
Event Bindings
We can bind any available phx-*
Phoenix Binding to a SwiftUI Element. However certain events are not available on native.
LiveView Native currently supports the following events on all SwiftUI views:
-
phx-window-focus
: Fired when the application window gains focus, indicating user interaction with the Native app. -
phx-window-blur
: Fired when the application window loses focus, indicating the user’s switch to other apps or screens. -
phx-focus
: Fired when a specific native UI element gains focus, often used for input fields. -
phx-blur
: Fired when a specific native UI element loses focus, commonly used with input fields. -
phx-click
: Fired when a user taps on a native UI element, enabling a response to tap events.
> The above events work on all SwiftUI views. Some events are only available on specific views. For example, phx-change
is available on controls and phx-throttle/phx-debounce
is available on views with events.
There is also a Pull Request to add Key Events which may have been merged since this guide was published.
Basic Click Example
The phx-click
event triggers a corresponding handle_event/3 callback function whenever a SwiftUI view is pressed.
In the example below, the client sends a "ping"
event to the server, and trigger’s the LiveView’s "ping"
event handler.
Evaluate the example below, then click the "Click me!"
button. Notice "Pong"
printed in the server logs below.
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
Press me on native!
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
@impl true
def render(assigns), do: ~H""
@impl true
def handle_event("ping", _params, socket) do
IO.puts("Pong")
{:noreply, socket}
end
end
|> Server.SmartCells.LiveViewNative.register("/")
import Server.Livebook, only: []
import Kernel
:ok
Click Events Updating State
Event handlers in LiveView can update the LiveView’s state in the socket.
Evaluate the cell below to see an example of incrementing a count.
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
Count: <%= @count %>
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :count, 0)}
end
@impl true
def render(assigns), do: ~H""
@impl true
def handle_event("increment", _params, socket) do
{:noreply, assign(socket, :count, socket.assigns.count + 1)}
end
end
|> Server.SmartCells.LiveViewNative.register("/")
import Server.Livebook, only: []
import Kernel
:ok
Your Turn: Decrement Counter
You’re going to take the example above, and create a counter that can both increment and decrement.
There should be two buttons, each with a phx-click
binding. One button should bind the "decrement"
event, and the other button should bind the "increment"
event. Each event should have a corresponding handler defined using the handle_event/3
callback function.
Example Solution
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
<%= @count %>
Increment
Decrement
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :count, 0)}
end
@impl true
def render(assigns), do: ~H""
@impl true
def handle_event("increment", _params, socket) do
{:noreply, assign(socket, :count, socket.assigns.count + 1)}
end
def handle_event("decrement", _params, socket) do
{:noreply, assign(socket, :count, socket.assigns.count - 1)}
end
end
Enter Your Solution Below
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
<%= @count %>
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :count, 0)}
end
@impl true
def render(assigns), do: ~H""
end
|> Server.SmartCells.LiveViewNative.register("/")
import Server.Livebook, only: []
import Kernel
:ok
Selectable Lists
List
views support selecting items within the list based on their id. To select an item, provide the selection
attribute with the item’s id.
Pressing a child item in the List
on a native device triggers the phx-change
event. In the example below we’ve bound the phx-change
event to send the "selection-changed"
event. This event is then handled by the handle_event/3
callback function and used to change the selected item.
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
Item <%= i %>
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, selection: "None")}
end
@impl true
def render(assigns), do: ~H""
@impl true
def handle_event("selection-changed", %{"selection" => selection}, socket) do
{:noreply, assign(socket, selection: selection)}
end
end
|> Server.SmartCells.LiveViewNative.register("/")
import Server.Livebook, only: []
import Kernel
:ok
Expandable Lists
List
views support hierarchical content using the DisclosureGroup view. Nest DisclosureGroup
views within a list to create multiple levels of content as seen in the example below.
To control a DisclosureGroup
view, use the isExpanded
boolean attribute as seen in the example below.
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
Level 1
Item 1
Item 2
Item 3
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :is_expanded, false)}
end
@impl true
def render(assigns), do: ~H""
@impl true
def handle_event("toggle", %{"isExpanded" => is_expanded}, socket) do
{:noreply, assign(socket, is_expanded: is_expanded)}
end
end
|> Server.SmartCells.LiveViewNative.register("/")
import Server.Livebook, only: []
import Kernel
:ok
Multiple Expandable Lists
The next example shows one pattern for displaying multiple expandable lists without needing to write multiple event handlers.
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
Level 1
Item 1
Level 2
Item 2
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :expanded_groups, %{1 => false, 2 => false})}
end
@impl true
def render(assigns), do: ~H""
@impl true
def handle_event("toggle-" <> level, %{"isExpanded" => is_expanded}, socket) do
level = String.to_integer(level)
{:noreply,
assign(
socket,
:expanded_groups,
Map.replace!(socket.assigns.expanded_groups, level, is_expanded)
)}
end
end
|> Server.SmartCells.LiveViewNative.register("/")
import Server.Livebook, only: []
import Kernel
:ok
Forms
In Phoenix, form elements must be inside of a form. Phoenix only captures events if the element is wrapped in a form. However in SwiftUI there is no similar concept of forms. To bridge the gap, we built the LiveView Native Live Form library. This library provides several views to enable writing views in a single form.
Phoenix Applications setup with LiveView native include a core_components.ex
file. This file contains several components for building forms. Generally, We recommend using core components rather than the views. We’re going to cover the views directly so you understand how to build forms from scratch and how we built the core components. However, in the Forms and Validations reading we’ll cover using core components.
LiveForm
The LiveForm
view must wrap views to capture events from the phx-change
or phx-submit
event. The phx-change
event sends a message to the LiveView anytime the control or indicator changes its value. The phx-submit
event sends a message to the LiveView when a user clicks the LiveSubmitButton
. The params of the message are based on the name of the Binding argument of the view’s initializer in SwiftUI.
Here’s some example boilerplate for a LiveForm
. The id
attribute is required.
Button Text
Basic Example using TextField
The following example shows you how to connect a SwiftUI TextField with a phx-change
event and phx-submit
binding to a corresponding event handler.
Evaluate the example below. Type into the text field and press submit on your iOS simulator. Notice the inspected params
appear in the server logs in the console below as a map of %{"my-input" => value}
based on the name
attribute on the TextField
view.
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
Enter text here
Submit
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
require Logger
@impl true
def render(assigns), do: ~H""
@impl true
def handle_event("change", params, socket) do
Logger.info("Change params: #{inspect(params)}")
{:noreply, socket}
end
@impl true
def handle_event("submit", params, socket) do
Logger.info("Submitted params: #{inspect(params)}")
{:noreply, socket}
end
end
|> Server.SmartCells.LiveViewNative.register("/")
import Server.Livebook, only: []
import Kernel
:ok
Event Handlers
The phx-change
and phx-submit
event handlers should generally be bound to the LiveForm. However, you can also bind the event handlers directly to the input view if you want to separately handle a single view’s change events.
Enter text here
Submit
Controls and Indicators
SwiftUI organizes interactive views in the Controls and Indicators section. You may refer to this documentation when looking for views that belong within a form.
We’ll demonstrate how to work with a few common control and indicator views.
Slider
This code example renders a SwiftUI Slider. It triggers the change event when the slider is moved and sends a "slide"
message. The "slide"
event handler then logs the value to the console.
The Slider view uses named content areas minumumValueLabel
and maximumValueLabel
. The example below demonstrates how to represent these areas using the template
attribute.
This example also demonstrates how to use the params sent by the slider to store a value in the socket and use it elsewhere in the template.
Evaluate the example and enter some text in your iOS simulator.
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
0%
100%
<%= @percentage %>
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :percentage, 0)}
end
@impl true
def render(assigns), do: ~H""
@impl true
def handle_event("slide", %{"my-slider" => value}, socket) do
{:noreply, assign(socket, :percentage, value)}
end
end
|> Server.SmartCells.LiveViewNative.register("/")
import Server.Livebook, only: []
import Kernel
:ok
Stepper
This code example renders a SwiftUI Stepper. It triggers the change event and sends a "change-tickets"
message when the stepper increments or decrements. The "change-tickets"
event handler then updates the number of tickets stored in state, which appears in the UI.
Evaluate the example and increment/decrement the step.
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
Tickets <%= @tickets %>
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :tickets, 0)}
end
@impl true
def render(assigns), do: ~H""
@impl true
def handle_event("change-tickets", %{"my-stepper" => tickets}, socket) do
{:noreply, assign(socket, :tickets, tickets)}
end
end
|> Server.SmartCells.LiveViewNative.register("/")
import Server.Livebook, only: []
import Kernel
:ok
Toggle
This code example renders a SwiftUI Toggle. It triggers the change event and sends a "toggle"
message when toggled. The "toggle"
event handler then updates the :on
field in state, which allows the Toggle
view to be toggled o through the isOn
attribute.
Evaluate the example below and click on the toggle.
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
On/Off
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :on, false)}
end
@impl true
def render(assigns), do: ~H""
@impl true
def handle_event("toggle", %{"my-toggle" => on}, socket) do
{:noreply, assign(socket, :on, on)}
end
end
|> Server.SmartCells.LiveViewNative.register("/")
import Server.Livebook, only: []
import Kernel
:ok
DatePicker
The SwiftUI Date Picker provides a native view for selecting a date. The date is selected by the user and sent back as a string. Evaluate the example below and select a date to see the date params appear in the console below.
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
require Logger
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, :date, nil)}
end
@impl true
def render(assigns), do: ~H""
@impl true
def handle_event("pick-date", params, socket) do
Logger.info("Date Params: #{inspect(params)}")
{:noreply, socket}
end
end
|> Server.SmartCells.LiveViewNative.register("/")
import Server.Livebook, only: []
import Kernel
:ok
Parsing Dates
The date from the DatePicker
is in iso8601 format. You can use the from_iso8601
function to parse this string into a DateTime
struct.
iso8601 = "2024-01-17T20:51:00.000Z"
DateTime.from_iso8601(iso8601)
Your Turn: Displayed Components
The DatePicker
view accepts a displayedComponents
attribute with the value of "hourAndMinute"
or "date"
to only display one of the two components. By default, the value is "all"
.
You’re going to change the displayedComponents
attribute in the example below to see both of these options. Change "all"
to "date"
, then to "hourAndMinute"
. Re-evaluate the cell between changes and see the updated UI.
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
@impl true
def render(assigns), do: ~H""
@impl true
def handle_event("pick-date", _params, socket) do
{:noreply, socket}
end
end
|> Server.SmartCells.LiveViewNative.register("/")
import Server.Livebook, only: []
import Kernel
:ok
Small Project: Todo List
Using the previous examples as inspiration, you’re going to create a todo list.
Requirements
-
Items should be
Text
views rendered within aList
view. -
Item ids should be stored in state as a list of integers i.e.
[1, 2, 3, 4]
-
Use a
TextField
to provide the name of the next added todo item. -
An add item
Button
should add items to the list of integers in state when pressed. -
A delete item
Button
should remove the currently selected item from the list of integers in state when pressed.
Example Solution
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
Todo...
Add Item
Delete Item
<%= content %>
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok, assign(socket, items: [], selection: "None", item_name: "", next_item_id: 1)}
end
@impl true
def render(assigns), do: ~H""
@impl true
def handle_event("type-name", %{"text" => name}, socket) do
{:noreply, assign(socket, :item_name, name)}
end
def handle_event("add-item", _params, socket) do
updated_items = [
{"item-#{socket.assigns.next_item_id}", socket.assigns.item_name}
| socket.assigns.items
]
{:noreply,
assign(socket,
item_name: "",
items: updated_items,
next_item_id: socket.assigns.next_item_id + 1
)}
end
def handle_event("delete-item", _params, socket) do
updated_items =
Enum.reject(socket.assigns.items, fn {id, _name} -> id == socket.assigns.selection end)
{:noreply, assign(socket, :items, updated_items)}
end
def handle_event("selection-changed", %{"selection" => selection}, socket) do
{:noreply, assign(socket, selection: selection)}
end
end
Enter Your Solution Below
require Server.Livebook
import Server.Livebook
import Kernel, except: [defmodule: 2]
defmodule ServerWeb.ExampleLive.SwiftUI do
use ServerNative, [:render_component, format: :swiftui]
def render(assigns) do
~LVN"""
"""
end
end
defmodule ServerWeb.ExampleLive do
use ServerWeb, :live_view
use ServerNative, :live_view
# Define your mount/3 callback
@impl true
def render(assigns), do: ~H""
# Define your render/3 callback
# Define any handle_event/3 callbacks
end
|> Server.SmartCells.LiveViewNative.register("/")
import Server.Livebook, only: []
import Kernel
:ok