上記サイトでは LiveView のバージョンは0.9.0でしたが、記事公開時にセットアップがうまく行かなかったので0.8.0を用いています。
今回は Phoenix.LiveView を用いてUI要素であるToastを実現します。
Toastとは、「ちょっとした通知のためにごく短時間表示される主に四角いUI要素」の事を指します。
以下の画面の右上に表示されている要素がそれにあたります。

このtoastは、色々な UI Framework で採用されています。
大抵は Javascript を用いて実装されています。
本記事では Phoenix.LiveView の特性を活かし、Javscript を使用せずに実装します。
参考のために、動作するデモアプリを Heroku Render に置きました。
本記事で説明する主な内容は以下になります。
phx-hook を使用して input要素にフォーカスがあたったら全選択する方法このようなものを作ります。
メイン画面の主な仕様は以下となっております。
| 項目 | 内容 |
|---|---|
| Comment | Toastで表示させるコメントを入力。 |
| Duration | 表示時間を選択。3秒から10秒が選択可能。デフォルトは5秒。 |
ADD_TOASTボタン | このボタンを押下すると、Comment と Duration の内容で Toast を表示。複数回押下すると、複数のToastが表示。 |
| toast 表示エリア | 上記ボタン押下後 toastはここに一定時間表示。 |
作成するモジュールは以下です。
| モジュール名 | 役割 |
|---|---|
| ToastLive | Router から直接呼び出されるメインページ。Phoenix.LiveView にて作成。Toast を表示するためのUIを提供する。 |
| StackableToast | Toast を表示するためのモジュール。Phoenix.live_component にて作成。ToastLive の子要素となる。 |
記事公開時点でのそれぞれのバージョンは以下です。
| 項目 | バージョン |
|---|---|
| erlang | 22.2.8 |
| elixir | 1.10.2 |
| Phoenix | 1.4.10 |
| phoenixliveview | 0.8.0 |
以下の手順で作成します。
まずはプロジェクトを作成します。
今回はDBは使用しないので、--no-ecto オプションをつけて作成します。
$ mix phx.new lv_toast --no-ecto以下で紹介されている手順に従って、LiveView を使えるようにします。
https://hexdocs.pm/phoenix_live_view/installation.html
以下からの説明では、上記手順が済んだところから行います。
Router へ LiveView へのルーティングを追加します。
もともとの PageController の記述はコメントアウトし、LiveView 用のルーティングを追加します。
新たに ToastLive というモジュールを追加して、そこで実装を行うようにします。
以下では、変更箇所をハイライト表示しています。
# /lib/lv_toast_web/router.ex
scope "/", LvToastWeb do
pipe_through :browser
# get "/", PageController, :index live "/", ToastLive
endまず、メイン画面から作成します。
先程 Router で記述した ToastLive を追加します。
/lib/toast_web/ の下に live というディレクトリを作成し、その下に toast_live.ex というファイルを作成します。
├── lib
│ ├── lv_toast_web
│ │ ├── live│ │ │ └── toast_live.ex先ほど作成したファイルに、ToastLive の記述を追加します。
まずは、render と mount のみ実装します。
# /lib/lv_toast_web/live/toast_live.ex
defmodule LvToastWeb.ToastLive do
use Phoenix.LiveView
def render(assigns) do
Phoenix.View.render(LvToastWeb.ToastLiveView, "index.html", assigns)
end
def mount(_params, _session, socket) do
{:ok, socket}
end
endrender 関数で指定している、ToastLiveView と index.html.leex は次のステップで追加します。
以下の2ファイルを作成し、下のツリーのハイライト箇所のように配置します。
├── lib
│ ├── lv_toast_web
│ │ ├── live
│ │ │ └── toast_live.ex
│ │ ├── templates
│ │ │ └── toast_live│ │ │ └── index.html.leex│ │ └── views
│ │ └── toast_live_view.ex以下のように view を実装します。
Form を使用するので、Phoenix.HTML.Form をインポートしておきます。
それ以外は特に何も行いません。
# /lib/lv_toast_web/views/toast_live_view.ex
defmodule LvToastWeb.ToastLiveView do
use LvToastWeb, :view
import Phoenix.HTML.Form
endLiveView 用のテンプレートなので、拡張子は eex ではなく、 leex で作成します。
ポイントは以下です。
@changeset を指定する form_data には atomを指定form の submit に phx_submit を使用phx_hook を用いる# /lib/lv_toast_web/templates/toast_live/index.html.leex
<%= f = form_for :make_toast,"#", [phx_submit: :add_toast] %> <%= label f, "comment" %>
<%= text_input f, :comment, [value: "Hello! I'm a LiveView Toast.", phx_hook: "comment_input"] %> <%= label f, "duration(seconds)" %>
<%= select f, :duration, 3..10, selected: 5 %>
<%= submit "add toast" %>
</form>以下のように form_for の第一引数に :make_toast を、phx_submitに:add_toastを指定します。
# /lib/lv_toast_web/templates/toast_live/index.html.leex
<%= f = form_for :make_toast,"#", [phx_submit: :add_toast] %>
:この記述により、ボタンが押下されたときフォームデータは、 handle_event で以下のようにデータを取得できます。
# /lib/lv_toast_web/live/toast_live.ex
def handle_event("add_toast", %{"make_toast" => %{"comment" => comment, "duration"=> duration}}, socket) do
:テキスト入力欄にフォーカスがあたったら、テキストが全選択されると便利です。
また、そういうUIをよく見ます。
それを実現するためにphx-hookを用いました。
ここの部分はJavascript抜きで実現する方法が思いつきませんでした。
# /lib/lv_toast_web/templates/toast_live/index.html.leex
:
<%= text_input f, :comment, [value: "Hello! I'm a LiveView Toast.", phx_hook: "comment_input"] %> :
</form>対応する Javascript のコードは以下です。
特にハイライトされている部分がポイントとなる箇所です。
// /assets/js/app.js
let Hooks = {};
Hooks.comment_input = { mounted(){
this.el.addEventListener("focus", e => { this.el.select(); });
}
};
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: Hooks});とりあえずここまでで、ボタンを押したらトーストが追加されるようになるための準備ができました。
以下の3つのファイルを作成し、ハイライトされた場所に配置します。
├── assets
│ └── css
│ ├── app.css
│ ├── phoenix.css
│ └── toast.css├── lib
│ ├── lv_toast_web
│ │ ├── live
│ │ │ ├── stackable_toast.ex│ │ │ └── toast_live.ex
│ │ ├── templates
│ │ │ └── toast_live
│ │ │ ├── index.html.leex
│ │ │ └── toasts.html.leex│ │ └── views
│ │ └── toast_live_view.ex親と密接に連携できるように Phoenix.LiveComponent として実装します。
Toast 用の構造体も定義しておきます。
# /lib/lv_toast_web/live/stackable_toast.ex
defmodule LvToastWeb.StackableToast do
use Phoenix.LiveComponent
defmodule Toast do
defstruct comment: "", hide: true, timer: nil, duration: 3000
end
def render(assigns) do
Phoenix.View.render(LvToastWeb.ToastLiveView, "toasts.html", assigns)
end
def mount(socket) do
{:ok, assign(socket, toasts: Keyword.new())}
end
endこれは、メインページから以下のように呼び出します。
コンポーネント側でイベントを処理することを想定し、id を指定しておきます。
# /lib/lv_toast_web/templates/toast_live/index.html.leex
<%= f = form_for :make_toast,"#", [phx_submit: :add_toast] %>
:
</form>
<%= live_component @socket, LvToastWeb.StackableToast, id: "toasts" %>StackableToast の render で指定している toasts.html.leex を実装します。
assigns.toasts に表示すべきToastが保持されていることを想定して作成します。
以下では、#toasts の子要素として Toast が表示されます。
ハイライトされている箇所で Toast をレンダリングしています。
hide フラグの状況により、"hidden" クラスをつけ外しています。
# /lib/lv_toast_web/templates/toast_live/toasts.html.leex
<div id="toasts" phx-update="replace" >
<%= for {key, %Toast{comment: comment, hide: hidden}} <- @toasts do %>
<div class="live-view-toast <%= if hidden do "hidden" end %>" id="<%= key %>" > <%= comment %> </div> <% end %>
</div>仕様に合うようにセレクタに値を指定していきます。
Toast のコンテナ要素は右上固定で幅が320pxになるように、以下のように定義します。
/* /assets/css/toast.css */
#toasts {
position: fixed; top: 10px; width: 320px; right: 20px; z-index: 100;
}ポイントは以下です。
live-view-toast クラスをつけるhidden クラスのつけ外しで対応#toast の幅だけ画面外に移動し透明にするtransition と transform を指定hidden クラスがついているときは透明で右側の不可視エリアに移動しているこれらを盛り込んだものが以下になります。
/* /assets/css/toast.css */
.live-view-toast {
background: #333;
color: #eee;
border-radius: 3px;
opacity: 0.9; margin: 10px;
padding: 10px;
transition: all 0.2s; transition-timing-function: ease-out;}
.live-view-toast.hidden {
opacity: 0; transform: translateX(320px); transition-timing-function: ease-in;}
.live-view-toast:hover{
opacity: 1.0;
cursor: pointer;
}app.cssからインポートしておきます。
/* /assets/css/app.css */
@import "./toast.css";以下のシーケンスを作成していきます。
この中で3,7,11,15がToastに対して操作を行っているところです。
操作の結果を socket.assigns.toasts に対して反映し状態の遷移を作っていきます。
まず、ToastLive と StackableToast 間のデータの受け渡しのために構造体を作成します。
send_update 等で Enumerable プロトコルと Access ビヘイビアの実装が求められるので、実装しておきます。
ハイライトの箇所のようにファイルを作成します。
├── lib
│ ├── lv_toast_web
│ │ ├── live
│ │ │ ├── stackable_toast.ex
│ │ │ ├── toast_msg.ex│ │ │ └── toast_live.exToastMsg というモジュールを作成し、そこでプロトコルとビヘイビアを実装します。
特に特別な事はしないので、既存のモジュールを使用しながら実装します。
# /lib/lv_toast_web/live/toast_msg.ex
defmodule LvToastWeb.ToastMsg do
@ toast_id "toasts"
defstruct id: @ toast_id, msg: nil, comment: "", key: nil, duration: 0
@behaviour Access
@impl Access
def fetch(term, key), do: Map.fetch(term, key)
@impl Access
def get_and_update(data, key, fun), do: Map.get_and_update(data, key, fun)
@impl Access
def pop(data, key), do: Map.pop(data, key)
def get_id(), do: @ toast_id
end
defimpl Enumerable, for: LvToastWeb.ToastMsg do
def count(enumerable), do: Enumerable.List.count(Map.to_list(enumerable))
def member?(enumerable, element), do: Enumerable.List.member?(Map.to_list(enumerable), element)
def reduce(enumerable, acc, fun), do: Enumerable.List.reduce(Map.to_list(enumerable), acc, fun)
def slice(enumerable), do: Enumerable.List.slice(Map.to_list(enumerable))
endToastLiveのフォームイベントのハンドリングから行います。
シーケンス図の1の起点となるフォームの記述は以下になっています。
# /lib/lv_toast_web/templates/toast_live/index.html.leex
<%= f = form_for :make_toast,"#", [phx_submit: :add_toast] %>
:フォームより [ADD TOAST] ボタンが押下されると、ToastLive の handle_event/3 が呼び出されます。
handle_event/3では、phx_submit で指定したイベント名 :add_toast で、form_for の第一引数で指定した:make_toast をキーとしたマップとして取得されます。
# /lib/lv_toast_web/live/toast_live.ex
# sequence1
def handle_event("add_toast", %{"make_toast" => %{"comment" => comment, "duration"=> duration}}, socket) do {val, _} = duration |> Integer.parse()
# sequence2
send_update StackableToast, %ToastMsg{msg: :add_toast, comment: comment, duration: val*1000} {:noreply, socket}
endこの関数内で、フォームのデータを取得し、シーケンス図の2にあたる send_update を呼び出します。
ここでは、先程作成した ToastMsg の :msg に :add_toast を指定して、更にフォームのデータを詰めて StackableToast へ送ります。
StackableToast 側では以下の形でメッセージとして受け取れます。
ここでは、socket.assign.toasts に対して add_toast/2 の結果を適用しています。
# /lib/lv_toast_web/live/stackable_toast.ex
def update(assigns = %{msg: :add_toast}, socket) do
{:ok, assign(socket, toasts: add_toast(assigns, socket))}
endこれを以下のようにシーケンス分作成し、パターンマッチによって処理を分けるという方法があります。
# /lib/lv_toast_web/live/stackable_toast.ex
def update(assigns = %{msg: :add_toast}, socket) do
{:ok, assign(socket, toasts: add_toast(assigns, socket))}
end
def update(assigns = %{msg: :show_toast}, socket) do
{:ok, assign(socket, toasts: show_toast(assigns, socket))}
end
:今回はシーケンス図の3,7,11,15において、「assignsとsocketを受け取り変換後のtoastsを返す」というように決めて、:msg に応じた関数を渡すことにしています。
# /lib/lv_toast_web/live/stackable_toast.ex
def update(assigns = %ToastMsg{msg: msg}, socket) do
{:ok, assign(socket, toasts: get_func_for(msg).(assigns, socket))}
end
def get_func_for(msg) do
%{
:add_toast => &add_toast/2,
:show_toast => &show_toast/2,
:hide_toast => &hide_toast/2,
:remove_toast => &remove_toast/2,
}[msg]
end%ToastMsg{} 以外で update 関数が呼び出されることがあります。
その分は以下のように実装して、何も処理しないでおきます。
# /lib/lv_toast_web/live/stackable_toast.ex
def update(_, socket) do
{:ok, socket}
endToast の追加は以下のようにしています。
socket.assigns.toasts の最後に追加それを行っているのが以下になります。
# /lib/lv_toast_web/live/stackable_toast.ex
# sequence3
def add_toast(assigns = %ToastMsg{comment: comment, duration: duration}, socket) do
key = create_key()
ref = %ToastMsg{assigns | msg: :show_toast, key: key} |> set_timer(@toast_show_time)
(socket |> get_toast) ++ [{key, %Toast{comment: comment, timer: ref, duration: duration }}]
end
def create_key() do
System.monotonic_time() |> Integer.to_string() |> String.to_atom()
end
def set_timer(%ToastMsg{} = tm, time_after) do
# sequence4,8,12
Process.send_after(self(), tm, time_after)
end
def get_toast(socket) do
socket.assigns.toasts
endStackableToast が投げた send_after/4 は親要素の ToastLive が受け取ります。
それを、StackableToast へそのまま送ります。
# /lib/lv_toast_web/live/toast_live.ex
# sequence5,9,13
def handle_info(assigns = %ToastMsg{}, socket) do
send_update StackableToast, assigns
{:noreply, socket}
endToastの表示・非表示は以下のようにしています。
socket.assigns.toasts からkeyを指定し合致する Toast を取り出す# /lib/lv_toast_web/live/stackable_toast.ex
def show_toast(assigns = %ToastMsg{key: key}, socket) do
{_, new_list} = Keyword.get_and_update(socket |> get_toast(), key, fn item ->
ref = %ToastMsg{assigns | msg: :hide_toast} |> set_timer(item.duration) {item, %Toast{item | hide: false, timer: ref}} end)
new_list
end
def hide_toast(assigns = %ToastMsg{key: key}, socket) do
{_, new_list} = Keyword.get_and_update(socket |> get_toast(), key, fn item ->
ref = %ToastMsg{assigns | msg: :remove_toast} |> set_timer(@toast_transition_time)
{item, %Toast{item | hide: true, timer: ref}}
end)
new_list
endToast の削除では、socket.assigns.toasts から該当するキーのアイテムを削除しています。
# /lib/lv_toast_web/live/stackable_toast.ex
def remove_toast(%ToastMsg{key: key}, socket) do
Keyword.delete(socket |> get_toast(), key)
endこれで全てのシーケンスは完了しました。
Toast がクリックされたら消す機能を追加します。
これは、今までの機能が実装できていれば簡単に実現できます。
Toast 要素に phx-click と phx-target を追加します。
ポイントは以下です。
phx-clickではイベント名として "toast_click-[Toastのキー]" を指定するphx-targetでは自分のIDである "#toasts" を指定する以下では、前回からの変更箇所をハイライトしています。
# /lib/lv_toast_web/templates/toast_live/toasts.html.leex
<div id="toasts" phx-update="replace" >
<%= for {key, %{comment: comment, hide: hidden, timer: _ref}} <- @toasts do %>
<div
class="toast <%= if hidden do "hide" end %>"
id="<%= key %>"
phx-click="toast_click-<%= key %>" phx-target="#toasts" >
<%= comment %>
</div>
<% end %>
</div>イベントハンドラを実装します。
ポイントは以下です。
atom に変換hide_toast を呼び出す# /lib/lv_toast_web/live/stackable_toast.ex
def handle_event("toast_click-" <> key, _params, socket ) do
current_key = key |> String.to_atom()
%Toast{timer: ref} = Keyword.get(socket |> get_toast(), current_key)
Process.cancel_timer(ref)
{:noreply, assign(socket, toasts: hide_toast(%ToastMsg{key: current_key}, socket))}
endこれで Toast をクリックすると消えるようになります。
Phoenix.LiveView を使えば、JavaScript を使用しなくても Toast が実装できました。
複数のアプリで使いまわしたいので LiveComponent として実装しましたが、send_after/4のハンドリングを LiveComponent の handle_info/2 として直接ハンドリングする方法が見つかりませんでした。
そのため不格好になりますが、親のLiveViewで受け取ったものをそのまま子のコンポーネントに流すという苦肉の策をとりました。
チューニングしつついろいろなプロジェクトで使っていこうと思います。