はじめに
Spring Bootアプリケーションで一覧表示を行う際のページ分割を実装する。
環境
開発環境は以下の通り。
Eclipse IDE Version: 2023-12 (4.30.0)
PostgreSQL 16.0,
関連記事
サンプルソース
アプリケーションプロパティ
関連記事と同じ。
build.gradle
関連記事と同じ。
サンプルプログラム
一覧
以下のアドレスにアクセスするとログイン画面が表示される。
http://localhost:8080
ログイン完了後に一覧画面に遷移すると表示するデータ(行数)により以下のようにページが分割される。
28ページの場合
2ページの場合
本サンプルのページネーションの仕様。
- 一ページの表示行数は10行
- 先頭ページ(1ページ)と最終ページを常に表示
- ページ選択の両端に「<<」と「>>」を表示してページ番号クリック以外でもページ移動が可能
- 4ページ括りで表示する。1~4ページのレイアウトで5ページ目は5~8ページのレイアウト
- 先頭ページと最終ページの間に省略しているページがある場合は「・・」を表示
サンプルの解説
- 指定されたページのデータの取得はSQLのROW_NUMBER()関数で10件ずつ取得
- 一覧表示のHTMLにnavタグを配置してリスト項目をJavascriptで動的に作成
- navタグの中のリスト項目タグのスタイルシートはBootstrapのページネーションを使用
- ページ情報は一覧表示のHTMLにhidden属性で設定してコントローラーと受け渡しを行う
DTO
ページネーションで利用するのは以下の項目になる。
- private Integer count: データ件数
- private Integer page: 現在のページ
- private Integer maxpage: 最大ページ数
@Data
public class UserForm implements Serializable{
private String code;
private String name;
private String kana;
private String password;
private Integer statecode;
private Integer statuscode;
private String divisionname;
// 写真
private MultipartFile imgfile;
private String contenttype;
private String imgString;
private String searchname;
private String searchkana;
// 状態(複数)
private List<Integer> statekeys;
// 雇用形態
private Integer statusKey;
// 検索で状態と雇用形態の選択肢を保持する
private Map<String, Map<Integer, String>> mapitems;
private String mapstring;
private Map<String, Object> msg;
// 検索結果
private Integer count;
private Integer page;
private Integer maxpage;
public UserForm() {
code = "";
name = "";
kana= "";
password = "";
statecode = 0;
statuscode = 0;
divisionname = "";
imgfile = null;
contenttype = "";
imgString = "";
searchname = "";
searchkana= "";
statekeys = new ArrayList<Integer>();
statusKey = 0;
mapitems = new HashMap<String, Map<Integer, String>>();
mapstring = "";
msg = new HashMap<String, Object>();
count = 0;
page = 0;
maxpage = 0;
}
}
CSS
以下のスタイルはbootstrap.min.cssからコピーしている。
- paginationはulタグ(リスト)のスタイル
- page-itemはliタグ(リスト項目)のスタイル
- page-linkはaタグ(リンク)のスタイル
コピーしないで以下のようにヘッダーに参照を入れるとすべてのスタイルが使える。
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="./css/example.css">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous">
<title>list</title>
</head>
実際やってみるとintegrityの部分が意味不明であるのと原因不明の改行が発生したのでページネーションの部分のみを自前のCSSにコピーしている。
Bootstrap
BootstrapはWeb開発用のフロントエンドのフレームワークでその圧縮版がbootstrap.min.css。HTMLのヘッダーでリンクするとボタン、ナビゲーションバー、フォーム要素などのスタイリングが利用できる。
--省略--
.pagination{display:flex; justify-content:center;list-style:none}
.page-link{position:relative;display:block;color:#0d6efd; font-size: medium; text-decoration:none;background-color:#fff;border:1px solid #dee2e6;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}
.page-item.active
.page-link{z-index:3;color:#fff;background-color:#0d6efd;border-color:#0d6efd}
.page-item.disabled
.page-link{color:#6c757d;pointer-events:none;background-color:#fff;border-color:#dee2e6}
.page-link{padding:.375rem .75rem}
--省略--
HTML
<!DOCTYPE html>
--省略--
<form class="form-center" id="form" method="post" th:object="${UserForm}" th:action="@{/search}">
<input type="hidden" th:field=*{mapstring}>
<input type="hidden" id="code" th:field=*{code}>
<input type="hidden" id="page" th:field=*{page}>
<input type="hidden" id="maxpage" th:field=*{maxpage}>
--省略--
</form>
<nav><ul class="pagination" id="pagination"></ul></nav>
<footer class="footer">
<p>© All rights reserved by PALM</p>
</footer>
<script th:inline="javascript">
window.onload = function() {
let title = /*[[${title}]]*/"title";
let user = /*[[${session.user['fullname']}]]*/"user";
let element_title = document.getElementById("title");
element_title.textContent = title;
let element_user = document.getElementById("user");
element_user.textContent = "ユーザー:" + user;
localStorage.clear();
// ページネーション
let pageElement = document.getElementById("page");
let page = pageElement.value;
const pages = 4;
// 現在のページから4ページ括りの数(親ページ)を求める
let sec = Math.floor((page-1)/pages) + 1;
let maxpageElement = document.getElementById("maxpage");
let maxpage = maxpageElement.value;
html = '';
for (i = (sec-1) * pages + 1; i <= sec * pages && i <= maxpage; i++) {
if (i ==(sec-1) * pages + 1) {
if (page > 1){
// ページが1ページから始まらない場合は「<<」を表示する
html += `<li class="page-item"><a class="page-link" href="javascript:void(0);" onclick="pageclick(${parseInt(page)-1})">«</a></li>`;
}
if (sec > 1){
// 4ページずつ表示するセクションが1でない場合(5ページ目が先頭ページの場合)1ページ目へのリンクと「・・」を表示。
html += `<li class="page-item"><a class="page-link" href="javascript:void(0);" onclick="pageclick(${1})">1</a></li>`;
if (i != 2){
html += `<li class="page-item disabled"><a class="page-link" href="javascript:void(0);">・・</a></li>`;
}
}
}
if (i == page){
html += `<li class="page-item active"><a class="page-link" href="javascript:void(0);" onclick="pageclick(${i})">${i}</a></li>`;
}else{
html += `<li class="page-item"><a class="page-link" href="javascript:void(0);" onclick="pageclick(${i})">${i}</a></li>`;
}
if (i == sec * pages || i == maxpage) {
// 表示する右端
if (sec != Math.floor((maxpage-1)/pages) + 1){
// 4ページずつ表示するセクションが最終でない場合、最終ページへのリンクと「・・」を表示。
if (maxpage != pages+1){
html += `<li class="page-item disabled"><a class="page-link" href="javascript:void(0);">・・</a></li>`;
}
html += `<li class="page-item"><a class="page-link" href="javascript:void(0);" onclick="pageclick(${maxpage})">${maxpage}</a></li>`;
}
if (page != maxpage){
// ページが最終ページで終わらない場合は「>>」を表示する
html += `<li class="page-item"><a class="page-link" href="javascript:void(0);" onclick="pageclick(${parseInt(page)+1})">»</a></li>`;
}
}
}
//ナビゲーションバーの中身を設定する。
document.getElementById('pagination').innerHTML = html;
}
--省略--
- navタグ:他の文書へのナビゲーションリンクを提供するためのセクション
- ulタグ:項目の順序なしリスト
- liタグ:リストの項目
- aタグ:href属性を用いてリンクを作成する
本サンプルではnavタグ内にulタグをid="pagination"で定義してwindow.onloadイベントでliタグ以下を動的に作成している。
<nav><ul class="pagination" id="pagination"></ul></nav>
Javascriptで動的に作成たHTMLをulタグの中身に設定している。
<script th:inline="javascript">
window.onload = function() {
//ナビゲーションバーの中身を設定する。
document.getElementById('pagination').innerHTML = html;
}
ページ番号クリック
ページ番号が変更された場合はページ番号を引数とするpageclickメソッドが起動する。
コントローラーのメソッドに渡す引数のフォームのpageプロパティにページ番号を設定して"/page"にマッピングされたpostメソッドが呼ばれる。
<script th:inline="javascript">
function pageclick(num) {
// 検索項目を変更してからページ変更した場合は
// 検索条件を元にもどしてから変更されたページのデータ検索を行う
let form = document.getElementById("form");
form.reset();
let page = document.getElementById("page");
page.value = num;
form.action="/page";
form.submit();
}
コントローラー
ページ番号をクリックした場合は"/page"にマッピングされたpostメソッドが起動される。Postメソッドからリダイレクトで"/list"にマッピングされたGetメソッドが起動される。
@Controller
public class UserController {
@GetMapping(value={"/list"})
public String getList(@ModelAttribute("UserForm") UserForm form, Model model, HttpSession session) {
model.addAttribute("title", "一覧");
setMapItems(form);
setSearchKey(form, session);
// 職員データの取得
Map<String, List<Map<String, Object>>> data = new HashMap<>();
Integer count = userService.count(form);
form.setCount(count);
form.setMaxpage((int) (Math.floor((count - 1) / 10) + 1));
if (form.getPage() == 0) form.setPage(1);
data.put("users", userService.select(form));
model.addAttribute("data", data);
model.addAttribute("UserForm", form);
return "list";
}
@PostMapping(value={"/page"})
public String postPage(UserForm form, BindingResult result, RedirectAttributes redirectAttributes, HttpSession session){
redirectAttributes.addFlashAttribute("UserForm", form);
session.setAttribute("StateKeys", form.getStatekeys());
session.setAttribute("StatusKey", form.getStatusKey());
return "redirect:/list";
}
}
public String getList()メソッドの処理
引数であるformにcount(データ件数),page(現在のページ),maxpage(最大ページ)が設定されているがここでもデータ件数と最大ページを再設定している。
以下のcount()メソッドでformの検索条件を渡してデータ件数を取得してその値をformのデータ件数のプロパティに設定している。データ件数から最大ページを求めてformの最大ページのプロパティに設定している。
(10はページ当たりの行数)
Integer count = userService.count(form);
form.setCount(count);
form.setMaxpage((int) (Math.floor((count - 1) / 10) + 1));
サービス
以下のメソッドで一覧データを取得する。
public List<Map<String, Object>> select(UserForm form) {
以下でnoが指定ページの先頭行から+10行のデータを取得している。
WHERE no BETWEEN " + Integer.toString((form.getPage()-1)*10+1) + " AND " + Integer.toString(form.getPage()*10);
「no」はSELECTで定義されている。
String query =
"SELECT"+
" ROW_NUMBER() OVER(ORDER BY a.code) no"+
public Integer count()メソッドでデータ件数を取得している。
public Integer count(UserForm form) {
--省略--
int tmp = (Integer) jdbcTemplate.queryForObject(query, Integer.class);
form.setCount(tmp);
@Service
public class UserService {
@Autowired
private JdbcTemplate jdbcTemplate;
public List<Map<String, Object>> select(UserForm form) {
String query =
"SELECT"+
" ROW_NUMBER() OVER(ORDER BY a.code) no"+
",a.code"+
",a.name"+
",a.kana"+
",b.name statename"+
",c.name statusname"+
",a.divisionname"+
",a.remarks"+
",d.image"+
" FROM members a"+
" LEFT JOIN name b"+
" ON a.statecode=b.code AND b.category=2"+
" LEFT JOIN name c"+
" ON a.statuscode=c.code AND c.category=1"+
" LEFT JOIN image d"+
" ON a.code=d.code"+
" WHERE a.name IS NOT null";
if (form.getSearchname() != null && form.getSearchname() != "") {
query += " AND a.name LIKE '" + form.getSearchname() + "%'";
}
if (form.getSearchkana() != null && form.getSearchkana() != "") {
query += " AND a.kana LIKE '" + form.getSearchkana() + "%'";
}
if (form.getStatekeys().size() != 0) {
//状態 通常 休職 退職
query += " AND (";
for (int i = 0; i < form.getStatekeys().size(); i++) {
if (i == 0) {
query += " a.statecode = '" + form.getStatekeys().get(i) + "'";
}else{
query += " OR a.statecode = '" + form.getStatekeys().get(i) + "'";
}
}
query += ")";
}
if (form.getStatusKey() != 0) {
query += " AND (a.statuscode = '" + form.getStatusKey() + "')";
}
query += " ORDER BY code";
query = "SELECT no,code,name,kana,statename,statusname,divisionname,remarks,image" +
" FROM (" + query +") WHERE no BETWEEN " + Integer.toString((form.getPage()-1)*10+1) + " AND " + Integer.toString(form.getPage()*10);
List<Map<String, Object>> tmp = jdbcTemplate.queryForList(query);
for (int i=0; i<tmp.size(); i++) {
if (tmp.get(i).get("image") != null) {
StringBuffer encoded = new StringBuffer();
encoded.append("data:"+ form.getContenttype()+ ";base64,");
encoded.append(Base64.getEncoder().encodeToString((byte[])tmp.get(i).get("image")));
tmp.get(i).put("imgString",encoded.toString());
}else {
tmp.get(i).put("imgString",null);
}
}
return tmp;
}
public Integer count(UserForm form) {
String query =
"SELECT count(*)"+
" FROM members a"+
" LEFT JOIN name b"+
" ON a.statecode=b.code AND b.category=2"+
" LEFT JOIN name c"+
" ON a.statuscode=c.code AND c.category=1"+
" WHERE a.name IS NOT null";
if (form.getSearchname() != null && form.getSearchname() != "") {
query += " AND a.name LIKE '" + form.getSearchname() + "%'";
}
if (form.getSearchkana() != null && form.getSearchkana() != "") {
query += " AND a.kana LIKE '" + form.getSearchkana() + "%'";
}
if (form.getStatekeys().size() != 0) {
//状態 通常 休職 退職
query += " AND (";
for (int i = 0; i < form.getStatekeys().size(); i++) {
if (i == 0) {
query += " a.statecode = '" + form.getStatekeys().get(i) + "'";
}else{
query += " OR a.statecode = '" + form.getStatekeys().get(i) + "'";
}
}
query += ")";
}
if (form.getStatusKey() != 0) {
query += " AND (a.statuscode = '" + form.getStatusKey() + "')";
}
int tmp = (Integer) jdbcTemplate.queryForObject(query, Integer.class);
return tmp;
}
}
おわりに
ネットの記事を探りながらページネーションの機能を実装してみた。本記事に掲載している画像処理については回を改めて投稿したい。