Переходы в приложениях 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, перейдя по этой ссылке.
Где ссылка на гитхаб?