Анимация переходов между RecyclerView и ViewPager

Автор: | 01.08.2018

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

В этой статье мы продемонстрируем, как реализовать переход из изображения в RecyclerView на одной активности в изображение в ViewPager в другой активности, используя Shared Elements для того, чтобы определить, какие представления участвуют в процессе перехода и как. Мы также рассмотрим сложный случай обратного перехода в список к элементу, которого ранее не было на экране.

Вот результат, которого мы собираемся достичь:

 

 

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

Переход общего элемента определяет, как происходит анимация элемента, который присутствует в двух активностях, между ними. Например, изображение, которое отображается в ImageView на активностях А и Б, переходит от активности А к активности Б когда Б становится видимой.

Реализация перехода от RecyclerView к ViewPager

Ниже приведена анимация перехода общего элемента из RecyclerView в ViewPager.

Для начала откроем файл res/values-v21/styles.xml и в свойствах темы зададим windowsContentTransitions а анимацию.

<item name="android:windowContentTransitions">true</item>
<item name="android:windowSharedElementEnterTransition">@transition/change_image_transform</item>
<item name="android:windowSharedElementExitTransition">@transition/change_image_transform</item>

Теперь определим анимацию, создав файл change_image_transform.xml в папке res/transition.

<?xml version="1.0" encoding="utf-8"?>
<transitionSet xmlns:android="http://schemas.android.com/apk/res/android">
    <changeBounds/>
    <changeImageTransform/>
</transitionSet>

RecyclerView и ViewPager используют для своей работы адаптер, поэтому они не могут установить transitionName в разметке XML. Нужно использовать View.setTransitionName() и View.setTag(), чтобы динамически установить transitionName и tag соответственно во время привязки представления к адаптеру. Поэтому в метода адаптера onBindViewHolder() добавим следующий код:

@Override public void onBindViewHolder(@NonNull final MyViewHolder holder, int position) {
  holder.image.setTag(position);
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    holder.image.setTransitionName(
        holder.image.getContext().getString(R.string.transition_name, position)); // в качестве примера ресурсу R.string.transition_name задано значение name%1$d
  }
}

Здесь поле image — это ImageView, анимацию перехода которого мы собираемся реализовать. В результате каждому элементу будет задан transitionName, равный nameX, где X — позиция элемента в списке.

Примечание: в нашем примере будут использоваться следующие имена активностей: SourceActivity для активности с RecycleView и DestinationActivity для активности с ViewPager.

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

  • Во-первых, каждое представление должно иметь уникальный transitionName.
  • Во-вторых, нужно подождать, пока ViewPager начнёт показывать изображения, прежде чем включать анимацию.

Теперь нужно реализовать обработку нажатия на элемент. При нажатии будет открываться вторая активность, в которую будут передавать созданные имя и тег. Для этого пробросим в адаптер слушатель из активности.

interface ClickListener {
  void onItemClick(ImageView image);
}

private ClickListener listener;
...

RecyclerAdapter(List photoList, ClickListener listener) {
  this.photoList = new ArrayList<>(photoList);
  this.listener = listener;
}

...

@Override public void onBindViewHolder(@NonNull final MyViewHolder holder, int position) {
    final int photo = photoList.get(position);
    holder.image.setImageResource(photo);
    holder.image.setTag(position);
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
      holder.image.setTransitionName(
          holder.image.getContext().getString(R.string.transition_name, position));
    }
    holder.image.setOnClickListener(new View.OnClickListener() {
      @Override public void onClick(View view) {
        listener.onItemClick(holder.image);
      }
    });
  }

В активности же создадим экземпляр этого слушателя и переопределим метод onItemClick().

@Override protected void onCreate(Bundle savedInstanceState) {
  ...

  recyclerView.setAdapter(new RecyclerAdapter(photoList, new RecyclerAdapter.ClickListener() {
    @Override public void onItemClick(ImageView image) {
      Intent intent = new Intent(SourceActivity.this, DestinationActivity.class);
      intent.putExtra("current", (int) image.getTag());
      intent.putIntegerArrayListExtra("list", photoList);
      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        ActivityOptions options =
            ActivityOptions.makeSceneTransitionAnimation(SourceActivity.this, image,
                image.getTransitionName());
        startActivity(intent, options.toBundle());
      } else {
        startActivity(intent);
      }
    }
  }));
}

Здесь основным моментом является использование объекта ActivityOptions, в который с помощью метода makeSceneTransitionAnimation() передаются контекст, представление, которое нужно анимировать, и его transitionName.

После этого стартует интент и мы переходим на активность с ViewPager. Как уже говорилось выше, нельзя сразу при старте активности запускать анимацию, поскольку ViewPager нужно время, чтобы сгенерировать элементы. Поэтому перед вызовом setContentView() в методе onCreate() добавим вызов метода postponeEnterTransition(), который запрещает переход общего элемента.

@Override protected void onCreate(Bundle savedInstanceState) {
  super.onCreate(savedInstanceState);

  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    postponeEnterTransition();
  }

  setContentView(R.layout.activity_destination);

  ...
}

После этого начинается инициализация ViewPager и передача данных в адаптер. В адаптере нам нужно задать те же transitionName и tag, что был у элемента в RecyclerView, после чего вызвать ме

@Override @NonNull public Object instantiateItem(@NonNull ViewGroup collection, int position) {
  int photo = photoList.get(position);
  LayoutInflater inflater = LayoutInflater.from(mContext);
  View v = inflater.inflate(R.layout.item, collection, false);
  ImageView img = v.findViewById(R.id.image);
  img.setImageResource(photo);

  collection.addView(v);

  String name = mContext.getString(R.string.transition_name, position);
  img.setTag(position);

  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    img.setTransitionName(name);
    if (position == current) {
      listener.setStartPostTransition(img);
    }
  }

  return v;
}

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

@TargetApi(21) @Override public void setStartPostTransition(final View view) {
  view.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
    @Override public boolean onPreDraw() {
      view.getViewTreeObserver().removeOnPreDrawListener(this);
      startPostponedEnterTransition();
      return false;
    }
  });
}

В результате мы получим плавную анимацию перехода из RecyclerView в ViewPager.

Реализация перехода от ViewPager к RecyclerView

Теперь нужно осуществить обратную анимацию при возврате из DestinationActivity в SourceActivity. Основной проблемой здесь может являться то, что при возврате в первую активность не обязательно вернётся тот же элемент, что был при переходе. В таком случае нужно определить позицию нового элемента, сделать скролл, если необходимо, и воспроизвести анимацию.

В DestinationActivity нам нужно переопределить метод finishAfterTransition(), внутри которого нужно поместить в интент текущую позицию элемента.

@Override public void finishAfterTransition() {
  int pos = viewPager.getCurrentItem();
  Intent intent = new Intent();
  intent.putExtra("exit_position", pos);
  setResult(RESULT_OK, intent);
  if (current != pos) {
    View view = viewPager.findViewWithTag(pos);
    setSharedElementCallback(view);
  }
  super.finishAfterTransition();
}

@TargetApi(21) private void setSharedElementCallback(final View view) {
  setEnterSharedElementCallback(new SharedElementCallback() {
    @Override
    public void onMapSharedElements(List<String> names, Map<String, View> sharedElements) {
      names.clear();
      sharedElements.clear();
      names.add(view.getTransitionName());
      sharedElements.put(view.getTransitionName(), view);
    }
  });
}

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

После этого активность закрывается и мы возвращаемся в SourceActivity. Здесь мы вызываем метод активности onActivityReenter(), чтобы поймать интент с позицией.

public void onActivityReenter(int resultCode, Intent data) {
  super.onActivityReenter(resultCode, data);
  if (resultCode == RESULT_OK && data != null) {
    exitPosition = data.getIntExtra("exit_position", 0);
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
      final RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
      View viewAtPosition = layoutManager.findViewByPosition(exitPosition);
      // Прокрутка к позиции, если вьюха для текущей позиции не null (т.е.
      // не является частью дочерних элементов layout-менеджера) или если
      // она видна не полностью.
      if (viewAtPosition == null || layoutManager.isViewPartiallyVisible(viewAtPosition, false,
          true)) {
        layoutManager.scrollToPosition(exitPosition);
        setTransitionOnView();
      }
      // карточка видна, нужно поставить колбек
      else {
        setTransitionOnView();
      }
    }
  }
}

Если прокручивать не нужно или прокрутка завершена, начинаем воспроизведение анимации. Для этого вызывается метод setTransitionOnView(), в котором ожидаем окончания скролла и запускаем анимацию.

@TargetApi(Build.VERSION_CODES.LOLLIPOP) private static class CustomSharedElementCallback
    extends SharedElementCallback {
  private View mView;

  public void setView(View view) {
    mView = view;
  }

  @Override
  public void onMapSharedElements(List<String> names, Map<String, View> sharedElements) {
    names.clear();
    sharedElements.clear();

    if (mView != null) {
      String transitionName = ViewCompat.getTransitionName(mView);
      names.add(transitionName);
      sharedElements.put(transitionName, mView);
    }
  }
}

@TargetApi(Build.VERSION_CODES.LOLLIPOP) private void setTransitionOnView() {
  final CustomSharedElementCallback callback = new CustomSharedElementCallback();
  setExitSharedElementCallback(callback);

  getWindow().getSharedElementExitTransition().addListener(new Transition.TransitionListener() {
    @Override public void onTransitionStart(Transition transition) {
    }

    @Override public void onTransitionPause(Transition transition) {
    }

    @Override public void onTransitionResume(Transition transition) {
    }

    @Override public void onTransitionEnd(Transition transition) {
      removeCallback();
    }

    @Override public void onTransitionCancel(Transition transition) {
      removeCallback();
    }

    private void removeCallback() {
      getWindow().getSharedElementExitTransition().removeListener(this);
      setExitSharedElementCallback(null);
    }
  });

  postponeEnterTransition();

  recyclerView.getViewTreeObserver()
      .addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
        @Override public boolean onPreDraw() {
          recyclerView.getViewTreeObserver().removeOnPreDrawListener(this);
          RecyclerView.ViewHolder holder =
              recyclerView.findViewHolderForAdapterPosition(exitPosition);
          if (holder instanceof RecyclerAdapter.MyViewHolder) {
            callback.setView(((RecyclerAdapter.MyViewHolder) holder).image);
          }
          return true;
        }
      });
}

На этом всё. Теперь анимация обратного перехода будет работать даже в том случае, если элемент находился до этого вне поля видимости.

Исходный код примера можно посмотреть на GitHub, перейдя по этой ссылке.

Читайте также

Анимация переходов между RecyclerView и ViewPager: 1 комментарий

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *