Jetpack NavigationでpopUpTo属性を使った画面遷移のアニメーションがおかしくなる問題の修正

# 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`属性）を設定することで実現することができます。

```xml
<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>
```

これを動作させてみると次のようになります。

![1.gif](https://qiita-image-store.s3.amazonaws.com/0/153167/49a66ecd-d856-5fa3-36d3-1dcaef50cc47.gif)

とりあえず意図通りに遷移できているように見えます。


## スライドアニメーションを加える

ここで、全ての画面遷移に横スライドアニメーションを付与してみます。

通常の順方向遷移では、次の画面を右からスライドインし、現在の画面を左へスライドアウトさせます。
反対に、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">`

```xml
    <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" />
```

これらのアニメーションの動作を確認してみると……

![2.gif](https://qiita-image-store.s3.amazonaws.com/0/153167/b3df614f-e7f3-8ad7-7c60-a5231600012c.gif)

トップ画面からメイン画面への遷移は問題ありませんが、残念ながらログイン画面からメイン画面への遷移がおかしくなっています。
メイン画面は右側からスライドインしていますが、なぜかログイン画面は右側へスライドアウトしています。



# 調査

## 使用されている遷移アニメーションのリソースを確認する

何が起きているのかもう少し詳しく調べてみます。

ログイン画面のFragmentで`fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation?`をオーバーライドし、アニメーション時に何のリソースが使われたのか具体的に確認してみます。

```kotlin
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によるトップ画面への逆方向遷移」との見分けが付きません。

苦肉の策ですが、次のような実装を行うしか思いつきませんでした。

```kotlin
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)
    }
}
```

とりあえずこれで期待通りにアニメーションするようになりました。

![3.gif](https://qiita-image-store.s3.amazonaws.com/0/153167/fda94b93-71f3-7a50-5ba4-a4c4a7a20af7.gif)




# 備考

## フェードイン・フェードアウトだと気づきにくい

デフォルトで用意されている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