Understanding Phoenix LiveView
- Setup
- The Primitives
- Building a Gallery app - (this article)
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.
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
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"
]
we need an index to go through these images, incrementing and decrementing it like we did for the counter
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
-
handle_event("next", _event, socket)
is identical to the"incr"
counter's version, and it increments the:idx
by1
. -
handle_event("prev", _event, socket)
instead decrements:idx
by1
.
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, 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. 🎉🥳
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: tuningh
andw
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 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
next_image_id
function has two different arity:
-
next_image_id/1
: passing only theid
argument,ids
will be equal@ids
. -
next_image_id/2
: with this function we pass theids
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, next_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(_session, socket) do
{:ok, assign(: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
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
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.
Thumbnails
It's now really easy to add the thumbnails, using a comprehension that maps id
s 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>
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;
}
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
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
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)