上記サイトでは 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
end
render
関数で指定している、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
end
LiveView 用のテンプレートなので、拡張子は 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.ex
ToastMsg というモジュールを作成し、そこでプロトコルとビヘイビアを実装します。
特に特別な事はしないので、既存のモジュールを使用しながら実装します。
# /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))
end
ToastLiveのフォームイベントのハンドリングから行います。
シーケンス図の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}
end
Toast の追加は以下のようにしています。
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
end
StackableToast が投げた 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}
end
Toastの表示・非表示は以下のようにしています。
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
end
Toast の削除では、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で受け取ったものをそのまま子のコンポーネントに流すという苦肉の策をとりました。
チューニングしつついろいろなプロジェクトで使っていこうと思います。