@koyo-miyamura です!
最近は本業の傍ら、Elixirコミュニティの方で副業しており、実務でのElixir実装にハマっております。
「お仕事でも使える Elixir」をテーマに実務での実装例の紹介に励んでおりますb
背景
副業で「無限スクロールデータテーブル」を実装する機会がありました。
無限スクロールデータテーブルというのはこういう感じで、テーブルをスクロールしたら次のページへのアクセスが行われるようなデータテーブルです。
(ツイッターなどのタイムラインをイメージするとわかりやすいです。)
実際に英語含めて調べてみても、あまり実装例なくて Javascript の動作を細かく追ったりして苦労したので、アルケミストの皆様のためにご紹介させていただければ!
(自分がやるときに欲しかった笑)
バージョン
Phoenix 1.6.15
LiveView 0.17.12
無限スクロールデータテーブルとは
実は「無限スクロール」の実装自体は以前 @torifukukaiou さんが過去に紹介しています。
実は LiveView 公式でも紹介されていたり。
しかしこれはスクロール領域が「画面全体」である場合の実装であり今回の要件を満たしませんでした。
スクロール領域を「データテーブル領域」にするにはここから一工夫必要となります。
実装
ということで実装してみましょう!
プロジェクト準備
以前ご紹介した方法で docker でプロジェクト準備します。
(もちろん普通にローカルで mix phx.new しても ok です!)
docker compose run --rm --no-deps web mix phx.new scrollable_table_sample
いつも通り User テーブルを作ります。
docker compose exec web mix phx.gen.live Users User users name:string
docker compose exec web mix ecto.migrate
router.ex
への追加も忘れずに
scope "/", ScrollableTableSampleWeb do
pipe_through :browser
get "/", PageController, :index
live "/users", UserLive.Index, :index
live "/users/new", UserLive.Index, :new
live "/users/:id/edit", UserLive.Index, :edit
live "/users/:id", UserLive.Show, :show
live "/users/:id/show/edit", UserLive.Show, :edit
end
100名のユーザーを作ります。
Phoenix には seed を管理する仕組みがあるのでこれを使ってみましょう!
for i <- 1..100, do: %{name: "name_#{i}"} |> ScrollableTableSample.Users.create_user()
これで流してみます。
docker compose exec web mix run priv/repo/seeds.exs
100名のユーザーができました!
スクローラブルにする
デフォルトだとテーブルがスクロールしないので以下のようにしてみます。
<div style="overflow-y: scroll; max-height: 70vh;">
<table>
...
</table>
</div>
スクロールバーがつきました
Scrivenner を導入する
事前に Scrivenner というDBのページネーション対応をサポートするライブラリを導入します。
導入は以前紹介しているのでこちらを参考に。
無限スクロールデータテーブルにする
さてここからが本題です!
ユーザー一覧のテーブルを無限スクロール仕様にしてみましょう。
テンプレートファイルの編集
まずテンプレートファイルを編集して InfiniteScroll
という名前の hooks をテーブルに組み込みます。
(LiveView には hook という仕組みがあり、これを用いることで Javascript <-> LiveView 間で連携できます。今回のようにブラウザのイベントを取りたい場合に特に便利ですね)
<div style="overflow-y: scroll; max-height: 70vh;" class="infinite-scrollable-table">
<table>
...
<tbody
id="users"
phx-update="append"
phx-hook="InfiniteScroll"
data-page={@page}
data-total_pages={@total_pages}
data-el=".infinite-scrollable-table"
>
hook の実装
hook は以下のように実装します。
hook は別ファイルに分けて実装すると見通しが良いですね
import { Hooks } from "./hooks"
let liveSocket = new LiveSocket("/live", Socket, {hooks: Hooks, params: {_csrf_token: csrfToken}})
hook の実装は以下のようになります。
let Hooks = {}
// Returns scroll rate [%] of scrollable area for target DOM (el).
const scrollAt = (el) => {
const scrollTop = el.scrollTop
const scrollHeight = el.scrollHeight
const clientHeight = el.clientHeight
const result = scrollTop / (scrollHeight - clientHeight) * 100
return result
}
Hooks.InfiniteScroll = {
page() { return parseInt(this.el.dataset.page, 10) },
total_pages() { return this.el.dataset.total_pages },
mounted() {
this.loading = false
const $scrollDiv = document.querySelector(this.el.dataset.el)
$scrollDiv.addEventListener("scroll", _e => {
if(!this.loading && this.page() != this.total_pages() && scrollAt($scrollDiv) >= 99){
// change "loading" into true in order to prevent multiple page loading at once
this.loading = true
this.pushEventTo("#" + this.el.id, "load_more", {})
}
})
},
reconnected(){ this.loading = false },
updated(){ this.loading = false }
}
export { Hooks }
細かい説明するとそれだけで1記事になるので詳細は割愛しますが、ざっくりいうと scrollAt
関数がデータテーブルのスクロール可能領域のうち、現在どこまでスクロールしているかを百分率で計算していて、それが規定値(今回は99%)を超えたら次のページを読み込むイベントが LiveView ハンドラー側に送られるという仕組みです。
scrollAt
詳細の仕組みを知りたい方は以下の MDN を読んでみるといいと思いますb
なお、 loading フラグで制御しないと複数ページ一気に読み込まれるのでご注意を!
LiveView ハンドラの実装
後はハンドラ部分を実装すれば完成です!
先ほど入れた Scrivenner を使ってページネーションしたデータを返すようにします。
また mount 時には 1ページ目だけ返すようにします。
後は Javascript 側の hook から送られてくる load_more
イベントをハンドリングして、次の1ページの User データを返せば OK です!
全体ではなく差分を返すことに注意が必要ですb
def list_users(page: page, per_page: per_page) do
from(u in User,
order_by: [desc: u.updated_at, desc: u.id]
)
|> Repo.paginate(page: page, page_size: per_page)
end
...
@default_page 1
@per_page 20
@impl true
def mount(_params, _session, socket) do
socket
|> assing_paginate_users(page: @default_page, per_page: @per_page)
|> then(&{:ok, &1})
end
...
def handle_event("delete", %{"id" => id}, socket) do
user = Users.get_user!(id)
{:ok, _} = Users.delete_user(user)
socket
|> assing_paginate_users(page: @default_page, per_page: @per_page)
|> then(&{:noreply, &1})
end
@impl true
def handle_event("load_more", _, %{assigns: assigns} = socket) do
next_page = assigns.page + 1
{:noreply,
socket
|> assing_paginate_users(page: next_page, per_page: @per_page)}
end
defp assing_paginate_users(socket, page: page, per_page: per_page) do
paginate_users(page: page, per_page: per_page)
|> then(&assign(socket, &1))
end
defp paginate_users(page: current_page, per_page: per_page) do
%{
entries: entries,
page_number: page,
page_size: _page_size,
total_entries: _total_entries,
total_pages: total_pages
} =
Users.list_users(
page: current_page,
per_page: per_page
)
[
users: entries,
page: page,
total_pages: total_pages
]
end
完成!
まとめ
無限スクロールデータテーブルを LiveView で実装しました。
なお、実際に実務ではさらにこちらを LiveView コンポーネント化して再利用性を高めるということをしました。
実務ではコンポーネント化することも多いので、こちらについては Elixir アドベントカレンダーの12日目にてご紹介します!
お楽しみに!
なおリポジトリはこちらにあるので、ぜひ遊んでみてください!