View Source Interactive SwiftUI Views

Run in Livebook

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.

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <Text>Hello, from LiveView Native!</Text>
    """
  end
end

defmodule ServerWeb.ExampleLive do
  use ServerWeb, :live_view
  use ServerNative, :live_view

  @impl true
  def render(assigns), do: ~H""
end

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.

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <Button phx-click="ping">Press me on native!</Button>
    """
  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

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.

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <Button phx-click="increment">Count: <%= @count %></Button>
    """
  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

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"""
    <!-- Displays the current count -->
    <Text><%= @count %></Text>

    <!-- Enter your solution below -->
    <Button phx-click="increment">Increment</Button>
    <Button phx-click="decrement">Decrement</Button>
    """
  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

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <!-- Displays the current count -->
    <Text><%= @count %></Text>

    <!-- Enter your solution below -->
    """
  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

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.

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <List selection={@selection} phx-change="selection-changed">
      <Text :for={i <- 1..10} id={"#{i}"}>Item <%= i %></Text>
    </List>
    """
  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

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.

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <List>
      <DisclosureGroup phx-change="toggle" isExpanded={@is_expanded}>
        <Text template="label">Level 1</Text>
        <Text>Item 1</Text>
        <Text>Item 2</Text>
        <Text>Item 3</Text>
      </DisclosureGroup>
    </List>
    """
  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

Multiple Expandable Lists

The next example shows one pattern for displaying multiple expandable lists without needing to write multiple event handlers.

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <List>
      <DisclosureGroup phx-change="toggle-1" isExpanded={@expanded_groups[1]}>
        <Text template="label">Level 1</Text>
        <Text>Item 1</Text>
        <DisclosureGroup phx-change="toggle-2" isExpanded={@expanded_groups[2]}>
          <Text template="label">Level 2</Text>
          <Text>Item 2</Text>
        </DisclosureGroup>
      </DisclosureGroup>
    </List>
    """
  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

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.

<LiveForm id="some-id" phx-change="change" phx-submit="submit">
  <!-- inputs go here -->
  <LiveSubmitButton>Button Text</LiveSubmitButton>
</LiveForm>

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.

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <LiveForm id="my-form" phx-change="change" phx-submit="submit">
      <TextField name="my-input">Enter text here</TextField>
      <LiveSubmitButton>Submit</LiveSubmitButton>
    </LiveForm>
    """
  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

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.

<LiveForm id="my-form">
  <TextField name="my-input" phx-change="change">Enter text here</TextField>
  <LiveSubmitButton>Submit</LiveSubmitButton>
</LiveForm>

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.

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <LiveForm id="my-form" phx-change="slide">
      <Slider
        name="my-slider"
        lowerBound={0}
        upperBound={100}
        step={1}
      >
        <Text template={:minimumValueLabel}>0%</Text>
        <Text template={:maximumValueLabel}>100%</Text>
      </Slider>
    </LiveForm>
    <Text><%= @percentage %></Text>
    """
  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

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.

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <LiveForm id="my-form">
      <Stepper
        name="my-stepper"
        lower-bound={0}
        upper-bound={16}
        step={1}
        phx-change="change-tickets"
      >
        Tickets <%= @tickets %>
      </Stepper>
    </LiveForm>
    """
  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

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.

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <LiveForm id="my-form" phx-change="toggle">
      <Toggle isOn={@on} name="my-toggle">On/Off</Toggle>
    </LiveForm>
    """
  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

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.

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <LiveForm id="my-form" phx-change="pick-date">
      <DatePicker name="my-date-picker"/>
    </LiveForm>
    """
  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

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.

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <LiveForm id="my-form" phx-change="pick-date">
      <DatePicker displayedComponents="date" name="my-date-picker" />
    </LiveForm>
    """
  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

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 a List 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"""
    <LiveForm id="my-form" phx-change="type-name" >
      <TextField name="text" text={@item_name}>Todo...</TextField>
    </LiveForm>
    <Button phx-click="add-item">Add Item</Button>
    <Button phx-click="delete-item">Delete Item</Button>
    <List selection={@selection} phx-change="selection-changed">
      <Text id={id} :for={{id, content} <- @items}><%= content %></Text>
    </List>
    """
  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

defmodule ServerWeb.ExampleLive.SwiftUI do
  use ServerNative, [:render_component, format: :swiftui]

  def render(assigns) do
    ~LVN"""
    <!-- Enter your solution below -->
    """
  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