1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

今さら聞けないページネーションの実装サンプル

Last updated at Posted at 2025-12-18

はじめに

この記事は Java Advent Calendar 2025 19日目の記事です。

毎年QiitaのAdventCalendarが始まると「年末だなぁ」って眺めてるだけでしたが、今年は初めて参加してみました。

というわけで、org.springframework.data.domain.Page を使えば簡単に実装できるページネーションですが、ページ数の直リンクや最初や最後へ飛ぶリンクを出す条件は地味に計算が必要なのでサンプルを公開します。

環境

  • Java 21
  • Spring Boot
    • spring-boot-starter-parent:3.4.11
      • spring-boot-starter-web
      • spring-boot-starter-thymeleaf
      • spring-boot-starter-data-jpa
      • lombok

実装サンプル

本の一覧を表示するページを想像してください。今回のサンプルで出来上がるページャー部分はこんな感じです。
なお、1ページしか存在しない場合はページ送りの部分は表示せず、「3/7ページ(全20件)」の部分だけを表示するようにしています。

スクリーンショット 2025-12-15 145436.png

※ページングと直接関係ない部分は注釈なく省略している部分がありますので悪しからず

application.yml

application.yml
spring:
  data.web.pageable:
    default-page-size: 10   #(1)
    one-indexed-parameters: true   #(2)

(1) default-page-size : 1ページにいくつデータを出すかです。Controllerの引数 @PageableDefault で個別指定もできるのでここではデフォルト値を入れます。

(2) one-indexed-parameters : URLにつくpageパラメータのindexを1から始めるかどうかです。デフォルトは false (0始まり)です。
例えば https://your-domain.com/list?page=1 の場合、true なら1ページ目扱い、false だと2ページ目扱いになります。正直好みの問題ではありますが、個人的には true の方が直感的で好きです。

Controller

BookController.java
package com.tamorieeeen.book.controller;

import java.util.Map;

import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort.Direction;
import org.springframework.data.web.SortDefault;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;

import com.tamorieeeen.book.service.BookService;

import lombok.RequiredArgsConstructor;

/**
 * @author tamorieeeen
 */
@Controller
@RequiredArgsConstructor
public class BookController {

    private final BookService bookService;

    /**
     * 一覧
     */
    @GetMapping("/")
    public String list(Model model,
            @SortDefault(sort = "bookId", direction = Direction.DESC) Pageable pageable) {

        model.addAttribute("page", bookService.getPage(pageable));

        return "list";
    }
}

@SortDefault でソート順をbookIdの降順に指定しています。
このページだけ application.ymldefault-page-size で設定した値を変更する場合は、代わりに @PageableDefault を使って下記のようにsizeも指定します。

@PageableDefault(size = 15, sort = "bookId", direction = Direction.DESC)

ControllerAdvice

AppControllerAdvice.java
package com.tamorieeeen.book.controller;

import jakarta.servlet.http.HttpServletRequest;

import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;

/**
 * @author tamorieeeen
 */
@ControllerAdvice
public class AppControllerAdvice {

    @ModelAttribute("currentPath")
    public String getUrl(HttpServletRequest request) {

        return request.getRequestURI();
    }
}

ページ送りのhref用として、現在のパスをフロント側から ${currentPath} で取得できるように @ControllerAdvice で定義しておきます。

Service

BookService.java
package com.tamorieeeen.book.service;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;

import com.tamorieeeen.book.model.PageModel;
import com.tamorieeeen.book.dao.entity.UsrBook;
import com.tamorieeeen.book.dao.repository.UsrBookRepository;

import lombok.RequiredArgsConstructor;

/**
 * @author tamorieeeen
 */
@Service
@RequiredArgsConstructor
public class BookService {

    private final UsrBookRepository usrBookRepository;

    /**
     * ページを取得
     */
    public PageModel<UsrBook> getPage(Pageable pageable) {

        return new PageModel<>(usrBookRepository.findAll(pageable));
    }
}

UsrBookRepositoryUsrBook の実装は省略しますが、

  • UsrBookRepository : JpaRepositoryをextendsしたinterface
  • UsrBook : int bookIdString title を持つEntity

です。また、本来はEntity(UsrBook)をそのままフロント側へ渡すべきではないですが、省略のためそのまま渡しています。

ページャー

本日の主役の登場です。repositoryから取得した Page<T> page を渡すだけでページングに必要な変数を計算するようにしています。また、どのEntityでも使えるようにジェネリクスにしています。

PageModel.java
package com.tamorieeeen.book.model;

import java.util.List;

import org.springframework.data.domain.Page;

import lombok.Getter;

/**
 * ページング
 */
@Getter
public class PageModel<T> {

    // 何ページ分出すか(奇数想定)
    private static final int MAX_PAGE = 3;
    // 現在のページの両側に何ページ分出すか
    private static final int SIDE_PAGE = (MAX_PAGE -1)/2;

    private List<T> contents;
    private int currentPage;
    private int totalCount;
    private int totalPage;
    private boolean previous;
    private boolean next;
    private boolean paging;
    private int pageFrom;
    private int pageTo;
    private boolean first;
    private boolean last;

    public PageModel(Page<T> page) {

        this.contents = page.getContent();
        // 現在のページ...(1)
        this.currentPage = page.getNumber() + 1;
        this.totalCount = (int) page.getTotalElements();
        // トータルページ数...(2)
        this.totalPage = page.getTotalPages() == 0 ? 1 : page.getTotalPages();
        this.previous = page.hasPrevious();
        this.next = page.hasNext();
        // ページング表示を出すかどうか...(3)
        this.paging = this.totalPage > 1;

        int defaultFrom = this.currentPage - SIDE_PAGE;
        int defaultTo = this.currentPage + SIDE_PAGE;

        // ページリンク:開始ページ...(4)
        int from = defaultFrom;
        if (defaultFrom < 1) {
            from = 1;
        } else if (this.totalPage < defaultTo) {
            from = Math.max(this.totalPage - MAX_PAGE + 1, 1);
        }
        this.pageFrom = from;

        // ページリンク:終了ページ数...(5)
        int to = defaultTo;
        if (this.totalPage <= MAX_PAGE) {
            to = this.totalPage;
        } else if (defaultTo < MAX_PAGE) {
            to = MAX_PAGE;
        } else if (this.totalPage < defaultTo) {
            to = this.totalPage;
        }
        this.pageTo = to;

        // 最初へのリンクを出すかどうか...(6)
        this.first = !page.isFirst() && page.getTotalPages() > MAX_PAGE && this.pageFrom != 1;
        // 最後へのリンクを出すかどうか...(7)
        this.last = !page.isLast() && page.getTotalPages() > MAX_PAGE && this.pageTo != page.getTotalPages();
    }
}

(1) page.getNumber()one-indexed-parameterstrue にしても0始まりなため、現在のページ数は+1する必要があります。

(2) page.getTotalPages() はデータが0件の場合に0が返ってきますが、1/1ページとして表示するため0の場合は1にしています。

(3) 2ページ目以降が無い場合はページリンクを出さないようにしています。

(4) ページリンクの開始ページは下記で算出しています。

  • デフォルトは現在のページから SIDE_PAGE を引いた数
  • デフォルトが1未満になる場合は1
    • 例) SIDE_PAGEが2(つまりMAX_PAGE=5)で現在が2ページ目の場合、デフォルトは0になるが、ページリンクは1から出したいので1
  • トータルページ数がページリンク終了ページのデフォルト未満の場合はtotalPage - MAX_PAGE + 1か1のどちらか大きい方
    • 例) SIDE_PAGEが1(つまりMAX_PAGE=3)の場合
    • トータルページ数が4で4ページ目の場合、ページリンク終了ページのデフォルトは5ですが、ページリンクは2~4を出したい、4-3+1=2 > 1なので2
    • トータルページ数が2で2ページ目の場合、ページリンク終了ページのデフォルトは3ですが、ページリンクは1,2を出したい、2-3+1=0 < 1なので1

(5) ページリンクの終了ページは下記で算出しています。

  • デフォルトは現在のページに SIDE_PAGE を足した数
  • トータルページ数が MAX_PAGE 以下の場合はトータルページ数
    • 例) MAX_PAGEが5、トータルページ数が4の場合、ページリンクは現在のページ数に関わらず1~4を出すため4
  • デフォルトが MAX_PAGE 未満の場合は MAX_PAGE
    • 例) MAX_PAGEが3、トータルページ数が4、現在のページ数が1の場合、ページリンクは1~3を出すため3
  • トータルページ数がデフォルト未満の場合はトータルページ数
    • 例) MAX_PAGEが3、トータルページ数が4、現在のページ数が4の場合、デフォルトは5だが、ページリンクは2~4を出したいのでトータルページ数の4

(6) 「最初へ(サンプルでは<<)」のリンクを出すのは下記の条件を満たす場合です。

  • 現在のページが最初のページではない
  • トータルページ数が MAX_PAGE より大きい
  • ページ直リンクが1からになっていない

(7) 「最後へ(サンプルでは>>)」のリンクを出すのは下記の条件を満たす場合です。

  • 現在のページが最後のページではない
  • トータルページ数が MAX_PAGE より大きい
  • ページ直リンクが最終ページまでになっていない

HTML

list.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{common :: meta_header('本一覧',~{::link},~{::script})}">
  <link rel="stylesheet" href="/css/list.css" />
</head>
<body>
  <h1>本一覧を表示するサンプル</h1>
  <table>
    <tr><th>本ID</th><th>タイトル</th></tr>
    <tr th:each="book : ${page.contents}">
      <td>[[${book.bookId}]]</td>
      <td>[[${book.title}]]</td>
    </tr>
  </table>
  <div th:object="${page}" class="paging">
    <div th:if="*{paging}" class="links">
      <ul>
        <li th:if="*{first}"><a th:href="@{${currentPath}(page=1)}">&lt;&lt;</a></li>
        <li th:if="*{previous}" class="arrow"><a th:href="@{${currentPath}(page=*{currentPage-1})}">&lt;</a></li>
        <li th:each="i : *{#numbers.sequence(pageFrom, pageTo)}" class="number" th:classappend="*{currentPage}==${i}?'current'"><a th:href="@{${currentPath}(page=${i})}">[[${i}]]</a></li>
        <li th:if="*{next}" class="arrow"><a th:href="@{${currentPath}(page=*{currentPage+1})}">&gt;</a></li>
        <li th:if="*{last}"><a th:href="@{${currentPath}(page=*{totalPage})}">&gt;&gt;</a></li>
      </ul>
    </div>
    <div class="total">[[*{currentPage}]]/[[*{totalPage}]]ページ<br>(全[[*{totalCount}]]件)</div>
  </div>
  <div></div>
</body>
</html>

基本的には PageModel で計算した値を呼び出して使うだけですが、ページ直リンク部分の現在のページは色を変えたかったので th:classappend="*{currentPage}==${i}?'current'" でliタグのclassにcurrentを追加しています。

※headerは共通化していてreplaceで入れ替えています。過去に解説してる記事があるので気になる方はどうぞ

CSS

CSSは各自で調整してもらえれば良いのですが、参考程度に載せておきます。

list.css
@charset "UTF-8";
/* -- pagination -- */
.paging {
  margin-top: 1rem;
}
.paging .links {
  margin-bottom: 0.5rem;
}
.paging ul {
  list-style: none;
  display: flex;
  align-items: center;
  -webkit-align-items: center;
  padding: 0;
  margin: 0;
}
.paging ul > li {
  padding: 0;
  width: 35px;
  height: 35px;
  text-align: center;
  border-radius: 10px;
}
.paging ul > li.number {
  margin: 0 2px;
  background: #e0dbbf;
}
.paging ul > li.arrow {
  margin: 0 7px;
}
.paging ul > li.current,
.paging ul > li:hover {
  background: #c7be8a;
}
.paging ul > li a {
  display: block;
  width: 100%;
  height: 100%;
  text-decoration: none;
  color: #484323;
  line-height: 35px;
  font-size: 0.9em;
}
.paging .total {
  text-align: center;
}

誰かの参考になれば嬉しい限りです。では、よいお年を!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?