ライブラリなどを利用せず自力でVueのページネーション処理を実装したのでその手順や考え方などを書いておく。
特にここでは、数十枚の画像がグリッド状に並んでいるような画面でのページネーションを想定しており、ページ番号ボタンなどをクリックすると表示される画像が切り替わるというものである。
なお、一覧表示対象のデータリストをバック側から取得する部分は省略し、すでにフロント側にデータリストがあるところからの実装となる。
完成イメージ
- 大量の画像データを複数ページに分けて一覧表示する
- ページ番号ボタンやページ送りボタン(1つ戻る<、1つ進む>、先頭へ戻る<<、最後へ進む>>)で表示ページを操作する
- 1ページ目を表示中は<, <<を非表示に、最後のページを表示中は>, >>を非表示にする
- 1度に表示されるページ番号ボタンの個数を制限する
環境と画面構成
環境
- Vue 3.2.26
- Vue CLI 4.5.13
- Node 12.16.3
- npm 6.14.4
- typescript 4.1.5
画面構成
└── src ├── assets │ └── image │ ├── blue.jpg │ ├── green.jpg │ └── red.jpg └── components ├── ListView.vue └── Pagination.vue
ListView.vue
が画像を一覧表示するコンポーネント、Pagination.vue
はページネーション関係の要素を表示するコンポーネントになっている。
ListView
コンポーネントの中にPagination
コンポーネントを配置するので、ListView
が親でPagination
が子という関係になる。
また、ページネーションの動きを確認するためのテスト画像としてblue.jpg
、green.jpg
、red.jpg
という3枚を用意しassets/image
ディレクトリに置いた。(動作確認をするだけなら全部同じ画像でも良かったが、縦横のサイズが異なる画像で表示を確認したかったので)
画像一覧のグリッド表示
100数件ほどの画像パスが入ったダミーのデータ配列を作成し、それぞれのパスを元に画像を参照しグリッド状に並べて表示する。
今回はフロント側のみの実装なのでこのようなデータ配列を用意しておくが、本来ならばバック側と通信を行うなどして取得してくるデータとなる。
ダミーのデータ配列を作成
assets/images
にある3枚のテスト画像の中からランダムに選び、ダミーのデータ配列となるdummyImages
に選んだ画像のパスを格納する。
まず、元となる3枚の画像のパスを格納したimageSet
配列を用意する。
img
タグのsrc
属性にv-bind
で画像パスを指定することになるが、パスの文字列をrequire()
で囲わないと表示することができない。
次に、一覧表示したい画像の数と同じ要素数の配列dummyImages
を生成し全ての要素をnullで埋める。
その後、map()
関数で各要素を画像パスの文字列に置き換えていく。
Math.floor(Math.random() * imageSet.length)
で0からimageSet.length - 1
までの整数(今回は0~2)をランダムに生成できるので、この値をimageSet
のインデックスとして指定すればよい。
export default defineComponent({ components: { Pagination }, setup() { // 確認用ダミー画像データ const imageSet = [ require('@/assets/images/green.jpg'), require('@/assets/images/red.jpg'), require('@/assets/images/blue.jpg'), ] // 三種類の画像をランダムに選んで配列に格納する const dummyImages = new Array(103).fill(null).map(() => imageSet[Math.floor(Math.random() * imageSet.length)]) return { dummyImages } }
画像の一覧表示
ListView
コンポーネントのテンプレートとスタイルを記述していく。
dummyImages
の中身をv-for
で取り出しそのまま表示している。
ページネーション処理はこの後作っていくのでとりあえず今は100件ぐらいの画像がずらっと並んでいる状態になる。
<template> <div class="thumbnail-container"> <div class="thumbnail" v-for="(url, index) in dummyImages" :key="index"> <img :src="url"> </div> </div> </template>
画像一覧を囲む.thumbnail-container
要素に対してdisplay: flex;
と flex-wrap: wrap;
を指定すると、ウインドウ幅に合わせて一行あたりの要素数を調整して表示できる。(ウインドウ幅から溢れた分は折り返され下の行に入る。)
<style scoped> .thumbnail-container { display: flex; flex-wrap: wrap; } .thumbnail { display: flex; margin-top: 20px; width: 200px; height: 200px; align-items: center; justify-content: center; } img { max-width: 195px; max-height: 195px; } </style>
ページネーション
ページネーション処理の実装
Pagination
コンポーネントを実装していく。
まず、親のListView
コンポーネントからプロパティを受け取れるようにprops
の部分を追加する。
total | 画像リストの総数。 |
imagesPerPage | 1ページあたりに表示する画像の枚数。 |
displayRange | ページ番号ボタンの最大表示数。 |
次に、Pagination
コンポーネントのプロパティを定義する。
pages | totalとimagesPerPageを使ってページ総数を計算し、1から始まるページ番号を要素として持つ配列を作成する。 |
currentPage | 現在表示しているページの番号で初期値は1を指定する。 内部値がnumber型のrefオブジェクトである。 |
画像リスト全体のうち何件目〜何件目を現在表示しているかという情報を示すもので、string
型の算出プロパティとして定義する。
現在ページの先頭の画像が何番目であるかは(現在のページ番号 - 1) * 1ページあたりの画像表示数 + 1
として求められるので、currentPage
とimagesPerPage
を使って計算する。
現在ページの末尾の画像が何番目であるかは現在のページ番号 * 1ページあたりの画像表示数
で求められるが、最終ページの場合はリストの総数を超えてしまう可能性があるのでtotal
と比較して小さい方を採用する。
currentPage
の値を更新するとともに、その情報をListView
コンポーネントに送る。
ページ番号ボタンやページ送りボタン(<<, <, >, >>)をクリックした時に、次に表示すべきページの番号を引数page
として受け取り、page
でcurrentPage
を更新する。
次のページ番号がページ総数の範囲外となってしまってはいけないので、page
が1〜最終ページに収まる場合だけ処理を行う。(この後でページ送りボタンの表示制御を入れるのでそもそも範囲外は選択できないようになるが、念のためここの処理を入れておくほうがいい)
context.emit()
関数を利用して、currentPage
という名前でpage
の値を親コンポーネントに送ることができる。
setup()
の外側でemits: ['currentPage']
を指定しておくのも忘れずに。
export default defineComponent ({ props: { total: { required: true, type: Number }, imagesPerPage: { required: true, type: Number }, displayRange: { required: true, type: Number } }, emits: ['currentPage'], setup(props, context) { // 1から始まるページ番号を要素として持つ配列を作成する const pages = new Array(Math.ceil(props.total / props.imagesPerPage)).fill(null).map((e, i) => i + 1) const currentPage = ref(1) const displayInfo = computed(() => { const start = (currentPage.value - 1) * props.imagesPerPage + 1 const end = Math.min(currentPage.value * props.imagesPerPage, props.total) return `${ props.total }件中 ${ start } - ${ end }件を表示` }) // 選択中のページ番号でcurrentPageを更新し、親コンポーネントに選択ページ番号の情報を送る const setCurrentPage = (page: number) => { if (page >= 1 && page <= pages.length ) { currentPage.value = page context.emit('currentPage', page) } } return { pages, currentPage, displayInfo, setCurrentPage } } })
ページネーション用のボタンを表示する
ListView
のテンプレート部分を記述していく。
ページ番号ボタンやページ送りボタンはそれぞれli
要素とし、特にページ番号ボタンはpages
を元にv-for
で繰り返し要素を作っている。
現在選択されているページ番号ボタンは色を変えて分かりやすくしたいので、page === currentPage
の比較を行い真ならactive
クラスを、偽ならinactive
クラスを付けるようにしている。
ページネーション用ボタンのクリックイベントにはsetCurrentPage()
をバインドしている。
setCurrentPage()
に渡す引数は、ページ番号ボタンならpage
、1つ戻るボタンならcurrentPage - 1
、1つ進むボタンならcurrentPage + 1
、先頭に戻るボタンなら1
、最後へ進むボタンならpages.length
とすればいい。
また、ページジング用ボタンと並べて画像の表示件数の情報も表示したいので、displayInfo
をそのまま出力する。
<template> <div class="pagination-container"> <ul class="pagination"> <li class="arrow" @click.prevent="setCurrentPage(1)"><<</li> <li class="arrow" @click.prevent="setCurrentPage(currentPage - 1)"><</li> <li v-for="page in pages" :key="page" @click.prevent="setCurrentPage(page)" :class="page === currentPage ? 'active' : 'inactive'" > {{ page }} </li> <li class="arrow" @click.prevent="setCurrentPage(currentPage + 1)">></li> <li class="arrow" @click.prevent="setCurrentPage(pages.length)">>></li> </ul> </div> <div> {{ displayInfo }} </div> </template>
これで画像のようにページネーション用の要素が縦に並ぶ。
後はこれらがボタンらしく見えるようにスタイルを付けていく。
li
要素を囲むul
要素にdisplay: flex;
を設定すればボタンが良い感じに横並びになる。
その他のボタンの形や背景色などのスタイルはお好みで。
<style scoped> .pagination-container { display: flex; margin: 10px 0; justify-content: center; } .pagination { display: flex; list-style-type: none; } li { margin: 0 1px; box-shadow: 1px 2px 3px rgba(50,50,50,0.05); border-radius: 3px; padding: 6px 12px; text-align: center; cursor: pointer; } .active { font-weight: bold; color: #ffffff; background: #5abb6a; } .inactive, .arrow { background: #ffffff; } .inactive:hover, .arrow:hover { background: #c6e6cb; } </style>
ページネーション用ボタンの表示制御
今のままだと、例えば1ページ目を表示しているのに更に「1つ前に戻る」ボタンがクリックできてしまったり、ページ番号ボタンが個数分全部表示されてしまったりする(100ページあれば100個のページ番号ボタンが横にずらっと並ぶ)ので、ページネーション用ボタンの表示制御を入れておいたほうがいい。
ページ送りボタンの表示制御のためのフラグで、それぞれ算出プロパティとして定義する。
現在表示しているページが1ページ目なのか、もしくは最終ページなのかを判定して結果を返しているだけだ。
このフラグが真の時は対応するページ送りボタンを非表示にする。
これまでpages
としてそのまま全部表示していたページ番号ボタンをdisplayRange
の値に応じてフィルタリングし、displayPages
という算出プロパティに置き換えて利用する。
少し複雑な処理になり、ページ総数とページ番号ボタンの最大表示数の大小に応じて場合分けして考える必要がある。
以下、「ページ番号」は1始まりの整数列、「インデックス」は0始まりの整数列というように使い分けることにする。
[ 1 ] pages.length > displayRange の場合
ページ総数が最大表示数より大きい場合である。
まず前提として、currentPage
を中心に左右同じ数だけページ番号ボタンを表示させるものとする。
例えばdisplayRange
が5であればcurrentPage
の左右に2個ずつボタンが並ぶことになる。
ただし、displayRange
は必ず奇数で設定しなければならないので注意。
displayRange
を2で割った数の整数部分(displayRange
が5ならその数は2)をcurrentPage
から引けばボタン表示範囲(pages
配列から切り出す範囲)の下端のページ番号になり、currentPage
に足せばボタン表示範囲の上端のページ番号になる。
したがって、ボタン表示範囲の下端・上端のページ番号からそれぞれ1を引けば、ボタン表示範囲の下端のインデックスlowerIndex
と上端のインデックスupperIndex
を求めることができる。
基本的にはこの計算方法でよいが、currentPage
が1ページ目や最終ページに近づいていくとlowerIndex
が0未満になったりupperIndex
がpages
の長さより大きくなってしまう場合があるので調整が必要になる。
(1) lowerIndex < 0 となる場合
currentPage
を中心にボタンを表示するのではなく、ページ番号1から右方向にdisplayRange
個のボタンを固定して表示する。
lowerIndex
は0に修正すればよい。
(2) upperIndex > pages末尾のインデックス となる場合
同様にcurrentPage
を中心にボタンを表示するのではなく、最終ページのページ番号から左方向にdisplayRange
個のボタンを固定して表示する。
lowerIndex
はpages
の長さからdisplayRange
を引いた値に修正すればよい。
このようにして求めたlowerIndex
を起点として、pages
からdisplayRange
個の要素をsplice()
メソッドにより切り出して返す。
ただし、splice()
は破壊的メソッドで元のpages
を変更してしまうので、一旦slice(0, pages.length)
でpages
の値渡しのコピー配列を作り、それに対してsplice()
による切り出しを行なっている。
[ 2 ] pages.length <= displayRange の場合
ページ総数が最大表示数以下の場合なので何も考える必要はなくpages
をそのまま返せば良い。
// ページ送りボタン(<<, <, >, >>)の表示制御フラグ const isLowerEnd = computed(() => { return currentPage.value === 1 }) const isUpperEnd = computed(() => { return currentPage.value === pages.length }) // ページ番号ボタンの表示制御 const displayPages = computed(() => { // 最大でdisplayRange個のページ番号ボタンを表示する if (pages.length > props.displayRange) { let lowerIndex = currentPage.value - Math.floor(props.displayRange / 2) - 1 let upperIndex = currentPage.value + Math.floor(props.displayRange / 2) - 1 if (lowerIndex < 0) { lowerIndex = 0 } if (upperIndex > pages.length - 1) { lowerIndex = pages.length - props.displayRange } return pages.slice(0, pages.length).splice(lowerIndex, props.displayRange) } else { return pages } })
テンプレートの方を修正する。
ページ番号ボタンのli
はdisplayPages
でループを回すようにし、ページ送りボタンにはv-if="!isLowerEnd"
またはv-if="!isUpperEnd"
を追加する。
<ul class="pagination"> <li v-if="!isLowerEnd" class="arrow" @click.prevent="setCurrentPage(1)"><<</li> <li v-if="!isLowerEnd" class="arrow" @click.prevent="setCurrentPage(currentPage - 1)"><</li> <li v-for="page in displayPages" :key="page" @click.prevent="setCurrentPage(page)" :class="page === currentPage ? 'active' : 'inactive'" > {{ page }} </li> <li v-if="!isUpperEnd" class="arrow" @click.prevent="setCurrentPage(currentPage + 1)">></li> <li v-if="!isUpperEnd" class="arrow" @click.prevent="setCurrentPage(pages.length)">>></li> </ul>
PaginationをListViewコンポーネントへ配置する
Pagination
コンポーネントでのページ番号の変更をListView
コンポーネントで検知し、表示するページを変更できるようにしよう。
まず、ページネーションに関わるプロパティを色々定義しておく。
currentPage |
現在表示しているページの番号で初期値は1を指定する。 内部値がnumber型のrefオブジェクトである。 Paginationからページ番号の変更イベントが伝わり更新される。 |
imagesPerPage |
1ページあたりに表示する画像の枚数。 基本的にこれは変更しないのでrefでなくていい。 |
displayRange |
ページ番号ボタンの最大表示数。 これも変更しないのでrefでなくていい。 |
Pagination
コンポーネントでページ番号ボタンなどをクリックすると次に表示すべきページ番号が送られてくるので、そのページ番号でcurrentPage
を更新する。
currentPage
の値に応じてページに表示すべき画像データをdummyImages
から抽出し、displayImages
という算出プロパティに置き換えて利用する。
先頭の画像データのインデックスstartIndex
は(現在のページ番号 - 1) * 1ページあたりの画像表示数
として計算でき、末尾の画像データのインデックスendIndex
はstartIndex
に1ページあたりの画像表示数 を加えれば求められる。
このstartIndex
とendIndex
を引数としてslice()
メソッドでdummyImages
から必要な要素を抽出できる。
export default defineComponent({ components: { Pagination }, setup() { // 省略 // ページネーション関係のパラメータ const currentPage = ref(1) const imagesPerPage = 10 const displayRange = 9 const updateCurrentPage = (page: number) => { currentPage.value = page } // 現在のページに表示する画像だけを抽出する const displayImages = computed(() => { const startIndex = (currentPage.value - 1) * imagesPerPage const endIndex = startIndex + imagesPerPage return dummyImages.slice(startIndex, endIndex) }) return { dummyImages, currentPage, imagesPerPage, displayRange, updateCurrentPage, displayImages } } }) </script>
テンプレートの方では、Pagination
をListView
へ追加する。
Pagination
で定義していたtotal
、imagesPerPage
、displayRange
の各プロパティに対して属性値を渡す。
また、Pagination
のcurrentPage
イベント(ページ番号ボタンなどをクリックすると発火)とupdateCurrentPage()
を紐付ける。
<template> <div class="thumbnail-container"> <div class="thumbnail" v-for="(url, index) in displayImages" :key="index"> <img :src="url"> </div> </div> <div> <Pagination :total="dummyImages.length" :imagesPerPage="imagesPerPage" :displayRange="displayRange" @currentPage="updateCurrentPage($event)" /> </div> </template>
おわりに
ページ番号ボタンをフィルタリングする部分は少し複雑だが、それ以外の機能は比較的容易に実装することができる。
今回は作っていないが、例えば1ページあたりのデータ表示数を切り替える処理が欲しい時は、imagesPerPage
をref
オブジェクトにするなどして簡単に作れそうだ。
Vueでページネーションを実現するためのライブラリもあるにはあるが、基本機能だけサッと組み込みたい場合は自分で作ってしまったほうが早そうだ。
全体のコードはこちらに掲載している。