HAKUTAI Tech Notes

IT関連、数学のことなどを主として思いつきで色々書き溜めていきます

【Vue.js / Typescript】画像一覧+ページネーションを実装する(フロント側のみ)

ライブラリなどを利用せず自力でVueのページネーション処理を実装したのでその手順や考え方などを書いておく。
特にここでは、数十枚の画像がグリッド状に並んでいるような画面でのページネーションを想定しており、ページ番号ボタンなどをクリックすると表示される画像が切り替わるというものである。
なお、一覧表示対象のデータリストをバック側から取得する部分は省略し、すでにフロント側にデータリストがあるところからの実装となる。

完成イメージ

  • 大量の画像データを複数ページに分けて一覧表示する
  • ページ番号ボタンやページ送りボタン(1つ戻る<、1つ進む>、先頭へ戻る<<、最後へ進む>>)で表示ページを操作する
  • 1ページ目を表示中は<, <<を非表示に、最後のページを表示中は>, >>を非表示にする
  • 1度に表示されるページ番号ボタンの個数を制限する

f:id:rozured:20220110011314g:plain

環境と画面構成

環境

  • 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.jpggreen.jpgred.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オブジェクトである。
displayInfo

画像リスト全体のうち何件目〜何件目を現在表示しているかという情報を示すもので、string型の算出プロパティとして定義する。
現在ページの先頭の画像が何番目であるかは(現在のページ番号 - 1) * 1ページあたりの画像表示数 + 1 として求められるので、currentPageimagesPerPageを使って計算する。
現在ページの末尾の画像が何番目であるかは現在のページ番号 * 1ページあたりの画像表示数で求められるが、最終ページの場合はリストの総数を超えてしまう可能性があるのでtotalと比較して小さい方を採用する。

setCurrentPage()

currentPageの値を更新するとともに、その情報をListViewコンポーネントに送る。
ページ番号ボタンやページ送りボタン(<<, <, >, >>)をクリックした時に、次に表示すべきページの番号を引数pageとして受け取り、pagecurrentPageを更新する。
次のページ番号がページ総数の範囲外となってしまってはいけないので、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)">&lt;&lt;</li>
      <li class="arrow" @click.prevent="setCurrentPage(currentPage - 1)">&lt;</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)">&gt;</li>
      <li class="arrow" @click.prevent="setCurrentPage(pages.length)">&gt;&gt;</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個のページ番号ボタンが横にずらっと並ぶ)ので、ページネーション用ボタンの表示制御を入れておいたほうがいい。

isLowerEnd, isUpperEnd

ページ送りボタンの表示制御のためのフラグで、それぞれ算出プロパティとして定義する。
現在表示しているページが1ページ目なのか、もしくは最終ページなのかを判定して結果を返しているだけだ。
このフラグが真の時は対応するページ送りボタンを非表示にする。

displayPages

これまで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を求めることができる。

f:id:rozured:20220109013556p:plain

基本的にはこの計算方法でよいが、currentPageが1ページ目や最終ページに近づいていくとlowerIndexが0未満になったりupperIndexpagesの長さより大きくなってしまう場合があるので調整が必要になる。

(1) lowerIndex < 0 となる場合
currentPageを中心にボタンを表示するのではなく、ページ番号1から右方向にdisplayRange個のボタンを固定して表示する。
lowerIndexは0に修正すればよい。

f:id:rozured:20220109013618p:plain

(2) upperIndex > pages末尾のインデックス となる場合
同様にcurrentPageを中心にボタンを表示するのではなく、最終ページのページ番号から左方向にdisplayRange個のボタンを固定して表示する。
lowerIndexpagesの長さからdisplayRangeを引いた値に修正すればよい。

f:id:rozured:20220109013636p:plain

このようにして求めた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
      }
    })


テンプレートの方を修正する。
ページ番号ボタンのlidisplayPagesでループを回すようにし、ページ送りボタンにはv-if="!isLowerEnd"またはv-if="!isUpperEnd"を追加する。

    <ul class="pagination">
      <li v-if="!isLowerEnd" class="arrow" @click.prevent="setCurrentPage(1)">&lt;&lt;</li>
      <li v-if="!isLowerEnd" class="arrow" @click.prevent="setCurrentPage(currentPage - 1)">&lt;</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)">&gt;</li>
      <li v-if="!isUpperEnd" class="arrow" @click.prevent="setCurrentPage(pages.length)">&gt;&gt;</li>
    </ul>

PaginationをListViewコンポーネントへ配置する

Paginationコンポーネントでのページ番号の変更をListViewコンポーネントで検知し、表示するページを変更できるようにしよう。

まず、ページネーションに関わるプロパティを色々定義しておく。

currentPage 現在表示しているページの番号で初期値は1を指定する。
内部値がnumber型のrefオブジェクトである。
Paginationからページ番号の変更イベントが伝わり更新される。
imagesPerPage 1ページあたりに表示する画像の枚数。
基本的にこれは変更しないのでrefでなくていい。
displayRange ページ番号ボタンの最大表示数。
これも変更しないのでrefでなくていい。
updateCurrentPage()

Paginationコンポーネントでページ番号ボタンなどをクリックすると次に表示すべきページ番号が送られてくるので、そのページ番号でcurrentPageを更新する。

displayImages

currentPageの値に応じてページに表示すべき画像データをdummyImagesから抽出し、displayImagesという算出プロパティに置き換えて利用する。
先頭の画像データのインデックスstartIndex(現在のページ番号 - 1) * 1ページあたりの画像表示数として計算でき、末尾の画像データのインデックスendIndexstartIndexに1ページあたりの画像表示数 を加えれば求められる。
このstartIndexendIndexを引数として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>


テンプレートの方では、PaginationListViewへ追加する。
Paginationで定義していたtotalimagesPerPagedisplayRangeの各プロパティに対して属性値を渡す。
また、PaginationcurrentPageイベント(ページ番号ボタンなどをクリックすると発火)と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ページあたりのデータ表示数を切り替える処理が欲しい時は、imagesPerPagerefオブジェクトにするなどして簡単に作れそうだ。
Vueでページネーションを実現するためのライブラリもあるにはあるが、基本機能だけサッと組み込みたい場合は自分で作ってしまったほうが早そうだ。
全体のコードはこちらに掲載している。