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



Где ссылка на гитхаб?