Если у вас большое количество записей (статей, товаров ...) на одной странице, то имеет смысл воспользоваться постраничным разделением этих данных. В этой статье я покажу несколько способов создания пагинации в Yii2.

Документация

Пагинацию будем реализовывать для постов блога на странице категории. Классический пример: есть категория блога, к ней имеют отношения посты (записи, статьи) по полю category_id.

Простая пагинация

Получим все посты одной категории

Контроллер */CategoryController.php:

public function actionView($id)
{
  // Получаем категорию по ID
  if (!$category = Category::findOne($id)) {
    throw new NotFoundHttpException('Категория не найдена!');
  }

  // Выполняем запрос на получение опубликованных постов у категории, которую получили
  $query = Post::find()->where(['category_id' => $category->id])->active();
  // Делаем копию выборки
  $countQuery = clone $query;
  // Подключаем класс Pagination, выводим по столько записей, сколько указано в Post::PAGE_SIZE
  $pagination = new Pagination(['totalCount' => $countQuery->count(), 'pageSize' => Post::PAGE_SIZE]);
  // Приводим параметры в ссылке к ЧПУ (убираем per-page)
  $pagination->pageSizeParam = false;
  $pagination->forcePageParam = false;

  // Можно так:
  /*
  $pagination = new Pagination([
  'totalCount' => $countQuery->count(),
  'pageSize' => Post::PAGE_SIZE,
  'forcePageParam' => false,
  'pageSizeParam' => false
  ]);
*/

$posts = $query->offset($pagination->offset)
->limit($pagination->limit)
 ->all();
  
// Передаем данные в представление
  return $this->render('view', [
    'category' => $category,
    'posts' => $posts,
    'pagination' => $pagination
  ]);
}

Вид */view.php:

<ul>
<? foreach ($posts as $post): ?>
  <li><a href="<?= Url::to(['/blog/post/view', 'slug' => $post->slug]) ?>"><?= $post->name ?></a></li>
<? endforeach; ?>
</ul>
<? echo LinkPager::widget([
  'pagination' => $pagination,
  'registerLinkTags' => true
]); ?>
<p>№ текущей страницы: <?= $pagination->getPage() + 1 ?></p>
<p>Количество страниц: <?= $pagination->getPageCount() ?></p>
<p>Количество постов: <?= $pagination->totalCount ?></p>

Здесь я вывел посты просто в ul для простоты и наглядности.

Параметр registerLinkTags прописывает в хедер ссылки на текущую, предыдущую и следующую страницы. По-умолчанию он выключен в false, чтобы не было конфликтов, если у вас несколько пагинаций на одной странице. Если указать данный параметр в true, то сгенерируются ссылки:

<link href="http://site.com/blog/category/1" rel="canonical">
<link href="/blog/category/1/page/2" rel="self">
<link href="/blog/category/1" rel="first">
<link href="/blog/category/1" rel="prev">
<link href="/blog/category/1/page/3" rel="next">
<link href="/blog/category/1/page/3" rel="last">

Тем самым мы говорим роботу о том, что на странице находится постраничное разбиение данных.

Яндекс вообще не учитывает rel=prev/next. Google до марта 2019 года рекомендовал использовать rel=prev/next. После марта 2019 года и Google отказался от их использования.

Итог: Нет необходимости использовать rel=prev/next в новых проектах. Если данные теги уже установлены на работающем проекте, то можно их не трогать, мешать они никому не будут. Поисковая система Bing до сих пор использует rel=prev/next.

Настроим маршрут в urlManager:

//...
// Правило для пагинации должно быть выше основного маршрута
'blog/category/<id:\d+>/page/<page:\d+>' => 'blog/category/view',
'blog/category/<id:\d+>' => 'blog/category/view',
//...

Лично мне не очень нравятся толстые контроллеры, поэтому я вынесу пагинацию в отдельный метод модели Post. В конце концов, это прямая её (модели) работа - получить данные, как-то их обработать и отдать в контоллер.

Модель */Post.php:

//...
const STATUS_DRAFT = 0;
const STATUS_ACTIVE = 1;
const PAGE_SIZE = 10;

public static function getAllByCategory(Category $category): array
{
  // Выполняем запрос на получение опубликованных постов у категории, ID которой пришёл из контроллера
  $query = self::find()->where(['category_id' => $category->id])->active(); // active() - PostQuery::active()
  // Делаем копию выборки
  $countQuery = clone $query;
  // Подключаем класс Pagination, выводим по столько записей, сколько указано в self::PAGE_SIZE
  $pagination = new Pagination(['totalCount' => $countQuery->count(), 'pageSize' => self::PAGE_SIZE]);
  // Приводим параметры в ссылке к ЧПУ (убираем per-page)
  $pagination->pageSizeParam = false;
  $pagination->forcePageParam = false;
  $posts = $query->offset($pagination->offset)
    ->limit($pagination->limit)
    ->all();

  // Возвращаем массив данных с постами и страницами
  return $data = [
      'posts' => $posts,
      'pagination' => $pagination
  ];
}

Теперь контроллер стал чище и ничего лишнего там нет (и не должно быть)

Контроллер */CategoryController.php:

public function actionView($id)
{
  // Получаем категорию по ID
  if (!$category = Category::findOne($id)) {
    throw new NotFoundHttpException('Категория не найдена!');
  }
  // Получаем список постов этой категории с пагинацией
  $data = Post::getAllByCategory($category);
  $posts = $data['posts'];
  $pagination = $data['pagination'];

  /**
   * Если набрать в адресной строке страницу пагинации, которой не существует
   * http://site.com/blog/category/10/page/1000000
   * показывается последняя страница листинга и в адресе остаётся этот путь
   * 1. Можно оставить как есть
   * 2. Можно отдать ошибку 404
   * 3. Можно установить временный редирект на последнюю страницу листинга
  */
  $page = (int)\Yii::$app->request->getQueryParam('page');
  // Если текущая страница больше чем количество страниц в листинге
  if ($page > $pagination->pageCount) {
    // Ошибка 404
    //throw new NotFoundHttpException('Page is not found!');            
    \Yii::$app->response->redirect(Url::to(['view', 'page' => $pagination->pageCount]), 302);            
  }
  // Передаем данные в представление
  return $this->render('view', [
    'category' => $category,
    'posts' => $posts,
    'pagination' => $pagination
  ]);
}

Пагинация, используя провайдер данных

Реализация постраничной навигации, используя провайдер данных . В данном примере будем использовать ActiveDataProvider.

Перепишем метод getAllByCategory() в нашей модели Post:

public static function getAllByCategory(Category $category): ActiveDataProvider
{
  $query = self::find()->active()->where(['category_id' => $category->id]);
  $countQuery = clone $query;

  return new ActiveDataProvider([
    'query' => $query,
    'totalCount' => (int)$countQuery->count(),
    'pagination' => [
      'pageSize' => self::PAGE_SIZE,
      'pageSizeParam' => false,
      'forcePageParam' => false
    ],
    'sort' => [
      'defaultOrder' => [
        'name' => SORT_ASC,
        'created_at' => SORT_DESC
      ]
    ],
  ]);
}

Контроллер */CategoryController.php:

public function actionView($id)
{
 // Получаем категорию по ID
 if (!$category = Category::findOne($id)) {
  throw new NotFoundHttpException('Категория не найдена!');
 }

 $dataProvider = Post::getAllByCategory($category);

 // Передаем данные в представление
 return $this->render('view', [
  'category' => $category,
  'dataProvider' => $dataProvider
 ]);
}

Вид */view.php:

<?= ListView::widget([
 'dataProvider' => $dataProvider,
 'itemOptions' => ['class' => 'post'],
 'itemView' => '_post',
 'layout' => '{items}{pager}',
 'pager' => ['registerLinkTags' => true],
]) ?>


// Можно так:
<?= $this->render('_list', [
 'dataProvider' => $dataProvider
]) ?>

Файл */_post.php:

<?
/**
 * @var $model \common\models\Post
 */
use yii\helpers\Url;
?>
<a href="<?= Url::to(['/blog/post/view', 'slug' => $model->slug]) ?>"><?= $model->name ?></a>

Пагинация, используя Pjax

В Yii2 есть класс \yii\widgets\Pjax, с помощью которого можно подгружать страницы пагинации асинхронно. Там можно ознакомится со всеми его свойствами и методами.

Модель Post и контроллер CategoryController оставим с предыдущего примера. Нам необходимо внести изменения в файл */view.php, который показывает категорию и посты, относящиеся к этой категории.

use yii\widgets\ListView;
use yii\widgets\Pjax;

<? Pjax::begin([
 // ID блока, на который навешиваем обработчик
 'id' => 'linkPagerCategory',
 'scrollTo' => true,
 // Применяем обработчик Pjax к нашей пагинации (класс генерится сам)
 'linkSelector'=>'.pagination a',
 'timeout' => 3000
]) ?>
<?= ListView::widget([
 'dataProvider' => $dataProvider,
 'itemOptions' => ['class' => 'post'],
 'itemView' => '_post',
 'layout' => '{items}{pager}',
 'pager' => ['registerLinkTags' => true]
]) ?>
<? Pjax::end() ?>

Можно во время подгрузки очередной страницы загружать ещё и прелоадер.

HTML:

<div id="process">
  <img src="/img/loading.gif" alt="Loading" width="100">
</div>

CSS:

#process{
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  bottom: 0;
  background: rgba(255, 255, 255, .95);
  z-index: 3; /* При необходимости выставить свои значения */
  display: none;
}

#process img{
  position: absolute;
  top: 80px;
  right: 30px;
  z-index: 4;
}

jQuery:

$(function () {
var process = $('#process');
$('#linkPagerCategory')
  .on('pjax:start', function() {
    process.fadeIn();
  })
  .on('pjax:end', function() {
    process.fadeOut();
  });
});

Расширение Yii2 Scroll Pager

С помощью данного расширения можно реализовать бесконечную (в зависимости от количество элементов в БД) прокрутку страницы, подгружая данные с помощью AJAX. Репозиторий расширения здесь .

Установка

composer require kop/yii2-scroll-pager "dev-master"

// Или добавить в composer.json, а затем выполнить "composer update"
"kop/yii2-scroll-pager": "dev-master"

Использование

Вид */view.php:

<?php
/* @var $this yii\web\View */
use kop\y2sp\ScrollPager;
/* @var $dataProvider yii\data\DataProviderInterface */
echo \yii\widgets\ListView::widget([
 'dataProvider' => $dataProvider,
 'summary' => false,
 'options' => [
   'tag' => 'div',
   'class' => 'posts'
 ],
 'itemOptions' => [
   'class' => 'post__item',
   'tag' => 'div'
 ],
 'itemView' => '_post',
 'emptyText' => ' ',
 'pager' => [
   'class' => ScrollPager::class,
   'container' => '.posts',
   'item' => '.post__item',
   'triggerTemplate' => '<div class="reload">
     <a href="javascript:void(0);" role="button" title="Показать еще..." class="btn-reload">{text}</a>
    </div>',
   'triggerText' => 'Показать еще...',
   'spinnerTemplate' => '<div class="ias-spinner" style="text-align: center;"><img src="{src}"/></div>',
   'spinnerSrc' => Yii::getAlias('@web/i/preloader_posts.gif'), // Прелоадер 
   'noneLeftText' => 'Записей больше нет</a>'
 ],
]);

По умолчанию, при выводе страницы находятся в ul>li. Но, что если нам нужно, чтобы ссылки на страницы были в блоке div, вместо ul>li. Для этого нужно переопределить класс LinkPager.

<?php
namespace frontend\components;

use yii\helpers\Html;

class CustomPager  extends \yii\widgets\LinkPager
{
  protected function renderPageButton($label, $page, $class, $disabled, $active)
  {
    $options = ['class' => $class === '' ? null : $class];
    if ($active) {
      Html::addCssClass($options, $this->activePageCssClass);
    }
    if ($disabled) {
      Html::addCssClass($options, $this->disabledPageCssClass);
      return  Html::tag('span', $label);
    }
    $linkOptions = $this->linkOptions;
    $linkOptions['data-page'] = $page;

    return Html::a($label, $this->pagination->createUrl($page), $linkOptions);
}

  protected function renderPageButtons()
  {
  	$pageCount = $this->pagination->getPageCount();
    if ($pageCount < 2 && $this->hideOnSinglePage) {
      return '';
    }

    $buttons = [];
    $currentPage = $this->pagination->getPage();

    // first page
    $firstPageLabel = $this->firstPageLabel === true ? '1' : $this->firstPageLabel;
    if ($firstPageLabel !== false) {
      $buttons[] = $this->renderPageButton($firstPageLabel, 0, $this->firstPageCssClass, $currentPage <= 0, false);
    }

    // prev page
    if ($this->prevPageLabel !== false) {
      if (($page = $currentPage - 1) < 0) {
        $page = 0;
      }
      $buttons[] = $this->renderPageButton($this->prevPageLabel, $page, $this->prevPageCssClass, $currentPage <= 0, false);
    }

    // internal pages
    list($beginPage, $endPage) = $this->getPageRange();
    for ($i = $beginPage; $i <= $endPage; ++$i) {
      $buttons[] = $this->renderPageButton($i + 1, $i, null, false, $i == $currentPage);
    }

    // next page
    if ($this->nextPageLabel !== false) {
      if (($page = $currentPage + 1) >= $pageCount - 1) {
        $page = $pageCount - 1;
      }
      $buttons[] = $this->renderPageButton($this->nextPageLabel, $page, $this->nextPageCssClass, $currentPage >= $pageCount - 1, false);
    }

    // last page
    $lastPageLabel = $this->lastPageLabel === true ? $pageCount : $this->lastPageLabel;
    if ($lastPageLabel !== false) {
      $buttons[] = $this->renderPageButton($lastPageLabel, $pageCount - 1, $this->lastPageCssClass, $currentPage >= $pageCount - 1, false);
    }

    return Html::tag('div', implode("\n", $buttons), $this->options);
  }
}

Ещё одно интересное решение для пагинации в Yii2.