LiveViewでtoastを実現する

はじめに

今回は Phoenix.LiveView を用いてUI要素であるToastを実現します。
Toastとは、「ちょっとした通知のためにごく短時間表示される主に四角いUI要素」の事を指します。
以下の画面の右上に表示されている要素がそれにあたります。

lv_toast_animation

このtoastは、色々な UI Framework で採用されています。
大抵は Javascript を用いて実装されています。
本記事では Phoenix.LiveView の特性を活かし、Javscript を使用せずに実装します。

参考のために、動作するデモアプリを Heroku Render に置きました。

本記事で説明する主な内容は以下になります。

  • PhoenixJavaScript を使わない Toast の実現方法
  • Phoenix.LiveView での Form の使い方
  • phx-hook を使用して input要素にフォーカスがあたったら全選択する方法
  • Phoenix.LiveComponent の使い方
  • Phoenix.LiveComponent のクリックのハンドリング

今から作るもの

このようなものを作ります。

lv_toast_image

主な仕様

メイン画面の主な仕様は以下となっております。

項目内容
CommentToastで表示させるコメントを入力。
Duration表示時間を選択。3秒から10秒が選択可能。デフォルトは5秒。
ADD_TOASTボタンこのボタンを押下すると、CommentDuration の内容で Toast を表示。複数回押下すると、複数のToastが表示。
toast 表示エリア上記ボタン押下後 toastはここに一定時間表示。

作成するモジュール

作成するモジュールは以下です。

モジュール名役割
ToastLiveRouter から直接呼び出されるメインページ。Phoenix.LiveView にて作成。Toast を表示するためのUIを提供する。
StackableToastToast を表示するためのモジュール。Phoenix.live_component にて作成。ToastLive の子要素となる。

バージョン情報

記事公開時点でのそれぞれのバージョンは以下です。

項目バージョン
erlang22.2.8
elixir1.10.2
Phoenix1.4.10
phoenixliveview0.8.0

手順

以下の手順で作成します。

  1. プロジェクトの新規作成
  2. プロジェクトに Phoenix.LiveView を組み込む
  3. ルーティングを記述
  4. メイン画面を作成
  5. Toast 機能を作成
  6. Toast の削除機能を追加

プロジェクトの新規作成

まずはプロジェクトを作成します。
今回はDBは使用しないので、--no-ecto オプションをつけて作成します。

$ mix phx.new lv_toast --no-ecto

プロジェクトにPhoenix.LiveViewを組み込む

以下で紹介されている手順に従って、LiveView を使えるようにします。

https://hexdocs.pm/phoenix_live_view/installation.html

上記サイトでは LiveView のバージョンは0.9.0でしたが、記事公開時にセットアップがうまく行かなかったので0.8.0を用いています。

Update: バージョン0.10を用いた最新のインストール方法は、Phoenix.LiveViewの最新インストール方法で紹介しています。

以下からの説明では、上記手順が済んだところから行います。

ルーティングを記述

RouterLiveView へのルーティングを追加します。
もともとの PageController の記述はコメントアウトし、LiveView 用のルーティングを追加します。
新たに ToastLive というモジュールを追加して、そこで実装を行うようにします。
以下では、変更箇所をハイライト表示しています。

# /lib/lv_toast_web/router.ex
scope "/", LvToastWeb do
  pipe_through :browser

#    get "/", PageController, :index  live "/", ToastLive
end

メイン画面を作成

まず、メイン画面から作成します。

ToastLiveの追加

先程 Router で記述した ToastLive を追加します。
/lib/toast_web/ の下に live というディレクトリを作成し、その下に toast_live.ex というファイルを作成します。

├── lib
│   ├── lv_toast_web
│   │   ├── live│   │   │   └── toast_live.ex

ToastLiveの実装

先ほど作成したファイルに、ToastLive の記述を追加します。
まずは、rendermount のみ実装します。

# /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 関数で指定している、ToastLiveViewindex.html.leex は次のステップで追加します。

ViewとTemplateの追加

以下の2ファイルを作成し、下のツリーのハイライト箇所のように配置します。

  • toastliveview.ex
  • index.html.leex
├── lib
│   ├── lv_toast_web
│   │   ├── live
│   │   │   └── toast_live.ex
│   │   ├── templates
│   │   │   └── toast_live│   │   │       └── index.html.leex│   │   └── views
│   │       └── toast_live_view.ex

viewの実装

以下のように 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

templateの実装

LiveView 用のテンプレートなので、拡張子は eex ではなく、 leex で作成します。
ポイントは以下です。

  • 通常 @changeset を指定する form_data には atomを指定
  • formsubmitphx_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の記述について

以下のように 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
  :

phx_hookの使用について

テキスト入力欄にフォーカスがあたったら、テキストが全選択されると便利です。
また、そういう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});

とりあえずここまでで、ボタンを押したらトーストが追加されるようになるための準備ができました。

Toast機能を作成

以下の3つのファイルを作成し、ハイライトされた場所に配置します。

  • toast.css
  • stackable_toast.ex
  • toasts.html.leex
├── 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

StackableToastの実装

親と密接に連携できるように 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" %>

toasts.html.leexの実装

StackableToastrender で指定している 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.cssの実装

仕様に合うようにセレクタに値を指定していきます。

Toastのコンテナ要素

Toast のコンテナ要素は右上固定で幅が320pxになるように、以下のように定義します。

/* /assets/css/toast.css */
#toasts {
    position: fixed;    top: 10px;    width: 320px;    right: 20px;    z-index: 100;
}

Toast要素

ポイントは以下です。

  • Toast要素に live-view-toast クラスをつける
  • 表示・非表示は hidden クラスのつけ外しで対応
  • 非表示時は #toast の幅だけ画面外に移動し透明にする
  • アニメーションのために transitiontransform を指定
  • 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";

Toastの動作シーケンス

以下のシーケンスを作成していきます。

toast_sequence

この中で3,7,11,15がToastに対して操作を行っているところです。
操作の結果を socket.assigns.toasts に対して反映し状態の遷移を作っていきます。

ToastMsg構造体の追加

まず、ToastLiveStackableToast 間のデータの受け渡しのために構造体を作成します。
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

Toastの追加

ToastLiveのフォームイベントのハンドリングから行います。

ToastLveとStackableToastの連携

シーケンス図の1の起点となるフォームの記述は以下になっています。

# /lib/lv_toast_web/templates/toast_live/index.html.leex
<%= f = form_for :make_toast,"#", [phx_submit: :add_toast] %>
  :

フォームより [ADD TOAST] ボタンが押下されると、ToastLivehandle_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でのメッセージのハンドリング

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の追加

Toast の追加は以下のようにしています。

  • 現在時刻でキーを作成
  • そのキーと次のシーケンスIDで ToastMsg を作成
  • ToastMsg を未来へ投げる
  • 新しい 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

ToastLiveでのメッセージの受け取り

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の表示・非表示

Toastの表示・非表示は以下のようにしています。

  • socket.assigns.toasts からkeyを指定し合致する Toast を取り出す
  • 取り出した Toast に対して表示状態を変更する
  • 次のシーケンスIDを指定した ToastMsg を作成
  • 未来へ投げる
# /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の削除

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の追加

Toast 要素に phx-clickphx-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 に変換
  • 該当する Toast のタイマーを停止
  • 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のハンドリングを LiveComponenthandle_info/2 として直接ハンドリングする方法が見つかりませんでした。
そのため不格好になりますが、親のLiveViewで受け取ったものをそのまま子のコンポーネントに流すという苦肉の策をとりました。
チューニングしつついろいろなプロジェクトで使っていこうと思います。

ここまでの動作デモを Heroku Render に置きました。
ソースコードはGithubに置いてあります。