Understanding Phoenix LiveView: build a Gallery app

Understanding Phoenix LiveView

In the previous articles we’ve seen how to setup Phoenix LiveView and built a counter (which is like the “Hello World” code example in the LiveView world). We’ve also taken a look at the live-cycle, inspecting websocket messages, having a glimpse of how the things work under the hood.

It’s now time to build our Gallery app! For simplicity we are not going to use any database. Our images will be just a fixed set of urls taken from Unsplash.

Gallery app with Slideshow

We are about to see different things, starting with a simple draft of the app and then going through a refactoring of the code, and building features like thumbnails and a slideshow option.

From a counter to a gallery

We start by using the counter’s code we’ve seen in the previous article.
At the moment we aim to just get a working draft of a gallery – we’ll spend time later to refactor the code and add more functionalities.

# lib/gallery_web/live/gallery_live.ex

defmodule GalleryWeb.GalleryLive do
  use Phoenix.LiveView

  def mount(_params, _session, socket) do
    {:ok, assign(socket, :counter, 0)}
  end

  def render(assigns) do
    ~L"""
    <label>Counter: <%= @counter %></label>
    <button phx-click="incr">+</button>
    """
  end

  def handle_event("incr", _, socket) do
    {:noreply, update(socket, :counter, &(&1 + 1))}
  end
end

So we start by copying the counter code in the GalleryLive module, checking that the correct route is in place in lib/gallery_web/router.ex

# lib/gallery_web/router.ex

defmodule GalleryWeb.Router do
  use GalleryWeb, :router
  ...

  scope "/", GalleryWeb do
    ...
    live "/gallery", GalleryLive
  end

end

We have a fixed list of image urls taken from unsplash.com

[
  "https://images.unsplash.com/photo-1562971179-4ad6903a7ed6?h=500&fit=crop",
  "https://images.unsplash.com/photo-1552673597-e3cd6747a996?h=500&fit=crop",
  "https://images.unsplash.com/photo-1561133036-61a7ed56b424?h=500&fit=crop",
  "https://images.unsplash.com/photo-1530717449302-271006cdc1bf?h=500&fit=crop"
]

and we need an index to go through these images, incrementing and decrementing it like we did for the counter

Image list and Index
Image list and Index

At the beginning the index is 0 and it points to the first image. By incrementing it, the index will then point to the second, third and fourth image. Once reached the end of the list, it will go back to the first one.

Let’s rename :counter to :idx in mount/2. Then, we change the template in render/1, adding two new buttons: prev and next. They send two different "prev" and "next" events, which we handle separately.

defmodule GalleryWeb.GalleryLive do
  use Phoenix.LiveView

  def mount(_params, _session, socket) do
    {:ok, assign(socket, :idx, 0)}
  end
	
  def render(assigns) do
    ~L"""
    <label>Image Index: <%= @idx %></label>
    <button phx-click="prev">Prev</button>
    <button phx-click="next">Next</button>
   """
  end
	
  def handle_event("prev", _event, socket) do
    {:noreply, update(socket, :idx, &(&1 - 1)}
  end
	
  def handle_event("next", _event, socket) do
    {:noreply, update(socket, :idx, &(&1 + 1)}
  end
end
  • handle_event("next", _event, socket) is identical to the "incr" counter’s version, and it increments the :idx by 1.
  • handle_event("prev", _event, socket) instead decrements :idx by 1.
Prev decrements and Next increments
Prev decrements and Next increments

It’s better to move these updates in two separate functions: assign_prev_idx/1 and assign_next_idx/1.

def handle_event("prev", _event, socket) do
  {:noreply, assign_prev_idx(socket)}
end

def handle_event("next", _event, socket) do
  {:noreply, assign_next_idx(socket)}
end

def assign_prev_idx(socket) do
  socket
  |> update(:idx, &(&1 - 1))
end

def assign_next_idx(socket) do
  socket
  |> update(:idx, &(&1 + 1))
end

They take a socket and update/3 the :idx returning a new socket. There are different reasons why I prefer to move the update part into a different function, outside handle_event. With just an update/3 the advantage of doing so maybe is not that obvious, but in this way we keep the handle_event/3 cleaner, the update’s logic easier to test, and if we give a good name to the new function it’s also clearer what our handle_event/3 callback does.

To show the images, we define a new function called image(idx), where the argument is the index, and it returns an image url.

def image(idx) do
  [
    "https://images.unsplash.com/photo-1562971179-4ad6903a7ed6?h=500&fit=crop",
    "https://images.unsplash.com/photo-1552673597-e3cd6747a996?h=500&fit=crop",
    "https://images.unsplash.com/photo-1561133036-61a7ed56b424?h=500&fit=crop",
    "https://images.unsplash.com/photo-1530717449302-271006cdc1bf?h=500&fit=crop"
  ]
  |> Enum.at(idx)
end

For the sake of simplicity, image/1 uses a small fixed list of image urls.
We use Enum.at/2 to get the image url at the index idx. Enum.at/2 works also with a negative index, going through the list in reverse order

iex> ["a", "b", "c"] |> Enum.at(-1)
"c"

iex> ["a", "b", "c"] |> Enum.at(10)
nil

When the index is out-of-bound Enum.at/2, by default, returns nil. When the index reaches the end of the list, it should then go back at the beginning pointing to the first element.

We can use rem/2, the reminder of an integer division. The first argument is our index (dividend) and the second is the length of the list (divisor)

iex> rem(2, 3)
2
iex> rem(3, 3)
0
iex> rem(4, 3)
1
iex> rem(-4, 3)
1

iex> ["a", "b", "c"] |> Enum.at(rem(2, 3))
"c"
iex> ["a", "b", "c"] |> Enum.at(rem(3, 3))
"a"
iex> ["a", "b", "c"] |> Enum.at(rem(4, 3))
"b"

So we can use it to loop over the list elements, while the index increments.

Since our image list is constant, we can define it as module attribute and calculate its length at compilation time

defmodule GalleryWeb.GalleryLive do
  @images [
    "https://images.unsplash.com/photo-1562971179-4ad6903a7ed6?h=500&fit=crop",
    "https://images.unsplash.com/photo-1552673597-e3cd6747a996?h=500&fit=crop",
    "https://images.unsplash.com/photo-1561133036-61a7ed56b424?h=500&fit=crop",
    "https://images.unsplash.com/photo-1530717449302-271006cdc1bf?h=500&fit=crop"
  ]
  
  @images_count Enum.count(@images)
  ...

  def image(idx) do
    idx = rem(idx, @images_count)
    Enum.at(@images, idx)
  end
end

Using Enum.at in this case is more than fine, but remember that lists in Elixir are linked lists: if the list is big, Enum.at/2 can become expensive since it has to go through the whole list to reach the elements at the end.

Please drop me a comment below if you’d like to have an episode on how lists work in Elixir.

We can define an img tag, in the view in render/1 function, and use the image/1 function to set the src attribute’s value.

def render(assigns) do
  ~L"""
  <label>Image Index: <%= @idx %></label>
  <button phx-click="prev">Prev</button>
  <button phx-click="next">Next</button>
	
  <img src="<%= image(@idx) %>">
  """
end

Going back to the browser, after refreshing the page, now we should see a working first version of our gallery – by pressing prev and next buttons we go through the images. 🎉🥳

Working gallery: first version
Working first version

A bit of refactoring: Gallery module

The next step is to do a bit of refactoring, which will make easier to add thumbnails and a slideshow functionality.

At the moment we have everything in the GalleryWeb.GalleryLive module – it would be nice to uncouple the gallery logic from the LiveView part, moving it to a different module called Gallery, defined in lib/gallery.ex

# lib/gallery.ex

defmodule Gallery do
  # image_ids()
  # first_id()


  # prev_image_id(ids, id)
  # prev_index(ids, id)

  # next_image_id(ids, id)
  # next_index(ids, id)

  # thumb_url(id)
  # large_url(id)

  # image_url(image_id, params)
end

image_url/1

The first function we are going to write is image_url/1. If we take a closer look at one of the Unsplash’s image URL, we see that it’s made by different parts

  • "https://images.unsplash.com/" the Unsplash base url
  • "photo-1562971179-4ad6903a7ed6" the image id
  • "?h=500&fit=crop" and some query params. We can use the query params to request a different image size: tuning h and w params we are able to request a large image or a thumbnail

Instead of keeping the list of URLs of large images, we can switch to a list of images ids. With just the image id we can build a URL for both thumbnails and large images.

# lib/gallery.ex

defmodule Gallery do
  @unsplash_url "https://images.unsplash.com"

  @ids [
    "photo-1562971179-4ad6903a7ed6",
    "photo-1552673597-e3cd6747a996",
    "photo-1561133036-61a7ed56b424",
    "photo-1530717449302-271006cdc1bf"
  ]

  def image_ids, do: @ids

  def image_url(image_id, params) do
    URI.parse(@unsplash_url)
    |> URI.merge(image_id)
    |> Map.put(:query, URI.encode_query(params))
    |> URI.to_string()
  end

end

We use the URI module to compose and generate the final URL. URI.parse/1 parses the Unsplash base url returning a URI struct, then URI.merge/2 sets the image_id as the path

%URI{
  scheme: "https",
  port: 443,
  host: "[images.unsplash.com](http://images.unsplash.com/)",
  path: "/photo-1561133036-61a7ed56b424",
  query: nil,
  ...
}

URI.encode_query/1 then encodes a params map to a query string. If we want a thumbnail we can set both w and h to 100px

iex> URI.encode_query(%{w: 100, h: 100, fit: "crop"})
"fit=crop&h=100&w=100"

URI.to_string/1 returns the final URL, converting the struct into a string.

iex> Gallery.image_url("photo-1562971179-4ad6903a7ed6", %{w: 100, h: 100})

"https://images.unsplash.com/photo-1562971179-4ad6903a7ed6?h=100&w=100"

Let’s add two helpers which can be useful later

# lib/gallery.ex

defmodule Gallery do
  
  def thumb_url(id), 
    do: image_url(id, %{w: 100, h: 100, fit: "crop"})
	
  def large_url(id), 
    do: image_url(id, %{h: 500, fit: "crop"})

  ...
end

thumb_url/1 returns a 100×100 image url and large_url/2 a 500px height image.

next_image_id/2 and prev_image_id/2

Instead of dealing with an index directly (incrementing and decrementing it), we define a next_image_id(ids, id) function that given an id, returns the next element in the ids list.

# lib/gallery.ex

defmodule Gallery do
  
  def first_id(ids \\ @ids), do: List.first(ids)
	
	
  def next_image_id(ids\\@ids, id) do
    Enum.at(ids, next_index(ids, id), first_id(ids))
  end
	
	
  defp next_index(ids, id) do
    ids
    |> Enum.find_index(& &1 == id)
    |> Kernel.+(1)
  end	
  ...
end

In next_image_id, by setting @ids as ids default value, we define the function with two different arity:

  • next_image_id/1: passing only the id argument, ids will be equal @ids.
  • next_image_id/2: with this function we pass the ids list ourself, which can be useful to unit test the function.

next_image_id/2 uses the private function next_index(ids,id), which finds the index of the id element in the ids list, incrementing it by 1.

If the id is the last element in the list, next_index/2 returns an index that is out-of-bound and Enum.at/2 (in next_image_id/2) would return nil.

We can pass first_id(ids) as Enum.at/3 third argument – instead of returning nil, Enum.at/3 will return the first element in ids.

Let’s see next_image_id/2 in action on the terminal

iex> Gallery.next_image_id(["a", "b", "c"], "b")
"c"
iex> Gallery.next_image_id(["a", "b", "c"], "c")
"a"

prev_image_id/2 and prev_index/2 are really similar to the next_* functions

# lib/gallery.ex

defmodule Gallery do

  def prev_image_id(ids\\@ids, id) do
    Enum.at(ids, prev_index(ids, id))
  end


  defp prev_index(ids, id) do
    ids
    |> Enum.find_index(& &1 == id)
    |> Kernel.-(1)
  end

  ...
end

In the prev_ case we don’t need to set the Enum.at/3 default value, because prev_index/2 doesn’t return an index out-of-bound. When id is the first element of ids, prev_index/2 passes the -1 index to Enum.at/2, which returns the ids last element.

Refactoring GalleryLive

Now it’s time to make some changes in GalleryLive and use the functions we’ve just built in Gallery.

We don’t need anymore the @images, @images_count module attributes and image/1 function.

In mount/2, instead of :idx, we now assign Gallery.first_id() to :current_id

# lib/gallery_web/live/gallery_live.ex

defmodule GalleryWeb.GalleryLive do
  ...

  def mount(_params, _session, socket) do
    {:ok, assign(socket, :current_id, Gallery.first_id())}
  end
	
  ...
end

In render/1 we use @current_id and instead of image(@idx) we adopt Gallery.large_url(@current_id)

# GalleryWeb.GalleryLive
# lib/gallery_web/live/gallery_live.ex

def render(assigns) do
  ~L"""
  <label>Image id: <%= @current_id %></label>
  <button phx-click="prev">Prev</button>
  <button phx-click="next">Next</button>
  
  <img src="<%= Gallery.large_url(@current_id) %>">
  """
end

We then replace assign_prev_idx/1 and assign_next_idx/1 with assign_prev_id/1 and assign_next_id/1, updating handle_event("prev", _, socket) and handle_event("next", _, socket) accordingly

# GalleryWeb.GalleryLive
# lib/gallery_web/live/gallery_live.ex

def handle_event("prev", _, socket) do
  {:noreply, assign_prev_id(socket)}
end

def handle_event("next", _, socket) do
  {:noreply, assign_next_id(socket)}
end

def assign_prev_id(socket) do
  assign(socket, :current_id, 
    Gallery.prev_image_id(socket.assigns.current_id))
end

def assign_next_id(socket) do
  assign(socket, :current_id, 
    Gallery.next_image_id(socket.assigns.current_id))
end

Refreshing the page on the browser we should get a similar result of what we got before, but this time, instead of an index, we use image ids.

Gallery with image id
Gallery with image id

Thumbnails

It’s now really easy to add the thumbnails, using a comprehension that maps ids returned by Gallery.image_ids() to <img> tags. We use Gallery.thumb_url/1 to get a thumbnail url

<center>
  <%= for id <- Gallery.image_ids() do %>
    <img src="<%= Gallery.thumb_url(id) %>">
  <% end %>
</center>
Thumbnails
Thumbnails

It would be nice to see which of the images in the thumbnails is shown below.

# GalleryWeb.GalleryLive
# lib/gallery_web/live/gallery_live.ex

defp thumb_css_class(thumb_id, current_id) do
  if thumb_id == current_id do
    "thumb-selected"
  else
    "thumb-unselected"
  end
end

We write the thumb_css_class(thumb_id, current_id) helper into GalleryLive and use it to render the css class of the thumbnails img tag.

<%= for id <- Gallery.image_ids() do %>
  <img src="<%= Gallery.thumb_url(id) %>"
  class="<%= thumb_css_class(id, @current_id) %>">
<% end %>

thumb_css_class/2 returns "thumb-selected" css class when id and @current_id are equal, "thumb-unselected" otherwise.

Then we add the two css classes in assets/css/app.css

/* assets/css/app.css */

.thumb-selected {
  border: 4px solid #0069d9;
}

.thumb-unselected {
  opacity: 0.5;
}
Selected thumbnails
Selected thumbnails

Slideshow

With a slideshow feature we want that GalleryLive automatically changes the current image at a regular interval.

Let’s start by assigning a :slideshow value in mount/2, initially set to :stopped.

def mount(_params, _session, socket) do
  socket = 
    socket
    |> assign(:current_id, Gallery.first_id())
    |> assign(:slideshow, :stopped)
  {:ok, socket}
end

Then we change the view in render/1 by removing the <label> tag and adding a third button:

  • when @slideshow is :stopped we show a Play button, which sends a "play_slideshow" event when clicked
  • otherwise we show a Stop button, which sends a "stop_slideshow" event
<center>
  <button phx-click="prev">Prev</button>
  <button phx-click="next">Next</button>

  <%= if @slideshow == :stopped do %>
    <button phx-click="play_slideshow">Play</button>
  <% else %>
    <button phx-click="stop_slideshow">Stop</button>
  <% end %>
</center>

We handle the first event which starts the slideshow

def handle_event("play_slideshow", _, socket) do
  {:ok, ref} = :timer.send_interval(1_000, self(), :slideshow_next)
  {:noreply, assign(socket, :slideshow, ref)}
end

def handle_info(:slideshow_next, socket) do
  {:noreply, assign_next_id(socket)}
end

:timer.send_interval(milliseconds, pid, message) starts the slideshow by sending every second a :slideshow_next message to self(), the Gallery LiveView process. It returns a ref reference, which we’ll need later to stop the slideshow, and we assign it to :slideshow.

The process now receives a :slideshow_next message every second. This message is handled by handle_info(:slideshow_next, socket) which calls assign_next_id(socket) to assign :current_id to the next image id.

To stop the slideshow, we implement the handle_event("stop_slideshow", _, socket) function that cancels the timer and assigns :slideshow back to :stopped.

def handle_event("stop_slideshow", _, socket) do
  :timer.cancel(socket.assigns.slideshow)
  {:noreply, assign(socket, :slideshow, :stopped)}
end
Gallery with Slideshow
Gallery with Slideshow

We made it! 🎉👩‍💻👨‍💻🎉 We finally got a slideshow feature that shows us the images automatically!

What’s next?

We’ve seen a lot! If you want to copy & paste the code, you find the full code at this gist link with the two Gallery and GalleryWeb.GalleryLive modules we’ve built during the article.

If you want the plug&play project’s code (to try everything out without coding it yourself) please let me know in the comments below.

You can now take a step forward: you can use LiveView live_link and pushState support to bring the image id in the URL and update the URL when showing a different image. This gives you a great way to share a specific image to other users.