ViewPager 2 — новая функциональность в старой обертке

    ViewPager — один из самых известных и широко используемых компонентов Android Support Library. Все простейшие карусели, онбординги и слайдеры выполнены именно на нем. В феврале 2019 года команда разработки AndroidX выпустила ViewPager2. Давайте разберемся, какие у этого были предпосылки и какими преимуществами обладает обновленная версия компонента.



    ViewPager 2


    На момент написания поста (июль 2019) доступна бета-версия ViewPager2, а это значит, что нижеупомянутые проблемы могут быть исправлены, а функциональность доработана и расширена. Разработчики обещают в будущем добавить поддержку TabLayout (пока он умеет работать только с первой версией), оптимизировать производительность адаптера, внести множество мелких исправлений и доработать документацию.

    Интеграция


    Компонент не поставляется со стандартными пакетами, а подключается отдельно. Для этого необходимо добавить в блок dependencies в gradle-скрипте вашего модуля следующую строку:

    implementation "androidx.viewpager2:viewpager2:1.0.0-beta02"
    

    Реализация


    Начнём с хороших новостей: переход с первой на вторую версию максимально прост и сводится к смене импортов. Старый добрый синтаксис не тронули: текущую страницу возвращает метод getCurrentItem(), подписаться на изменение состояния пейджера позволяет ViewPager2.onPageChangeCallback, адаптер по-прежнему устанавливается через setAdapter().



    Стоит копнуть глубже, как становится понятно, что у первого и второго пейджеров нет ничего общего кроме интерфейсов. Знакомство с реализацией метода setAdapter() не оставляет почвы для сомнений:

    public final void setAdapter(@Nullable Adapter adapter) {
       mRecyclerView.setAdapter(adapter);
    }
    

    Да, ViewPager2 является всего лишь обёрткой над RecyclerView. С одной стороны, это большой плюс, с другой же — добавляет головной боли. Замаскировать RecyclerView под «листалку» стало возможно с появлением PagerSnapHelper. Этот класс меняет физику скролла. Когда пользователь отпускает палец, PagerSnapHelper рассчитывает, какой элемент списка находится ближе всего к осевой линии списка, и с плавной анимацией выравнивает его точно по центру. Таким образом, если свайп был достаточно резким, список пролистывается к следующему элементу, в противном случае — с анимацией возвращается в исходное состояние.

    new PagerSnapHelper().attachToRecyclerView(mRecyclerView);
    

    image
    При использовании PagerSnapHelper убедитесь, что ширина и высота самого RecyclerView, а также всех его ViewHolder’ов задана равной MATCH_PARENT. В противном случае поведение SnapHelper будет непредсказуемым, могут возникнуть баги в совершенно неожиданных местах. Всё это делает создание карусели из элементов небольшой высоты довольно трудоёмким, хотя и возможным.

    Учитывая все вышесказанное, в верстке виджет будет выглядеть следующим образом:

    <androidx.viewpager2.widget.ViewPager2
            android:id="@+id/main_pager"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    

    В одном пакете с ViewPager2 мы также можем найти класс ScrollEventAdapter, помогающий сохранить преемственность синтаксиса. ScrollEventAdapter реализует RecyclerView.OnScrollListener и трансформирует события скролла в события OnPageChangeCallback.

    @Override
    public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
        if (mAdapterState != STATE_IN_PROGRESS_MANUAL_DRAG && 
                newState == RecyclerView.SCROLL_STATE_DRAGGING) {
            ...
            dispatchStateChanged(SCROLL_STATE_DRAGGING);
            return;
        }
        ...
    }
    

    Теперь OnPageChangeCallback представлен не интерфейсом, а абстрактным классом, что позволяет переопределять только необходимые методы (в большинстве случаев вам понадобится только onPageSelected(Int), срабатывающий при выборе определенной страницы):

    main_pager.registerOnPageChangeCallback(
        object : ViewPager2.OnPageChangeCallback() {
            override fun onPageSelected(position: Int) {
                //do your stuff
            }
        }
    )
    

    Особенности


    Достоин упоминания и метод setPageTransformer(), в качестве параметра принимающий ViewPager2.PageTransformer. Он устанавливает callback на каждое событие выбора страницы и служит для задания собственной анимации этой страницы. Callback получает на вход View текущей страницы и её номер. Ближайшим аналогом этого метода является ItemAnimator из RecyclerView.

    В новых версиях библиотеки были добавлены две реализации трансформера:

    CompositePageTransformer и MarginPageTransformer. Первый отвечает за то, чтобы комбинировать трансформеры, чтобы применять сразу несколько трансформаций к одному пейджеру, а второй — для проставления отступов между страницами:

    image

    Кроме того, новый виджет поддерживает смену ориентации: простым вызовом метода setOrientation() вы можете превратить свой пейджер в вертикальный список со свайпам сверху вниз:

    main_pager.setOrientation(ViewPager2.ORIENTATION_VERTICAL)
    

    Происходит это вновь благодаря переходу на RecyclerView: под капотом вызывается смена ориентации LayoutManager, который отвечает за отображение элементов списка. Хочется отметить, что делегирование большого количества задач другим классам пошло новому компоненту на пользу: его листинг стал гораздо более компактным и читаемым.

    На этом интересное не заканчивается. В одном из обновлений ViewPager2 получил поддержку ItemDecoration: класса-помощника для декорирования дочерних View. Этот механизм может быть использован для рисования разделителей между элементами, границ, подсветки ячеек.

    Готовых реализаций декораторов уже очень много, ведь они уже много лет успешно применяются при работе с обычным RecyclerView. Все наработки теперь применимы и к пейджерам. «Из коробки» доступна стандартная реализация разделителей элементов пейджера:

    main_pager.addItemDecoration(
            DividerItemDecoration(this, RecyclerView.HORIZONTAL)
    )
    

    Вместе с очередным апдейтом в мае 2019 года ViewPager2 был добавлен еще один важный метод: setOffscreenPageLimit(Int). Он отвечает за то, сколько элементов справа и слева от центрального будет инициализироваться в пейджере. Хотя по-умолчанию за кэширование и показ View отвечает RecyclerView, с помощью этого метода вы можете явно задать желаемое количество подгружаемых элементов.

    Адаптер


    Идейным наследником адаптера первого пейджера является FragmentStateAdapter: интерфейсы взаимодействия и нейминг классов почти не различаются. Изменения коснулись разве что именования некоторых методов. Если раньше нужно было реализовать абстрактную функцию getItem(position), чтобы вернуть нужный экземпляр Fragment для заданной позиции, и это именование могло трактоваться двояко, то сейчас эта функция переименована в createFragment(position). Общее количество фрагментов как и раньше поставляется функцией getCount().

    Из важных структурных изменений интерфейса следует также отметить, что адаптер теперь имеет возможность управлять жизненным циклом своих элементов, поэтому вместе с FragmentManager’ом в конструкторе он принимает Lifecycle-объект, либо Activity или Fragment. Из-за этого, для безопасности методы saveState() и restoreState() были объявлены финальными, и закрыты для наследования.
    За хранение фрагментов внутри RecyclerView отвечает класс FragmentViewHolder. Метод onCreateViewHolder() из FragmentStateAdapter вызывает FragmentViewHolder.create().

    static FragmentViewHolder create(ViewGroup parent) {
        FrameLayout container = new FrameLayout(parent.getContext());
        container.setLayoutParams(
                new ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT
                )
        );
        container.setId(ViewCompat.generateViewId());
        container.setSaveEnabled(false);
        return new FragmentViewHolder(container);
    }
    

    При вызове метода onBindViewHolder(), происходит связывание идентификатора элемента на текущей позиции и идентификатора ViewHolder, для дальнейшего присоединения к нему фрагмента:

    final long itemId = holder.getItemId();
    final int viewHolderId = holder.getContainer().getId();
    final Long boundItemId = itemForViewHolder(viewHolderId);
    ...
    mItemIdToViewHolder.put(itemId, viewHolderId);
    ensureFragment(position); //создание экземпляра фрагмента
    

    И, наконец, при присоединении контейнера из ViewHolder к иерархии View, выполняется FragmentTransaction, добавляющий Fragment в контейнер:

    void placeFragmentInViewHolder(@NonNull final FragmentViewHolder holder) {
        Fragment fragment = mFragments.get(holder.getItemId());
        ...
        scheduleViewAttach(fragment, container);
        mFragmentManager.beginTransaction()
                        .add(fragment, "f" + holder.getItemId())
                        .setMaxLifecycle(fragment, STARTED)
                        .commitNow();
        ...
    }
    

    Таким образом, вырисовывается два варианта использования ViewPager2: через наследование класса-адаптера либо напрямую от RecyclerView.Adapter, либо от FragmentStateAdapter.



    Наверняка у вас возникнет вопрос: зачем же использовать второй пейджер с Fragment’ами и адаптером для них, когда есть нормально функционирующая первая версия? ViewPager — далеко не «серебряная пуля» при работе с большими динамическими списками данных. Он отлично подходит для создания каруселей со статичным набором картинок или баннеров, но вот пагинируемые новостные ленты с подгрузкой рекламных постов, фильтрацией рождают трудно-поддерживаемых и уродливых монстров. Рано или поздно вы обязательно столкнётесь со жгучим желанием переписать всё на RecyclerView. Теперь вам не придётся этого делать, потому что пейджер сам превратился в него, позаимствовав его мощные возможности по работе с динамическими списками, обернув их при этом в привычный синтаксис.

    Единственное, что нам может предложить PagerAdapter — метод notifyDataSetChanged(), принуждающий ViewPager перерисовывать все отрисованные элементы списка. Вы можете резонно заметить, что никто не мешает нам хранить список позиций для существующих элементов и возвращать для них POSITION_UNCHANGED из метода getItemPosition(), всё так. Однако это решение нельзя назвать красивым, оно довольно громоздкое, к тому же, трудно расширяемое на те случаи, когда элементы в списке постоянно изменяются, а не только последовательно добавляются в конец. FragmentStateAdapter же обладает полным арсеналом методов RecyclerView.Adapter, поэтому логику перерисовки элементов можно настроить гораздо более гибко. Более того, вкупе с FragmentStateAdapter можно использовать DiffUtil, который позволяет почти полностью автоматизировать работу по уведомлению об изменениях.


    Внимание! Для корректной работы методов notify… (кроме notifyDataSetChanged) следует переопределить методы getItemId(Int) и containsItem(Long). Делается это потому, что реализация по-умолчанию смотрит только на номер страницы, и если вы, например, добавите новый элемент после текущего, он не будет добавлен, так как getItemId останется неизменным. Пример переопределения этих двух методов на основе списка элементов типа Int:

    override fun getItemId(position: Int): Long {
        return items[position].toLong()
    }
    
    override fun containsItem(itemId: Long): Boolean {
        return items.contains(itemId.toInt())
    }
    



    Основной причиной появления ViewPager2 является нежелание изобретать велосипед. С одной стороны, команда разработки AndroidX явно готова отказаться от морально устаревшего ViewPager уже сейчас и уж точно не собирается вкладываться в расширение его функциональности. Да и зачем? Ведь RecyclerView уже и так умеет всё, что нужно. С другой стороны, удаление и прекращение поддержки настолько широко используемого компонента явно не добавит лояльности со стороны сообщества.

    Подведём итоги: ViewPager2 определённо достоин внимания, хотя на данный момент и не лишён серьёзных недостатков.

    Минусы


    • Сырость и большое количество багов (простительно для бета-версии);
    • Закрытость. RecyclerView является private-полем ViewPager2, что лишает нас многих возможностей: невозможно реализовать swipe-to-dismiss или drag-n-drop (ItemTouchHelper подключается напрямую к RecyclerView), никак не переопределить ItemAnimator, не обратиться напрямую к LayoutManager и задействовать RecycledViewPool. Однако, с выходом новых версий компонента, количество унаследованных от RecyclerView методов интерфейса растет (например, ItemDecoration), и мы можем надеяться на добавление недостающих методов в будущем.

    Плюсы


    • Поддержка всех преимуществ RecyclerView.Adapter: комбинирование элементов разного типа в одном списке, добавление и удаление элементов прямо во время свайпа, анимированная перерисовка содержимого списка при изменении;
    • Поддержка всего спектра notify… методов и автоматического вычисления изменений с помощью DiffUtil;
    • Простота перехода за счёт преемственности синтаксиса;
    • Поддержка вертикальной и горизонтальной ориентации «из коробки»;
    • Поддержка RTL;
    • Поддержка ItemDecorator;
    • Поддержка программного скролла посредством fakeScrollBy();
    • Возможность вручную выставить количество подгружаемых элементов;
    • Возможность использования любого из готовых open-source решений для сокращения boilerplate-кода, неизбежного, при написании кастомного RecyclerView.Adapter. Например, EasyAdapter.

    В качестве резюме, хочется сказать, что к ViewPager2 действительно стоит присмотреться. Это многообещающее, расширяемое и функциональное решение. И хотя пускать новый виджет в продакшн ещё рано, можно с уверенностью сказать, что после полноценного релиза он может и должен полностью вытеснить своего прародителя.

    Для тех смелых и решительных, кого статья вдохновила на эксперименты, в 28-ой версии Support Library появился PagerSnapHelper, а это значит, что вы можете использовать его вместе с вашим RecyclerView, воссоздав ViewPager2 своими руками.

    Семпл работы ViewPager2 и FragmentStateAdapter.

    Официальные release-notes ViewPager2
    Surf
    126,50
    Компания
    Поделиться публикацией

    Похожие публикации

    Комментарии 6

      +1
      О, спасибо, а то слышал про нововведения мельком, а руки не доходили посмотреть что к чему.
        0
        Плюсы

        — Поддержка программного скролла посредством fakeScrollBy();
        — Возможность вручную выставить количество подгружаемых элементов;

        Первый пункт был и в ViewPager, он реализуется методами fakeDragBy() и endFakeDrag(). Насчет второго пункта, если вы про setOffscreenPageLimit(), то это, разумеется, тоже присутствовало в первой версии.
          +1
          Оба пункта скорее относятся не к превосходству компонента над предшественником, а об удобности его использования в принципе.
          setOffscreenPageLimit в ViewPager действительно был, но его не было у RecyclerView. Точнее, похожий эффект может быть достигнут с помощью довольно сложных манипуляций с LayoutManager'ом, что и сделали разработчики VP2.
            0
            Понял. Просто после списка плюсы/минусы, вы делаете следующий вывод:
            И хотя пускать новый виджет в продакшн ещё рано, можно с уверенностью сказать, что после полноценного релиза он может и должен полностью вытеснить своего прародителя.

            Что какбы наталкивает на мысль, что речь шла о плюсах в сравнении с «прародителем», который на самом деле даже не близкий родственник.
          0
          Полагаю, бесконечную циклическую прокрутку фиксированного числа (предзагруженных) страниц здесь тоже без костылей не реализовать?
            +1
            Решения «из коробки» нет, но так как под капотом RecyclerView, и в качестве адаптера у пейджера можно использовать RecyclerView.Adapter, можно ворох уже готовых инструментов для упрощения работы с ним. Например, уже упомянутый в статье EasyAdapter имеет эту функцию, и включается она простым вызовом метода:
            adapter.setInfiniteScroll(true)

            Пример работы со списком с RecyclerView.Adapter и динамическим списком с эмуляцией пагинации можете найти в этой ветке семпла.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое