Building for Web and Native with LiveView Native

Building for Web and Native with LiveView Native

Yesterday at Elixir Conf 2022 Dockyard announced the LiveView Native project. LiveView Native is a SwiftUI native iOS package that allows Phoenix LiveView on the server to drive the UI of an iOS app.

There is an excellent tutorial in the documentation you can follow that builds a very basic starter application. I recommend following that to get an idea of how it works.

One very interesting aspect of using LiveView to power a Native app is that it should be possible to create an application that can run on iOS and the web from a single codebase. To explore this, I modified the tutorial application slightly to render equivalent html when the socket connection is from a web browser. It turned out to be trivially easy to do.

Here is what we're trying to build:

0:00
/
Same code on iOS and Web

First, the LiveViews need to know if the client is web or iOS. Conveniently, LiveView Native socket client passes a platform param on connect that we can assign in an on_mount hook.

Wrap all live views in a live_session with an on_mount hook. Every time a live view in the live_session is mounted LvnTutorialWeb.InitAssigns.on_mount/4 will be called.

live_session :default, on_mount: [{LvnTutorialWeb.InitAssigns, :default}] do
  live("/cats", CatsListLive)
  live("/cats/:name", CatLive)
end

Now let's create that new module at lib/lvn_tutorial_web/init_assigns.ex

defmodule LvnTutorialWeb.InitAssigns do
  import Phoenix.LiveView

  def on_mount(:default, _params, _session, socket) do
    case get_connect_params(socket) do
      %{"_platform" => "ios"} -> {:cont, socket |> assign(:platform, :ios)}
      _ -> {:cont, socket |> assign(:platform, :web)}
    end
  end
end

This will read the _platform param from the connection params and assign
either :ios or :web to all LiveView sockets. Now in our LiveViews we can
conditionally render based on the platform param in assigns.

Note: on_mount/4 runs on the initial mount and the socket mount for every connection. The first call will not have socket params and therefore the case will fall through to the :web case. This is not an issue because LiveView Native appears to not render until the socket is connected, thus only getting the iOS template code.

Now that we are assigning the platform param to every LiveView socket, we can conditionally render the native app UI or the web UI.

Let's update cats_list_live.ex to the code below that will render either HTML or LiveView Native templates based on the platform. I didn't implement the Genserver or favorites from the tutorial for this example.

The examples below use Tailwind for basic styling of the HTML

defmodule LvnTutorialWeb.CatsListLive do
  use LvnTutorialWeb, :live_view

  @cats [
    "Clenil",
    "Flippers",
    "Jorts",
    "Kipper",
    "Lemmy",
    "Lissy",
    "Mikkel",
    "Minka",
    "Misty",
    "Nelly",
    "Ninj",
    "Pollito",
    "Siegfried",
    "Truman",
    "Washy"
  ]

  def mount(_params, _session, socket) do
    {:ok, assign(socket, cats: @cats)}
  end

  def render(%{platform: :ios} = assigns) do
    ~H"""
      <list nav-title="CatMatchr®">
        <%= for name <- @cats do %>
          <navigationlink id={name} data-phx-link="redirect" data-phx-link-state="push" data-phx-href={Routes.live_path(@socket, LvnTutorialWeb.CatLive, name)}>
            <hstack>
              <asyncimage src={"/images/cats/#{name}.jpg"} frame-width="100" frame-height="100" />
              <text><%= name %></text>
              <spacer />
            </hstack>
          </navigationlink>
        <% end %>
      </list>
    """
  end

  def render(%{platform: :web} = assigns) do
    ~H"""
      <div>
        <h1 class="px-4 py-6 -mb-2 text-3xl font-bold">CatMatchr®</h1>
        <ul class="divide-y">
          <%= for name <- @cats do %>
            <li id={name}>
              <%= live_redirect(to: Routes.live_path(@socket, LvnTutorialWeb.CatLive, name), class: "flex items-center px-4 py-2") do %>
                <img src={"/images/cats/#{name}.jpg"} width="100" height="100" />
                <h2 class="ml-4"><%= name %></h2>
              <% end %>
            </li>
          <% end %>
        </ul>
      </div>
    """
  end
end

In cat_live.ex we can do the same. This time a very naive pubsub implementation is used to increment likes across all devices. You can see the feedback from a click in the iOS app on the web app.

defmodule LvnTutorialWeb.CatLive do
  use LvnTutorialWeb, :live_view

  def mount(%{"name" => name}, _session, socket) do
    if connected?(socket) do
      Phoenix.PubSub.subscribe(LvnTutorial.PubSub, "cat:#{name}")
    end

    {:ok,
     assign(socket, %{
       name: name,
       likes: 0
     })}
  end

  @impl true
  def handle_info(%Phoenix.Socket.Broadcast{event: "increment-like", payload: _diff}, socket) do
    {:noreply, assign(socket, likes: socket.assigns.likes + 1)}
  end

  @impl true
  def handle_event("increment-like", _params, socket) do
    LvnTutorialWeb.Endpoint.broadcast("cat:#{socket.assigns.name}", "increment-like", %{})
    {:noreply, socket}
  end

  def render(%{platform: :ios} = assigns) do
    ~H"""
      <vstack nav-title={@name}>
        <asyncimage src={"/images/cats/#{@name}.jpg"} frame-width="300" frame-height="300" />
        <text><%= @likes %> like<%= if @likes == 1, do: "", else: "s" %></text>
        <button phx-click="increment-like">
          <text>Add a Like</text>
        </button>
      </vstack>
    """
  end

  def render(%{platform: :web} = assigns) do
    ~H"""
      <div class="h-full flex flex-col justify-center items-center space-y-4">
        <h1 class="text-2xl font-bold"><%= @name %></h1>
        <img src={"/images/cats/#{@name}.jpg"} width="300" height="300" />
        <p><%= @likes %> like<%= if @likes == 1, do: "", else: "s" %></p>
        <button phx-click="increment-like">Add a like</button>
      </div>
    """
  end
end

Let's do a bit of tweaking to the HTML layout to get the UI looking more like the mobile UI. Obviously you could style things however you like since maybe you don't want them to look the same. But for this example I do.

Make the HTML LiveView container a full-height div in lvn_tutorial_web.ex.

  def live_view do
    quote do
      use Phoenix.LiveView,
        layout: {LvnTutorialWeb.LayoutView, "live.html"},
        container: {:div, class: "h-full"}

      unquote(view_helpers())
    end
  end

Also make html and body full height and strip the default Phoenix HTML in your root.html.heex template.

<!DOCTYPE html>
<html lang="en" class="h-full">
  <head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <meta name="csrf-token" content={csrf_token_value()}>
    <%= live_title_tag assigns[:page_title] || "LvnTutorial", suffix: " · Phoenix Framework" %>
    <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/assets/app.css")}/>
    <script defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script>
  </head>
  <body class="h-full">
    <%= @inner_content %>
  </body>
</html>

This should give us HTML that looks somewhat like the iOS app.

It's unclear if or how well this hybrid HTML/Native technique could scale to a larger application (or how well LiveView Native will scale itself), but the fact that it is possible at all is pretty impressive.

Kudos and thanks to the Dockyard team.