Pull to refresh

Непрерывные переходы между общими элементами: из RecyclerView во ViewPager

Reading time 9 min
Views 14K
Original author: Shalom Gibly

Использование переходов в Material Design дает приложению визуальную непрерывность. Пока пользователь ходит по приложению, элементы интерфейса в нем меняют состояние. Анимации переходов соответствующих элементов от одного экрана к другому подчеркивают идею о том, что интерфейсы осязаемы.


Целью этой статьи является предоставление гайдлайнов и реализации для определенных непрерывных переходов между фрагментами ОС Android. Мы продемострируем, как реализовать переход из картинки в RecyclerView в картинку внутри ViewPager и обратно, используя "общие элементы" (shared elements) чтобы определить, как и какие элементы участвуют в переходе. Мы также обработаем сложный случай перехода обратно в сетку после листания на странице к элементу, который в сетке изначально был за пределами экрана.


Вот результат, которого мы хотим добиться (анимированная версия под катом).



Если вы хотите пропустить объяснение и сразу приступить к изучению кода, вы найдете его здесь.


От переводчика. Далее будет довольно много кода и гифок (по прикидкам, мегабайт на 20).



Что такое общие элементы?


Переход с использованием общего элемента определяет как вьюхи, которые присутствуют на двух фрагментах, двигаются между ними. Например, картинка которая показывается в ImageView и в фрагменте A и в фрагменте B, переходит из A в B, когда B становится видимым.


Существует множество ранее опубликованных примеров, которые объясняют, как работают общие элементы, и как реализовать базовый переход между фрагментами. В этой статье основы пропущены, и вместо этого разговор будет идти об особенностях реализации перехода во ViewPager и обратно. Однако, если вы хотите побольше узнать о переходах, я рекомендую начать чтение о переходах на сайте разработчиков Android и уделить время на просмотр этой презентации на Google I/O 2016.


Трудности


Маппинг общих элементов


Мы хотим обеспечить бесшовные переходы туда и обратно. Это как переход от сетки на экран деталей (в оригинале pager, а в переводе далее будет использоваться термин "страница"), так и переход обратно к релевантной картинке в сетке, когда пользователь пролистал страницу к другой картинке.


Чтобы это сделать, нам надо найти способ динамически переназначить маппинг общих элементов, чтобы предоставить системе переходов Android всё необходимое для того, чтобы свершилась магия!


Отложенная загрузка


Переходы общих элементов обладают мощной функциональностью, однако всё еще сложно работать с элементами, которые должны быть загружены до того, как мы можем перейти к ним. Переход может просто не работать как ожидается, если у вьюх в целевом фрагменте еще не выполнен layout и не загружены, например, изображения.


В этом проекте есть две области, в которых время загрузки влияет на переходы между общими элементами:


  1. ViewPager требуется несколько миллисекунд чтобы загрузить его вложенные фрагменты. А еще требуется время чтобы загрузить картинку в отображаемый фрагмент страницы (что к тому же может включать время на загрузку картинки по сети).
  2. RecyclerView также сталкивается с задержкой при загрузке изображений в его вьюхи.

Дизайн демо-приложения


Базовая структура


Перед тем как мы погрузимся в самою мякотку переходов, немного обсудим строение демо-приложения.



MainActivity загружает GridFragment чтобы отобразить RecyclerView, состоящий из картинок. Адаптер RecyclerView загружает список из картинок (неизменяемый массив, определенный в классе ItemData) и управляет событиями onClick заменяя на экране фрагмент GridFragment фрагментом ImagePagerFragment.


Адаптер ImagePagerFragment загружает вложенные ImageFragment-ы чтобы отобразить отдельные картинки в тот момент, когда пользователь листает страницы.


Примечание: Реализация демо-приложения использует библиотеку Glide для асинхронной загрузки картинок во вьюхи. Картинки в демо-приложении поставляются вместе с ним. Однако мы можете легко поправить класс ImageData на хранение URL, указывающих на картинки в интернете.


Синхронизация выбранной и показанной позиций


Чтобы передавать позицию выбранной картинки между фрагментами, мы будем использовать MainActivity как точку синхронизации.


При клике на элемент или при смене страницы в MainActivity передается соответствующий номер элемента.


Сохраненная позиция затем используется в нескольких местах:


  • Для определения, какую страницу отобразить во ViewPager.
  • При возврате к сетке и авто-скроллинге к позиции чтобы убедиться, что выбранный элемент видим.
  • И конечно, для привязки обработчиков переходов, как мы увидим в следующем разделе.

Настройка переходов


Как было сказано ранее, нам надо найти способ динамически изменять маппинг общих элементов чтобы дать системе переходов всё необходимое для колдунства.


Использование статического маппинга с использованием атрибутов transitionName для ImageView в XML-разметке не сработает, так как мы имеем дело с набором вьюшек которые используют тот же layout (например, представления, собранные адаптером RecyclerView или вьюхи, собранные ImageFragment).


Чтобы добиться этого, мы будем использовать методы, предоставляемые системой переходов.


  1. Мы проставим идентификатор перехода для картинок с помощью вызова setTransitionName. Это позволит связать вьюху с уникальным названием перехода. setTransitionName вызывается в момент биндинга вьюхи в адаптере RecyclerView в сетке GridFragment, а также в onCreateView в ImageFragment. В обоих местах мы используем уникальные названия или ссылки на картинки в качестве идентификатора для представления.
  2. Мы настраиваем SharedElementCallbacks чтобы перехватывать onMapSharedElements и поправить маппинг названий общих элементов ко вьюхам. Это будет сделано при выходе из GridFragment и при входе в ImagePagerFragment.

Настройка транзакции FragmentManager


Чтобы инициировать переход для замены фрагментов, мы сначала кое-что поднастроим при подготовке транзакции FragmentManager. Нам надо проинформировать систему о том, что у нас есть переход с общими элементами.


fragment.getFragmentManager()
   .beginTransaction()
   .setReorderingAllowed(true) // setAllowOptimization before 26.1.0
   .addSharedElement(imageView, imageView.getTransitionName())
   .replace(R.id.fragment_container,
        new ImagePagerFragment(),
        ImagePagerFragment.class.getSimpleName())
   .addToBackStack(null)
   .commit();

В коде выше setReorderingAllowed проставляется в true. Это позволяет переупорядочить изменения состояний фрагментов чтобы переход выглядел лучше. Для добавляемого фрагмента onCreate(Bundle) будет вызван до вызова onDestroy у удаляемого фрагмента, что позволит создать и разместить общий элемент интерфейса до начала перехода.


Переход картинки


Чтобы определить, как картинка трансформируется в процессе анимации перехода к новому местоположению, мы настроим TransitionSet в XML-файле и загрузим его в ImagePagerFragment.


<ImagePagerFragment.java>


Transition transition =
   TransitionInflater.from(getContext())
       .inflateTransition(R.transition.image_shared_element_transition);
setSharedElementEnterTransition(transition);

<image_shared_element_transition.xml>


<?xml version="1.0" encoding="utf-8"?>
<transitionSet
   xmlns:android="http://schemas.android.com/apk/res/android"
   android:duration="375"
   android:interpolator="@android:interpolator/fast_out_slow_in"
   android:transitionOrdering="together">
 <changeClipBounds/>
 <changeTransform/>
 <changeBounds/>
</transitionSet>

Изменение соответствия общих элементов


Начнем с изменений при выходе из GridFragment. Для этого мы вызовем setExitSharedElementCallback() и передадим в него SharedElementCallback который поставит в соответствие названия переходов вьюхам, которые мы хотели бы включить в переход.


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


В этом особом случае нам интересен только переход одного ImageView из сетки во фрагмент, который сейчас показывает ViewPager, так что требуется поменять маппинг только для первого именованного элемента, полученного в обработчике onMapSharedElements.


<GridFragment.java>


setExitSharedElementCallback(
   new SharedElementCallback() {
     @Override
     public void onMapSharedElements(
         List<String> names, Map<String, View> sharedElements) {
       // Ищем ViewHolder для выбранной позиции.
       RecyclerView.ViewHolder selectedViewHolder = recyclerView
           .findViewHolderForAdapterPosition(MainActivity.currentPosition);
       if (selectedViewHolder == null || selectedViewHolder.itemView == null) {
         return;
       }

       // Маппим имя первого общего элемента к дочерней ImageView.
       sharedElements
           .put(names.get(0),
                selectedViewHolder.itemView.findViewById(R.id.card_image));
     }
   });

Нам также надо изменить маппинг общих элементов при входе в ImagePagerFragment.
Для этого мы вызовем setEnterSharedElementCallback().


setEnterSharedElementCallback(
   new SharedElementCallback() {
     @Override
     public void onMapSharedElements(
         List<String> names, Map<String, View> sharedElements) {
           // Ищем ImageView для первичного фрагмента (фрагмента ImageFragment,
           // который сейчас виден на экране). Чтобы найти фрагмент, вызываем
           // instantiateItem для выбранной позиции.
           // На этом этапе метод просто вернет фрагмент для указанной позиции
           // и не будет создавать новый.
       Fragment currentFragment = (Fragment) viewPager.getAdapter()
           .instantiateItem(viewPager, MainActivity.currentPosition);
       View view = currentFragment.getView();
       if (view == null) {
         return;
       }

       // Маппим имя первого общего элемента к дочерней ImageView.
       sharedElements.put(names.get(0), view.findViewById(R.id.image));
     }
   });

Откладывание перехода


Изображения, которые мы хотели бы двигать в процессе перехода, требуют некоторого времени на загрузку в сетку и на страницу. Чтобы всё работало правильно, нам надо отложить переход до момента когда участвующие вьюхи будут готовы (например, когда для них выполнен layout и загружены картинки).


Чтобы это сделать, мы вызываем postponeEnterTransition() в методе onCreateView() наших фрагментов, а когда картинка загружена, мы запускаем переход путем вызова startPostponedEnterTransition().


Примечание: "отложить" вызывается и для сетки и для страницы чтобы обеспечить прямые и обратные переходы при навигации по приложению.


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


Это надо сделать в двух местах:


  1. Когда загружается картинка в ImageFragment, вызывается родительский ImagePagerFragment чтобы начать переход.
  2. При возврате к сетке, переход стартует когда выбранное изображение загрузится.

Вот как ImageFragment загружает картинку и уведомляет своего родителя о готовности.


Отметим что вызов postponeEnterTransition сделан в ImagePagerFragment, хотя startPostponedEnterTransition вызывается из дочернего ImageFragment


Glide.with(this)
   .load(arguments.getInt(KEY_IMAGE_RES)) // Загружаем картинку
   .listener(new RequestListener<Drawable>() {
     @Override
     public boolean onLoadFailed(@Nullable GlideException e, Object model,
         Target<Drawable> target, boolean isFirstResource) {
       getParentFragment().startPostponedEnterTransition();
       return false;
     }

     @Override
     public boolean onResourceReady(Drawable resource, Object model,
         Target<Drawable> target, DataSource dataSource, boolean isFirstResource) {
       getParentFragment().startPostponedEnterTransition();
       return false;
     }
   })
   .into((ImageView) view.findViewById(R.id.image));

Как вы могли заметить, мы также вызываем старт отложенного перехода в случае, если загрузка картинки не удалась. Это нужно, чтобы предотвратить зависание UI при сбое загрузки.


Финальные штрихи


Чтобы сделать наши переходы еще более плавными, мы хотели бы затемнять элементы сетки при переходе картинки в экран страницы.


Чтобы этого добиться, мы создадим TransitionSet и используем его в качестве перехода для выхода из GridFragment.


setExitTransition(TransitionInflater.from(getContext())
   .inflateTransition(R.transition.grid_exit_transition));

<?xml version="1.0" encoding="utf-8"?>
<transitionSet xmlns:android="http://schemas.android.com/apk/res/android"
   android:duration="375"
   android:interpolator="@android:interpolator/fast_out_slow_in"
   android:startDelay="25">
 <fade>
   <targets android:targetId="@id/card_view"/>
 </fade>
</transitionSet>

Вот как будет выглядеть переход после настройки анимации выхода:



Как вы могли заметить, переход еще не отполирован полностью. Затемняющая анимация запускается для всех карточек сетки, включая ту, которая содержит картинку, перемещающуюся на страницу.


Чтобы это исправить, мы исключим выбранную карточку из перехода выхода перед коммитом транзакции фрагмента в GridAdapter.


// view - это карточка, на которую произошел клик, инициирующий переход.
((TransitionSet) fragment.getExitTransition()).excludeTarget(view, true);

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



И последний штрих, мы настраиваем GridFragment на прокрутку и показ карточки, на которую мы переходим при обратной навигации со страницы (это сделано в onViewCreated):


recyclerView.addOnLayoutChangeListener(
   new OnLayoutChangeListener() {
      @Override
      public void onLayoutChange(View view,
                int left,
                int top,
                int right,
                int bottom,
                int oldLeft,
                int oldTop,
                int oldRight,
                int oldBottom) {
         recyclerView.removeOnLayoutChangeListener(this);
         final RecyclerView.LayoutManager layoutManager =
            recyclerView.getLayoutManager();
         View viewAtPosition =
            layoutManager.findViewByPosition(MainActivity.currentPosition);
         // Прокрутка к позиции, если вьюха для текущей позиции не null (т.е.
         // не является частью дочерних элементов layout-менеджера) или если
         // она видна не полностью.
         if (viewAtPosition == null
             || layoutManager.isViewPartiallyVisible(viewAtPosition, false, true)){
            recyclerView.post(()
               -> layoutManager.scrollToPosition(MainActivity.currentPosition));
         }
     }
});

Подводя итоги


В этой статье мы реализовали плавный переход из RecyclerView во ViewPager и обратно.


Мы показали как отложить переход и начать его после готовности всех вьюх. Мы также реализовали ремаппинг общих элементов для того, чтобы обеспечить анимацию перехода когда общие вьюхи меняются динамически при навигации по приложению.


Эти изменения переходов между фрагментами нашего приложения сделали их визуально более непрерывными для пользователя.



Код демо-приложения прилагается.

Tags:
Hubs:
+24
Comments 0
Comments Leave a comment

Articles