DEV Community

Cover image for Understanding LiveView: build a Gallery app
Alvise Susmel
Alvise Susmel

Posted on • Originally published at poeticoding.com on

Understanding LiveView: build a Gallery app

Understanding Phoenix LiveView

Subscribe to get FREE Tutorials by email. Start learning Elixir and Phoenix to build features and apps!

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(_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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
]
Enter fullscreen mode Exit fullscreen mode

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

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(_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
Enter fullscreen mode Exit fullscreen mode
  • 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

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

So we can use it to loop over the list, 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
    ...
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

thumb_url/1 returns a 100x100 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
Enter fullscreen mode Exit fullscreen mode

next_image_id function has 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"
Enter fullscreen mode Exit fullscreen mode

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, next_index(ids, id))
    end


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

  ...

end
Enter fullscreen mode Exit fullscreen mode

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(_session, socket) do
      {:ok, assign(:current_id, Gallery.first_id())}
    end

  ...
end
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then we 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
Enter fullscreen mode Exit fullscreen mode

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

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 %>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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(_session, socket) do
  socket = 
    socket
    |> assign(:current_id, Gallery.first_id())
    |> assign(:slideshow, :stopped)
  {:ok, socket}
end
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

: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
Enter fullscreen mode Exit fullscreen mode

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.

Subscribe to get FREE Tutorials by email. Start learning Elixir and Phoenix to build features and apps!

Top comments (0)