この記事は Google マテリアル ギャラリー チーム ソフトウェア エンジニア、Shalom Gibly による Android Developers Blog の記事 "Continuous Shared Element Transitions: RecyclerView to ViewPager" を元に翻訳・加筆したものです。詳しくは元記事をご覧ください。
マテリアル デザインを利用しているアプリでは、視覚的に途切れない画面遷移が行われます。ユーザーがアプリを操作すると、アプリ内のビューの状態が変わります。その際に、あるビューと次のビューで共通する要素を連動させ、動きや変化を出すことにより、実際にインターフェースに手を触れている感覚を提供できます。
本投稿では、Android の Fragment 間で途切れない画面遷移を行う際のガイドラインや実装について説明します。また、RecyclerView のイメージから ViewPager のイメージを開き、再び RecyclerView に戻る画面遷移を実装する方法を紹介します。その際に、「共通要素」を使ってどのビューがどのように移動するかを決めます。ページを切り替えてからグリッドに戻る際に、最初に開いたときには画面外にあったアイテムに戻るという厄介な画面遷移にも対応します。
目指した動作は、次のとおりです。
![]()
説明をスキップして直接コードを見たいという方は、
こちらをご覧ください。
共通要素とは
共通要素遷移を使うと、2 つのフラグメントに存在するビューが両者の間でどのように移動するかを決めることができます。たとえば、Fragment
A
と Fragment
B
の両方の
ImageView
に表示されているイメージは、
B
が表示される際に
A
から
B
に移動します。
共通要素の動作や基本的な Fragment の遷移の実装方法を説明したサンプルは、今までにもたくさん公開されています。本投稿では、基本はほぼ省略して、ViewPager を開く際とそこから戻る際の画面遷移を作成する具体的な方法について、順を追って示します。なお、画面遷移についてさらに詳しく知りたいという方は、まず
Android デベロッパー サイトで画面遷移について学習することをおすすめします。また、こちらの 2016 Google I/O の
プレゼンテーションもご覧ください。
問題点
共通要素のマッピング
画面を開く際と戻る際の遷移は、シームレスに行いたいものです。これには、グリッドからページャーへの遷移と、ページャーから関連するイメージに戻る際の遷移があります。ユーザーがページを切り替えると、開いたときとは違うイメージに戻ります。
これを実現するためには、共通要素の
動的再マッピングを行って、Android の画面遷移システムがこの魔法のような処理を行うために必要な情報を提供しなければなりません。
遅延読み込み
共通要素遷移は強力ですが、次の画面で読み込む必要がある要素を遷移前に扱わなければならない場合、複雑になることがあります。遷移先のフラグメントのビューが配置される前でまだ準備が整っていない場合、期待通りの画面遷移にならないかもしれません。
今回のプロジェクトでは、次の 2 つの点で読み込み時間が共通要素遷移に影響します。
ViewPager
が内部フラグメントを読み込むために、数ミリ秒が必要です。さらに、表示されるページャー フラグメントにイメージを読み込む時間も必要です(場合によっては、アセットをダウンロードする時間も必要になります)。 RecyclerView
でも、イメージをビューに読み込むために同じように時間がかかります。
デモアプリの設計
基本的な構造
実際の画面遷移に取りかかる前に、少しばかりデモアプリの構造を見てみましょう。
![]()
MainActivity は、イメージの
RecyclerView
を表示するために、
GridFragment
を読み込みます。
RecyclerView
アダプタは、イメージ アイテム(
ImageData
クラスで定義している配列定数)を読み込むとともに、表示されている
GridFragment
を
ImagePagerFragment
で置き換える
onClick
イベントを管理しています。
ImagePagerFragment
アダプタは、ネストされている
ImageFragments
を読み込み、ページの移動に合わせて個々のイメージを表示します。
注:デモアプリの実装には、非同期的にイメージをビューに読み込む
Glideを使っています。使っているイメージは、デモアプリにバンドルされていますが、オンライン イメージを指す URL 文字列を格納するように、
ImageData
クラスを簡単に変更することもできます。
選択位置 / 表示位置の連携
選択されたイメージの位置をフラグメント間で受け渡しするには、その位置を格納する場所が必要です。その場所として、
MainActivity
を利用します。
アイテムがクリックされたり、ページが変更されると、関連するアイテムの位置を使って MainActivity を更新します。
保存された位置は、後ほどいくつかの場所で利用します。
ViewPager
に表示するページを決めるとき - グリッドに戻るとき、戻り先のアイテムが確実に見えるように自動スクロールするとき
- そしてもちろん、画面遷移のコールバックをフックするとき(次のセクションで詳しく説明します)
画面遷移の設定
前述のように、共通要素の
動的再マッピングを行って、画面遷移システムがこの魔法のような処理を行うために必要な情報を提供しなければなりません。
XML でイメージビューの
transitionName
属性を設定する静的マッピングではうまくいきません。同じレイアウトを共有するビュー(例:
RecyclerView
アダプタによってインフレートされるビューや、
ImageFragment
によってインフレートされるビュー)の数は可変であり、それに対応する必要があるからです。
これを実現するために、画面遷移システムが提供するいくつかの機能を使います。
setTransitionName
を呼び出してイメージビューの遷移名を設定します。これによって、一意な遷移名でビューを識別できるようになります。setTransitionName
は、グリッドの RecyclerView
アダプタでビューをバインドするタイミングと、ImageFragment
の onCreateView
で呼び出されます。両方とも、ビューを識別する名前として、一意なイメージ リソースを使います。 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
に設定します。これにより、フラグメントの状態変化の順番が変更され、共通要素遷移が改善されます。置き換わるフラグメントの
onDestroy()
が呼ばれる前に、追加するフラグメントの
onCreate(Bundle)
が呼ばれるので、画面遷移が始まる前に共通のビューを作成して配置できるようになります。
イメージの移動
イメージが新しい場所に移動する際のアニメーションを定義するために、XML ファイルで
TransitionSet
を設定し、それを
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
を離れる際に共通要素のマッピングを調整します。そのためには、
SharedElementCallback
を指定して
setExitSharedElementCallback()
を呼び出し、遷移に含めたいビューに要素名をマッピングします。
このコールバックは、フラグメントのトランザクションが発生して
Fragment
が
終了するときと、
Fragment
がバックスタックからポップされて(「戻る」のナビゲーション)
再度開始されるときに呼び出されることに注意してください。この動作を利用すれば、共通のビューを再マッピングして画面遷移を調整することで、イメージのページを移動してビューが変更された場合にも対処できます。
今回の例では、関係があるのは、グリッドからビューページャーが保持しているフラグメントに移動する 1 つの
ImageView
だけです。そのため、マッピングの調整は、
onMapSharedElements
コールバックで受け取った
最初の名前付き要素に対してのみ行います。
<GridFragment.java>
setExitSharedElementCallback(
new SharedElementCallback() {
@Override
public void onMapSharedElements(
List<String> names, Map<String, View> sharedElements) {
// Locate the ViewHolder for the clicked position.
RecyclerView.ViewHolder selectedViewHolder = recyclerView
.findViewHolderForAdapterPosition(MainActivity.currentPosition);
if (selectedViewHolder == null || selectedViewHolder.itemView == null) {
return;
}
// Map the first shared element name to the child ImageView.
sharedElements
.put(names.get(0),
selectedViewHolder.itemView.findViewById(R.id.card_image));
}
});
共通要素のマッピングの調整は、
ImagePagerFragment
が開始される際にも行う必要があります。今度は、
setEnterSharedElementCallback()
を呼び出します。
<ImagePagerFragment.java>
setEnterSharedElementCallback(
new SharedElementCallback() {
@Override
public void onMapSharedElements(
List<String> names, Map<String, View> sharedElements) {
// Locate the image view at the primary fragment (the ImageFragment
// that is currently visible). To locate the fragment, call
// instantiateItem with the selection position.
// At this stage, the method will simply return the fragment at the
// position and will not create a new one.
Fragment currentFragment = (Fragment) viewPager.getAdapter()
.instantiateItem(viewPager, MainActivity.currentPosition);
View view = currentFragment.getView();
if (view == null) {
return;
}
// Map the first shared element name to the child ImageView.
sharedElements.put(names.get(0), view.findViewById(R.id.image));
}
});
遷移の先送り
移動させるイメージは、グリッドとページャーに読み込まれますが、読み込みには時間がかかります。これをきちんと動作させるために、関係するビューが準備(例: イメージデータのレイアウトや読み込み)できるまで遷移を
先送りする必要があります。
そのためには、フラグメントの
onCreateView()
で
postponeEnterTransition()
を呼び出し、イメージの読み込みが完了したタイミングで
startPostponedEnterTransition()
を呼び出して画面遷移を開始します。
注: アプリで開く操作と戻る操作の両方に対応するため、先送りはグリッドとページャーの両方のフラグメントで呼び出します。イメージの読み込みには
Glideを使っているので、イメージが読み込まれた際に遷移の開始をトリガーするリスナーを設定します。
これは、次の 2 か所で行います。
ImageFragment
のイメージが読み込まれたときに、親の ImagePagerFragment
を呼び出して遷移を開始します。 - グリッドに戻る遷移の際は、「選択されている」イメージが読み込まれた後に、遷移の開始が呼び出されます。
以下に、イメージが読み込まれて準備ができたときに親に通知する
ImageFragment
を示します。
postponeEnterTransition
は
ImagePagerFragment
で実行されますが、
startPostponeEnterTransition
はページャーが作成した子
ImageFragment
から呼ばれる点に注意してください。
<ImageFragment.java>
Glide.with(this)
.load(arguments.getInt(KEY_IMAGE_RES)) // Load the image resource
.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 がハングしないようにするために重要です。
最後の仕上げ
さらに画面遷移をスムーズにするために、イメージがページャー ビューに移動する際に、グリッド アイテムをフェードアウトさせましょう。
これを行うには、
GridFragment
が終了する際の遷移に適用される
TransitionSet
を作成します。
<GridFragment.java>
setExitTransition(TransitionInflater.from(getContext())
.inflateTransition(R.transition.grid_exit_transition));
<grid_exit_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:startDelay="25">
<fade>
<targets android:targetId="@id/card_view"/>
</fade>
</transitionSet>
終了時の遷移を設定すると、画面遷移は次のようになります。
気づいた方もいらっしゃるかもしれませんが、まだこの画面遷移は完璧とは言えません。フェードするアニメーションは、ページャーに移動するイメージが表示されているカードを含め、すべてのグリッドのカードビューに対して実行されています。
この点を修正するには、
GridAdapter
でフラグメントのトランザクションをコミットする前に、クリックしたカードを終了時の遷移から除外します。
// The 'view' is the card view that was clicked to initiate the transition.
((TransitionSet) fragment.getExitTransition()).excludeTarget(view, true);
この変更を行うと、アニメーションはかなり優れたものになります(終了時の遷移で、クリックしたカードはフェードアウトしませんが、その他のカードはすべてフェードアウトします)。
最後の仕上げとして、
GridFragment
をスクロールするよう設定し、ページャーから戻る際に遷移先のカードが表示されるようにします(これは、
onViewCreated
で行います)。
<GridFragment.java>
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);
// Scroll to position if the view for the current position is null (not
// currently part of layout manager children), or it's not completely
// visible.
if (viewAtPosition == null
|| layoutManager.isViewPartiallyVisible(viewAtPosition, false, true)){
recyclerView.post(()
-> layoutManager.scrollToPosition(MainActivity.currentPosition));
}
}
});
まとめ本記事では、
RecyclerView
から
ViewPager
を開く際と、そこから戻る際のスムーズな画面遷移を実装しました。
画面遷移を先送りし、ビューの準備ができてから遷移を開始する方法を紹介するとともに、共通要素の
再マッピングも実装し、アプリの操作によって共通のビューが動的に変わる場合の画面遷移にも対応しました。
このような変更を行うことによって、ユーザーがアプリを操作する際に、視覚的に途切れることがない優れたフラグメントの遷移を実現できます。
デモアプリのコードは、
こちらに掲載されています。
Reviewed by
Yuichi Araki - Developer Relations Team