はじめに
SpringBootにてhtmxを使う第4弾、今回は無限スクロールです。画面を下へスクロールするたびに次のデータを取得していきます。
無限スクロールとは
画面を表示したあとに、続きのデータがあった場合は画面を下へスクロールすると、次の差分を追加して表示されるユーザ・インタフェースです。表示するデータの総量が多い場合に分割して表示することで、データの通信料を削減し、それにより描画までの速度も改善できます。
サーバからのデータ送信量はページングのときとほぼ変わることなく、画面のスクロールだけで次のデータ差分を取得できますので、スマホなど画面が小さいモバイルデバイスの操作だけでなく、大量にデータが存在する一覧画面を順次表示するWebシステムでも有用です。
無限スクロール(Infinite Scroll)を使う方法
htmxには、非同期でレスポンスを取得した後の制御方法として以下の属性1があります。
属性 | 用途 |
---|---|
hx-swap | 取得したレスポンス(HTML)を出力する位置。hx-swap の属性を記述した要素との相対位置で設定する。無指定のときはinnerHTML で、子要素を入れ替える。 |
hx-select | 取得したレスポンス(HTML)から取得する内容をcss-selectorで指定する。 |
こちらを使った具体例を見てみましょう。
htmxで無限スクロール(Infinite Scroll)を実現する
htmxの実装例:https://htmx.org/examples/infinite-scroll/ に紹介されており、こちらを使えば実現できます。
このサンプルでは、<table>
内で取得した一覧結果の末尾に
<tr hx-get="/contacts/?page=2"
hx-trigger="revealed"
hx-swap="afterend">
<td>Agent Smith</td>
<td>void29@null.org</td>
<td>55F49448C0</td>
</tr>
を追加しています。
属性名 | 役割 | 例示した値 |
---|---|---|
hx-get |
非同期でリクエスト |
/contacts/?page= でページ番号を指定 |
hx-trigger |
リクエストするタイミング | revealed: この要素が表示されたときに発動する |
hx-swap |
リクエスト結果を置き換える方法 | afterend: この要素の次に出力する |
hx-get
のURLにつけているクエリパラメータ page
の値は、ページングを使ったときと同様に表示するデータのページ番号を指定して一覧結果のページング内容を取得します。
先ほどのサンプルでは、HTMLの内容は以下のようになります。
<table hx-indicator=".htmx-indicator">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>ID</th>
</tr>
</thead>
<tbody>
<tr>
<td>Agent Smith</td>
<td>void10@null.org</td>
<td>5361G91G32</td>
</tr>
<tr>
<td>Agent Smith</td>
<td>void11@null.org</td>
<td>47EBA40EE1</td>
</tr>
・・・(省略)・・・
<tr>
<td>Agent Smith</td>
<td>void28@null.org</td>
<td>D5AG92G363</td>
</tr>
<tr hx-get="/contacts/?page=2" hx-trigger="revealed" hx-swap="afterend">
</tr>
</tbody>
</table>
これを一番下までスクロールすると
<table hx-indicator=".htmx-indicator">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>ID</th>
</tr>
</thead>
<tbody>
<tr>
<td>Agent Smith</td>
<td>void10@null.org</td>
<td>5361G91G32</td>
</tr>
<tr>
<td>Agent Smith</td>
<td>void11@null.org</td>
<td>47EBA40EE1</td>
</tr>
・・・(省略)・・・
<tr>
<td>Agent Smith</td>
<td>void28@null.org</td>
<td>D5AG92G363</td>
</tr>
<tr hx-get="/contacts/?page=2" hx-trigger="revealed" hx-swap="afterend">
</tr>
<tr>
<td>Agent Smith</td>
<td>void30@null.org</td>
<td>2524194E6A</td>
</tr>
・・・(省略)・・・
<tr>
<td>Agent Smith</td>
<td>void48@null.org</td>
<td>79E65GE293</td>
</tr>
<tr hx-get="/contacts/?page=3" hx-trigger="revealed" hx-swap="afterend">
</tr>
</tbody>
</table>
と、hx-swap="afterend"
の次の要素に、hx-get="/contacts/?page=2"
の結果が出力されます。
このように、ページ分割したデータと、末尾に無限スクロール用のhx-swap="afterend"
で次の内容を表示するhtmxを含んだHTMLを出力していくことで、次々とデータを取得できます。
SpringBootとBootstrapを組み合わせる
SpringBootの実装は、ページングで実装したサーバ側の実装をそのまま使えます。
まずは画面の実装を変えます。ページングのボタンをはずした一覧画面のHTMLへ作り変えます。
<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<title th:inline="text">タイトル</title>
<link rel="stylesheet" th:href="@{/webjars/bootstrap/{version}/css/bootstrap.min.css(version=${@webJarsProperties.bootstrap})}" />
<script th:src="@{/webjars/bootstrap/{version}/js/bootstrap.min.js(version=${@webJarsProperties.bootstrap})}"></script>
<script th:src="@{/js/htmx.min.js}"></script>
</head>
<body>
<div class="container-fluid">
<div class="container p-3">
<div>
<div class="alert">一覧を表示します</div>
</div>
<div class="table-responsive">
<table class="table table-bordered text-nowrap">
<thead>
<tr>
<th>#番号</th>
<th>名称</th>
<th>価格</th>
<th>在庫数</th>
</tr>
</thead>
<tbody id="list" hx-get="/infinite/list" hx-trigger="load" hx-select="tr">
</tbody> ・・・(1)
</table>
</div>
</div>
</div>
</body>
</html>
(1) 一覧のデータ部分を取得します。まずは先頭5件を取得しますので、ページの読み込みと同時に取得するよう hx-trigger="load"
に指定、そして取得したレスポンスをhx-select
にて <tr>
要素を指定します。
これで、/infinite/list
をリクエストした結果のレスポンスから、テーブルのデータ部分を表示する <tr>
要素を抜き取ります。
このリクエストで表示する、ページング用に使っていたHTMLにて無限スクロール用のhtmx属性を含んだ要素を加えます。
<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<table>
<tbody>
<tr th:each="item : ${pages.getContent()}">
<td th:text="${item.id}">1</td>
<td th:text="${item.name}">name</td>
<td th:text="${item.price}" class="text-end">100</td>
<td th:text="${item.stock}" class="text-end">10</td>
</tr>
<tr th:if="${! pages.last}" ・・・(2)
th:hx-get="'/infinite/list?page=' + ${pages.pageable.pageNumber + 1}" hx-trigger="revealed" hx-swap="afterend">
</tr>
</tbody>
</table>
</html>
一覧のデータを出力する部分は同じですが、最後の <tr>
のあとに 無限スクロール用のhtmx属性を追加した <tr>
を追加します。
属性 | 用途と説明 |
---|---|
th:if="${! pages.last} " |
表示するデータがまだ最後のページだった場合は表示しないようThymeleafで制御する。 |
th:hx-get="'/infinite/list?page=' + ${pages.pageable.pageNumber + 1}" |
リクエストする次のページのURL。現在リクエストされているページ番号+1は次のページになる。 |
hx-trigger="revealed" |
この(2)の要素が描画されたときに実行する。 |
hx-swap="afterend" |
この(2)の要素の後にhx-get の内容を出力する。 |
無限スクロール用の要素が表示されるごとに次の一覧を表示していき、出力した内容が表の最下部に追加されて画面から表示されなくなった場合は、画面の最下部へスクロールすることで次の一覧を追加して出力します。
<div class="container-fluid">
<div class="container p-3">
<div>
<div class="alert">一覧を表示します</div>
</div>
<div class="table-responsive">
<table class="table table-bordered text-nowrap">
<thead>
<tr>
<th>#番号</th>
<th>名称</th>
<th>価格</th>
<th>在庫数</th>
</tr>
</thead>
<tbody id="list" hx-get="/infinite/list" hx-trigger="load" hx-select="tr">
<tr>
<td>1</td>
<td>apple</td>
<td class="text-end">100</td>
<td class="text-end">10</td>
</tr>
<tr>
<td>2</td>
<td>banana</td>
<td class="text-end">250</td>
<td class="text-end">20</td>
</tr>
・・・(中略)・・・
<tr>
<td>5</td>
<td>egg</td>
<td class="text-end">100</td>
<td class="text-end">50</td>
</tr><tr>
</tr><tr hx-get="/infinite/list?page=1" hx-trigger="revealed" hx-swap="afterend" data-hx-revealed="true" class="">
</tr>
<tr>
<td>6</td>
<td>fig</td>
<td class="text-end">200</td>
<td class="text-end">30</td>
</tr>
<tr>
<td>7</td>
<td>grape</td>
<td class="text-end">150</td>
<td class="text-end">40</td>
</tr>
・・・(中略)・・・
<tr>
<td>10</td>
<td>jam</td>
<td class="text-end">300</td>
<td class="text-end">10</td>
</tr>
<tr hx-get="/infinite/list?page=2" hx-trigger="revealed" hx-swap="afterend" data-hx-revealed="true" class="">
</tr>
</tbody>
</table>
</div>
</div>
</div>
Controllerの実装
infinite-index.html
とinfinite-list.html
を出力するControllerを実装します。
package com.github.apz.sample.controller;
import com.github.apz.sample.model.Item;
import com.github.apz.sample.service.ItemService;
import lombok.AllArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
import java.util.List;
@Controller
@RequestMapping("/infinite")
@AllArgsConstructor
public class InfiniteItemController {
ItemService itemService;
@GetMapping("/")
public ModelAndView index(ModelAndView mnv) {
mnv.setViewName("infinite-index");
return mnv;
}
@GetMapping("/list")
public ModelAndView list(ModelAndView mnv, Pageable pageable) {
Page<Item> pageItems = itemService.getPageItems(pageable);
mnv.addObject("pages", pageItems);
mnv.setViewName("infinite-list");
return mnv;
}
}
初期表示ならびにページングの実装はそのまま使えます。
ほぼ修正いらずで無限スクロールが実装できてしまいます。
素晴らしいですね。
モーダルダイアログを使う
さて、この一覧からモーダルダイアログを表示できるよう追加します。linifnite-list.html
の番号の列にボタンを配置し、モーダルダイアログをinfinite-index.html
を実装します。
<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<table>
<tbody>
<tr th:each="item : ${pages.getContent()}">
<td>
<button th:hx-get="'/item/' + ${item.id}" hx-target="#modal-content" hx-trigger="click" hx-select="div" ・・・(1)
data-bs-toggle="modal" data-bs-target="#modals-here"
class="btn btn-primary" th:text="${item.id}">1</button>
</td>
<td th:text="${item.name}">name</td>
<td th:text="${item.price}" class="text-end">100</td>
<td th:text="${item.stock}" class="text-end">10</td>
</tr>
<tr th:if="${! pages.last}"
th:hx-get="'/infinite/list?page=' + ${pages.pageable.pageNumber + 1}" hx-trigger="revealed" hx-swap="afterend">
</tr>
</tbody>
</table>
</html>
変更点は(1)です。ボタンの実装以外にもhx-select
を追加して、受けたレスポンスのどの要素を出力するかを明示します 2
モーダル用の実装
ここは特に変わりません。同じ実装がそのまま使えます。
テーブルの背景色にtable-stripedを適用する
以上でテーブルの無限スクロールを実現しましたが、今回の実装ではテーブルの背景に table-striped
を摘要していないため、すべての行の背景色が同じでした。ではこちらも定義してみましょう。
おやおや?5行ごとに背景色のOn/Offが入れ替わっちゃってますね…
この原因は無限スクロールを使うために設定した
<tbody>
<tr th:each="item : ${pages.getContent()}">
<td th:text="${item.id}">1</td>
<td th:text="${item.name}">name</td>
<td th:text="${item.price}" class="text-end">100</td>
<td th:text="${item.stock}" class="text-end">10</td>
</tr>
<tr th:if="${! pages.last}"
th:hx-get="'/infinite/list?page=' + ${pages.pageable.pageNumber + 1}" hx-trigger="revealed" hx-swap="afterend"> ・・・(これ!)
</tr>
</tbody>
そう、このhtmx属性を宣言している <tr>
の影響です。
このままだと困りますので、一番安直な手段としては、空の<tr>
を追加してあげることです。
<tbody>
<tr th:each="item : ${pages.getContent()}">
<td th:text="${item.id}">1</td>
<td th:text="${item.name}">name</td>
<td th:text="${item.price}" class="text-end">100</td>
<td th:text="${item.stock}" class="text-end">10</td>
</tr>
<tr></tr> ・・・(空のtrを追加)
<tr th:if="${! pages.last}"
th:hx-get="'/infinite/list?page=' + ${pages.pageable.pageNumber + 1}" hx-trigger="revealed" hx-swap="afterend"> ・・・(これ!)
</tr>
</tbody>
まとめ
htmxの無限スクロールを、SpringBootとBootstrap5.3で実現する方法を紹介しました。
サンプルコード
-
他にも、htmx属性を宣言している要素ではなく他の要素へ出力可能な hx-select-oobや、hx-swap-oobがあります ↩
-
初期表示のときに
hx-get="/infinite/list" hx-trigger="load" hx-select="tr"
を宣言しているため、この設定が引き継がれてしまい、hx-select
を宣言しないと、<tr>
要素を取り出してしまう。 ↩