TL;DR
Jetpack NavigationでpopUpTo属性を使ったActionで画面遷移した場合、遷移元のFragmentには常にpopExitAnimで指定したリソースが使われるようになり、意図したアニメーションになりません。
遷移元Fragmentでfun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation?
をオーバーライドし、適切なタイミングで適切なAnimation
オブジェクトを生成することで修正可能です。
発生環境
- Android 3.3
- Navigation v1.0.0-beta01より以前のバージョン
問題発生手順
popUpTo属性で一方通行の遷移を組む
Jetpack Navigationにて、例えば、通常トップ画面からメイン画面に遷移させますが、特定条件下においてはログイン画面を経由させてからメイン画面に遷移させるような画面遷移を組むとします。
このとき、ログイン画面を経由した場合のメイン画面でBackキーが押された場合、ログイン画面に戻さずトップ画面に戻るようにします(一方通行化)。
このような画面遷移をNavigation Graphエディタで組む場合、ログイン画面からメイン画面へ遷移するActionにPop To(app:popUpTo
属性)を設定することで実現することができます。
<navigation
app:startDestination="@id/fragmentTop"
...>
<fragment
android:id="@+id/fragmentTop"
...>
<action
android:id="@+id/action_fragmentTop_to_fragmentMain"
app:destination="@id/fragmentMain" />
<action
android:id="@+id/action_fragmentTop_to_fragmentLogin"
app:destination="@id/fragmentLogin" />
</fragment>
<fragment
android:id="@+id/fragmentLogin"
...>
<action
android:id="@+id/action_fragmentLogin_to_fragmentMain"
app:destination="@id/fragmentMain"
app:popUpTo="@+id/fragmentTop"
app:popUpToInclusive="false" />
</fragment>
<fragment
android:id="@+id/fragmentMain"
...>
</fragment>
</navigation>
これを動作させてみると次のようになります。
とりあえず意図通りに遷移できているように見えます。
スライドアニメーションを加える
ここで、全ての画面遷移に横スライドアニメーションを付与してみます。
通常の順方向遷移では、次の画面を右からスライドインし、現在の画面を左へスライドアウトさせます。
反対に、Backstackによる逆方向遷移では、前の画面を左からスライドインし、現在画面を右へスライドアウトさせます。
これらのアニメーションを行うために以下のリソースを用意し、全ての<action>
タグのenterAnim
・exitAnim
・popEnterAnim
・popExitAnim
に指定します。
-
nav_enter_from_right
:<translate fromXDelta="100%p" toXDelta="0%p">
-
nav_exit_to_left
:<translate fromXDelta="0%p" toXDelta="-100%p">
-
nav_enter_from_left
:<translate fromXDelta="-100%p" toXDelta="0%p">
-
nav_exit_to_right
:<translate fromXDelta="0%p" toXDelta="100%p">
<action
...
app:enterAnim="@anim/nav_enter_from_right"
app:exitAnim="@anim/nav_exit_to_left"
app:popEnterAnim="@anim/nav_enter_from_left"
app:popExitAnim="@anim/nav_exit_to_right" />
これらのアニメーションの動作を確認してみると……
トップ画面からメイン画面への遷移は問題ありませんが、残念ながらログイン画面からメイン画面への遷移がおかしくなっています。
メイン画面は右側からスライドインしていますが、なぜかログイン画面は右側へスライドアウトしています。
調査
使用されている遷移アニメーションのリソースを確認する
何が起きているのかもう少し詳しく調べてみます。
ログイン画面のFragmentでfun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation?
をオーバーライドし、アニメーション時に何のリソースが使われたのか具体的に確認してみます。
class FragmentLogin {
...
private fun navigateToMain() {
findNavController().navigate(R.id.action_fragmentLogin_to_fragmentMain)
}
override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? {
Log.d(javaClass.simpleName, "transit=$transit, enter=$enter, nextAnim=${nextAnim.animResourceName()}")
return super.onCreateAnimation(transit, enter, original)
}
}
private fun Int.animResourceName(): String {
return when (this) {
R.anim.nav_enter_from_right -> "nav_enter_from_right"
R.anim.nav_exit_to_left -> "nav_exit_to_left"
R.anim.nav_enter_from_left -> "nav_enter_from_left"
R.anim.nav_exit_to_right -> "nav_exit_to_right"
else -> "unknown (0x%x)".format(this)
}
}
これでログイン画面まわりの遷移を観察したところ、次のような実行結果が得られました。
- 1. トップ画面からログイン画面への順方向遷移
D/FragmentLogin: onCreateAnimation: transit=0, enter=true, nextAnim=nav_enter_from_right
D/FragmentTop: onCreateAnimation: transit=0, enter=false, nextAnim=nav_exit_to_left
- 2. ログイン画面からBackstackによるトップ画面への逆方向遷移
D/FragmentTop: onCreateAnimation: transit=0, enter=true, nextAnim=nav_enter_from_left
D/FragmentLogin: onCreateAnimation: transit=0, enter=false, nextAnim=nav_exit_to_right
- 3. 再度トップ画面からログイン画面へ順方向遷移
D/FragmentLogin: onCreateAnimation: transit=0, enter=true, nextAnim=nav_enter_from_right
D/FragmentTop: onCreateAnimation: transit=0, enter=false, nextAnim=nav_exit_to_left
- 4. ログイン画面からメイン画面への順方向遷移
D/FragmentMain: onCreateAnimation: transit=0, enter=true, nextAnim=nav_enter_from_right
D/FragmentLogin: onCreateAnimation: transit=0, enter=false, nextAnim=nav_exit_to_right
問題となっている4.は、Backstack popアニメーションとして指定していたnav_exit_to_right
が実際に使われていることがわかりました。
問題の修正
適切なAnimationオブジェクトを生成する
上記4.のタイミングで使用されるアニメーションを正しいものに修正します。
本来、onCreateAnimation()
の引数だけで「4. ログイン画面からメイン画面への順方向遷移」のタイミングであることが判定できればいいのですが、引数で渡されるtransit
やenter
だけでは残念ながら「2. ログイン画面からBackstackによるトップ画面への逆方向遷移」との見分けが付きません。
苦肉の策ですが、次のような実装を行うしか思いつきませんでした。
class FragmentLogin {
private var overwriteAnimationResource = 0
private fun navigateToMain() {
overwriteAnimationResource = R.anim.nav_exit_to_left // 正しいアニメーションリソースを上書き用変数に代入
findNavController().navigate(R.id.action_login_to_main)
}
override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? {
Log.d(javaClass.simpleName, "transit=$transit, enter=$enter, nextAnim=${nextAnim.animResourceName()}")
if (overwriteAnimationResource != 0) {
// 上書き用アニメーションリソース変数に値がセットされていた場合、これを使ってAnimationオブジェクトを生成する
val tempRes = overwriteAnimationResource
overwriteAnimationResource = 0 // 0に戻しておく
Log.w(javaClass.simpleName, "overwritten nextAnim=${tempRes.animResourceName()}")
return AnimationUtils.loadAnimation(requireContext(), tempRes)
}
return super.onCreateAnimation(transit, enter, original)
}
}
とりあえずこれで期待通りにアニメーションするようになりました。
備考
フェードイン・フェードアウトだと気づきにくい
デフォルトで用意されているFragment切り替えアニメーションはフェードイン・フェードアウトで、順方向もBackstackによる逆方向もenter/exitで同じアニメーションが適用されています。
これによってこの現象が起きていることに気づきにくいです。
Jetpack Navigationのスタックの仕組み≠Fragment BackStackのスタックの仕組み?
まだ正確に読み取れていませんが、NavController.navigate(int)
のコードを追いかけてみると、結局のところFragmentTransaction.replace()
とFragmentManager.popBackStack()
というFragment操作で昔からお馴染みのメソッドで画面スタックが制御されている雰囲気を感じます。
そして、popUpTo
属性付きでnavigate()
を実行した場合、目的のFragmentまでpopBackStack()
で戻ってからreplace()
をかけるというやり方で実装しているようです。
この点から、ログイン画面にBackstack popアニメーションが適用されてしまうのは何となく合点がいきました。
ただ、普通のFragment制御を考えると、トップ画面の次に表示されているログイン画面下でpopBackStack()
するのですから、瞬間的にトップ画面が表示(onStart
)されるはずです。
にも関わらず、4.の操作ではトップ画面のライフサイクルは発生しないようです。
Navigationライブラリでは普通のFragmentスタックに細工がされているのでしょうか?
これを理解するにはもっとNavigationライブラリのコードを読み解かなければなりません。。。
関連Issue
下記のIssueはまさにこの現象について報告されています。
2019年2月現時点で特段の進捗は無いようです。
Wrong animation used for action with popUpTo to another destination
https://issuetracker.google.com/issues/111659726