一覧画面とかで見るPaginationをあれしてA.R.E.する

おはようございます。株式会社GxP 向山です。年中半袖Tシャツで過ごしています。
[以前の記事](https://qiita.com/smukoyama-gxp/items/bcd12b152f9d887bbf56)から"新卒入社"の肩書が消えました。何故でしょうか🤔

そんなことより、こちらは[グロースエクスパートナーズ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/gxp)の9日目の記事となります。
今回は普段から一覧画面とかで見かけるアレをBootstrapであれしてA.R.E.していきたいと思います。

# Paginationってなんだよ

**ページャー(pager)**、またの名を**ページネーション(pagination)**

一覧画面で1画面に収まりきらない場合に予め表示する件数を決めておき、溢れた分を次ページにして行き来ができるアレです。

![2023-12-08_19h08_35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/906388/93593daa-19df-f846-fc45-983ba8870ce5.png)
![2023-12-08_19h08_51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/906388/2b15a224-eb5d-2408-eb4e-a341e0ddd430.png)
![2023-12-08_19h09_05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/906388/ea215fc4-3e97-2455-b084-afcc5948fa9a.png)

Qiitaだと2023/12/08現在はこんな感じになってますね。
矢印で1ページごとに進む感じ。
![2023-12-08_18h18_32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/906388/1c4b913c-4d3d-0f20-c176-b1c27b6281a6.png)

Bootstrap[^1]では`pagination`の名前で部品が提供されているので、今回は`pagination`で進めます。

いざ作ろうとした時に検索ワードが悪かったのか自分が求めているものがヒットせず、作るのに少し苦労したので備忘録として残していきます。

# 結論

他サイトの記事[^2]を参考にしつつ、自分が作りたかったpaginationがこちらになります。

<p class="codepen" data-height="300" data-default-tab="html,result" data-slug-hash="eYxXOJz" data-user="smukoyama-gxp" style="height: 300px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/smukoyama-gxp/pen/eYxXOJz">
  Untitled</a> by smukoyama (<a href="https://codepen.io/smukoyama-gxp">@smukoyama-gxp</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

はい、こんな感じに最初と最後のページにいてもpaginationの横幅が変動しないモノを作りたかったのでした。

上のはCodePen上のみで動かせるように調整したものなので、実際に組む時はこんな感じ。（Thymeleafの場合）

```javascript:pagination.js
/**
 * paginationを生成
 * @param currentPage 現在のページ数
 * @param maxPage 計算済みの最大ページ数
 * @param visiblePage 現在のページから表示する前後のページ数
 */
function createPagination(currentPage, maxPage, visiblePage) {
  // 表示するページの範囲を計算
  let startPage = currentPage - visiblePage
  let endPage = currentPage + visiblePage

  // paginationの設定
  let pagination = $('<ul class="pagination justify-content-center">')

  // 最初のページ
  let first = createPaginationLink(1, '<i class="fa-solid fa-angles-left"></i>', currentPage !== 1)
  pagination.append(first)
  // 前のページ
  let back = createPaginationLink(currentPage - 1, '<i class="fa-solid fa-angle-left"></i>', startPage > 1)
  pagination.append(back)

  // ページ番号
  if (startPage < 1) {
    endPage += -(startPage - 1) // 1未満のページ数をendPageに加算
    startPage = 1
  } else if (maxPage < endPage) {
    startPage -= (endPage - maxPage) // maxPage以上のページ数をstartPageに加算
    if (startPage < 1) {
      startPage = 1
    }
    endPage = maxPage
  }
  for (let i = startPage; i <= endPage && i <= maxPage; i++) {
    let number = createPaginationLink(i, i, true)
    if (i === currentPage) {
      number.addClass('active')
      number.css('pointer-events', 'none')
    }
    pagination.append(number)
  }

  // 後のページ
  let next = createPaginationLink(currentPage + 1, '<i class="fa-solid fa-angle-right"></i>', endPage < maxPage)
  pagination.append(next)
  // 最後のページ
  let last = createPaginationLink(maxPage, '<i class="fa-solid fa-angles-right"></i>', currentPage !== maxPage)
  pagination.append(last)

  // 生成したpaginationを設定
  $('#pagination').html(pagination);
}

/*
 * URLから指定したパラメータを取得
 */
function getQueryParam($key) {
  if (1 < document.location.search.length) {
    const query = document.location.search.substring(1);
    const parameters = query.split('&');
    for (let i = 0; i < parameters.length; i++) {
      // パラメータ名とパラメータ値に分割する
      const element = parameters[i].split('=');
      if (element[0] === $key) {
        return element[1];
      }
    }
  }
  return null;
}

/*
 * paginationのリンクを生成
 */
function createPaginationLink(currentPage, label, isEnable) {
  let pageLink = $(`<a class="page-link" href="?page=${currentPage}">`).html(`<div class="text-center" style="width: 1.5rem">${label}</div>`)
  let pageItem = $('<li class="page-item">').html(pageLink)
  if (!isEnable) {
    pageItem.addClass('disabled')
  }
  return pageItem
}
```

```html:list.html
<div class="container-fluid pt-3">
  <!-- list -->
  <div class="row">
    <div class="col">
      <table class="table">
        ...
      </table>
    </div>
  </div>
  <!-- pagination -->
  <div class="row">
    <nav class="col" id="pagination"></nav>
  </div>
</div>

<script th:inline="javascript" type="text/javascript">
  /*<![CDATA[*/
  $(function () {
    // paginationを生成
    let maxPage = /*[[${maxPage}]]*/ 2
    let visiblePage = /*[[${visiblePage}]]*/ 2
    
    // ページ数を取得
    let currentPage = Number(getQueryParam('page'));
    if (currentPage < 1) {
      currentPage = 1;
    }
    createPagination(currentPage, maxPage, visiblePage)
  })
  /*]]>*/
</script>
```

paginationはjavascriptを使って組み立て、最終的に`<nav id="pagination"></nav>`に埋め込まれます。

# 説明

今回記事にした理由はもう一つあって、paginationを調べると実装方法がサンプルみたいで3ページぐらいまでしかないものが多く、動的にページ数を計算しているかつ説明している記事が無かったため作りました。

見た目のためにBootstrapとFont Awesomeを使ってますが、基本的にはそのままでもある程度動きます。

## 最大ページ数の計算

今回、Javaでサーバー側を開発しているため、DBから一覧に表示するためのリストを取得し、画面を表示する前に最大ページ数を計算しています。
計算ロジックは言うまででもないかなぁ、とは思いますが一応載せておくとこんな感じ。

```java
// 一覧に表示するリストを取得
List<Entity> results = service.find();
// 最大のページ数を計算
Integer maxPage = 1;
if (results.size() > 0) {
    double div = (double) results.size() / (double) pageSize; // pageSizeには1ページで表示する件数を設定
    maxPage = (int) Math.ceil(div); // 小数点以下を切り上げ
}
```

## paginationの生成

ブラウザにページが表示されるタイミングでURLのクエリパラメータにある`page`から現在表示しているページ数を取得します。
取得できなかった場合はデフォルトで最初のページである`1`を設定します。

`createPagination`を呼び出し、paginationの生成が始まります。

```javascript
// ページ数を取得
let currentPage = Number(getQueryParam('page'));
if (currentPage < 1) {
  currentPage = 1;
}
```

### paginationリストを組み立て

先に矢印やページ数を詰めるための要素を作っておきます。

```javascript
let pagination = $('<ul class="pagination justify-content-center">')
```

`justify-content-center`があるのは画面で中央に表示させるためです。

ここからpaginationのリンクを生成していきますが、処理を共通化させたかったので`createPaginationLink`を作成しています。

```javascript
/*
 * paginationのリンクを生成
 */
function createPaginationLink(currentPage, label, isEnable) {
  let pageLink = $(`<a class="page-link" href="?page=${currentPage}">`).html(`<div class="text-center" style="width: 1.5rem">${label}</div>`)
  let pageItem = $('<li class="page-item">').html(pageLink)
  if (!isEnable) {
    pageItem.addClass('disabled')
  }
  return pageItem
}
```

引数には`移動先のページ数(page)`, `表示するラベル(label)`, `リンクの活性状態(isEnable)`を指定していきます。

### 最初のページ, 前のページ

最初のページである1ページへ移動するリンクと、現在表示しているページから1つ前のページへ移動するリンクです。

```javascript
// 最初のページ
let first = createPaginationLink(1, '<i class="fa-solid fa-angles-left"></i>', currentPage !== 1)
pagination.append(first)
// 前のページ
let back = createPaginationLink(currentPage - 1, '<i class="fa-solid fa-angle-left"></i>', startPage > 1)
pagination.append(back)
```

1ページ目を表示するため、最初のページへ移動するリンクには1を固定で指定し、前のページには現在のページから-1の値を指定します。
リンクの活性状態については最初のページへ移動するリンクの場合は1ページ目以外にいるか、
前のページへ移動するリンクの場合は`startPage`が1より大きい、つまりは1が表示されていないタイミングで活性となるようにしています。

### 現在のページ, 前後のページ

数字部分のページのリンクを作成します。

```javascript
// ページ番号
if (startPage < 1) {
  endPage += -(startPage - 1) // 1未満のページ数をendPageに加算
  startPage = 1
} else if (maxPage < endPage) {
  startPage -= (endPage - maxPage) // maxPage以上のページ数をstartPageから減算
  if (startPage < 1) {
    startPage = 1
  }
  endPage = maxPage
}
for (let i = startPage; i <= endPage && i <= maxPage; i++) {
  let number = createPaginationLink(i, i, true)
  if (i === page) {
    number.addClass('active')
    number.css('pointer-events', 'none')
  }
  pagination.append(number)
}
```

リンクを作成する部分については単純で、`startPage`から`endPage`までのページ数をループで回しています。
リンクの活性状態についてはどれも有効となるため`true`としていますが、現在のページについてはリンクは不要なのとclassに`active`を追加するため、別でclassとcssを設定しています。

`startPage`, `endPage`のデフォルトの値として、現在表示しているページから前後に表示するページを計算しますが、最初や最後のページだった場合にマイナスや最大ページ数以上となって欠けてしまったページ数を逆側に拡張するため、条件を追加しています。

`startPage`が1未満となった場合は`endPage`に欠けてしまったページ数を加算したいため、`startPage - 1`で0からのマイナス分として計算しておき、計算結果にマイナスを付けておくことで負数を正数に反転した値を`endPage`に加算していきます。
例として「現在のページから表示する前後のページ数」に`2`、「現在表示しているページ」が`1`だった場合、`startPage`には`-1`が設定されています。この溢れた1ページを`endPage`に加算したいため、`-(-1) + endPage(1+2)`となり`endPage`には`4`が設定されます。

`endPage`が最大ページ数以上となった場合は`startPage`から欠けてしまったページ数を減算したいため、こちらも同じ要領で`endPage - maxPage`で溢れた分のページを計算し、`startPage`を減算していきます。
この時、例えば「現在のページから表示する前後のページ数」に`2`「最大ページ数」に`3`、「現在表示しているページ」が`3`だった場合、`startPage`には`5(endPage) - 3(maxPage)`で`2`が算出され、`1`だった`startPage`から引かれて1未満となってしまうため、この場合は`1`を設定するようにしています。
因みに`endPage`についてはfor文にて`i <= maxPage`があるため上限を超えることはありません。

### 次のページ, 最後のページ

最初のページ, 前のページとあまり変化ないので説明は省略します。

```javascript
// 後のページ
let next = createPaginationLink(currentPage + 1, '<i class="fa-solid fa-angle-right"></i>', endPage < maxPage)
pagination.append(next)
// 最後のページ
let last = createPaginationLink(maxPage, '<i class="fa-solid fa-angles-right"></i>', currentPage !== maxPage)
pagination.append(last)
```

## paginationを埋め込み

ここまで組み立てたHTMLを`<nav id="pagination"></nav>`へ埋め込みます。

```javascript
// 生成したpaginationを設定
$('#pagination').html(pagination);
```

# 感想

よく見る部品なのでたくさん情報が転がっていると思っていたのですが、実際探してみると数字をハードコーディングで設定している記事が多く、意外に求めているものがなくてびっくりしました。
久々にロジックを考えさせられたので、夢中になった部分もあり、ちょっと楽しかったです。
最近はGoogleの検索一覧を見てみると「Load More」とか「無限スクロール」が採用されていてスマホに特化したUXなのかな、と思います。
ただ、これらのいずれもメリデメがあるため、利用者やシーンを考慮して適切な技術を採用して頂ければと思います。[^3]

[^1]: [Pagination · Bootstrap](https://getbootstrap.com/docs/4.0/components/pagination/)
[^2]: [BootstrapとJavascriptを使用したページネーションのサンプル | 年収アップへの道](https://miyanetdev.com/archives/361)
[^3]: [Pagination, incremental page loading, and their impact on Google Search](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading)