【Kotlin】Navigation Architecture Componentで安全に遷移する方法

この記事は[Kotlin Advent Calendar 2020](https://qiita.com/advent-calendar/2020/kotlin)の4日目の記事です。
Androidアプリ開発初心者ですが、空きがあったので勢いだけで参加してみました。

## はじめに
[Navigation Architecture Component](https://developer.android.com/guide/navigation?hl=ja)を使ってアプリを作成した際、
素早く操作した場合や戻るボタンの連打などで、NavControllerと表示されているFragmentがズレてしまい、エラー(`IllegalArgumentException`)が頻発しました。
これからライブラリ自体のアップデートで解消されていく可能性はありますが、現時点では、自前で対策する必要があります。

## 普通の画面遷移

```kotlin:MainFragmentからSubFragmentへの遷移
findNavController().navigate(R.id.action_mainFragment_to_subFragment)
// パラメータを渡す場合
findNavController().navigate(MainFragmentDirections.actionMainFragmentToSubFragment(params = params))
```

```kotlin:SubFragmentからMainFragmentへ戻る場合の遷移
findNavController().popBackStack()
// または
findNavController().popBackStack(R.id.MainFragment, false)
```
特にpopBackStack()を使った場合は、連打するとNavController内で管理されているBackStackがズレやすくなります。(戻りすぎる)

## 対策済み
Extensionを利用しFragmentに画面遷移用のメソッドを生やします。
(画面遷移前にヒストリとの整合性確認を行う処理を追加しています)

```kotlin:FragmentExtension.kt
package com.sample.extensions

import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.navigation.*
import androidx.navigation.fragment.DialogFragmentNavigator
import androidx.navigation.fragment.FragmentNavigator
import androidx.navigation.fragment.findNavController

/**
 * ==============================================
 *  Fragment Extensions
 * ==============================================
 */
/**
 * 現在のFragmentがヒストリの最新と一致しているか
 */
fun Fragment.isCurrentDestination(): Boolean {
  // ヒストリから現在のFragmentの情報を取得
  val currentDestination: NavDestination = findNavController().currentDestination ?: return false

  when (currentDestination) {
    is DialogFragmentNavigator.Destination -> {
      if (currentDestination.className != this.javaClass.name) {
        // ヒストリ上の現在のFragmentと画面遷移イベントが発生したFragmentが不一致
        return false
      }
      return true
    }
    is FragmentNavigator.Destination -> {
      if (currentDestination.className != this.javaClass.name) {
        // ヒストリ上の現在のFragmentと画面遷移イベントが発生したFragmentが不一致
        return false
      }
      return true
    }
    else -> {
      return false
    }
  }
}

/**
 * safeNavigate
 */
fun Fragment.safeNavigate(
  resId: Int,
  args: Bundle? = null,
  navOptions: NavOptions? = null,
  navigatorExtras: Navigator.Extras? = null
) {
  if (!isCurrentDestination()) {
    return
  }
  findNavController().apply {
    navigate(resId, args, navOptions, navigatorExtras)
  }
}

/**
 * safeNavigate
 */
fun Fragment.safeNavigate(
  directions: NavDirections,
  navOptions: NavOptions? = null
) {
  safeNavigate(directions.actionId, directions.arguments, navOptions)
}

/**
 * safePopBackStack
 */
fun Fragment.safePopBackStack() {
  if (!isCurrentDestination()) {
    return
  }
  forcePopBackStack()
}

/**
 * forcePopBackStack
 * note:ヒストリと現在表示されているFragmentの一致チェックなしで戻る処理を行う
 *      DialogFragmentを表示元のFragmentから非表示にする場合などの利用を想定
 */
fun Fragment.forcePopBackStack() {
  findNavController().apply {
    val navBackStackEntry : NavBackStackEntry =  previousBackStackEntry ?: return
    // ヒストリの1つ前の画面に戻る
    // popBackStack()だと、連打により2つ前の画面まで戻ってしまいヒストリがおかしくなることがある
    popBackStack(navBackStackEntry.destination.id, false)
  }
}

/**
 * safePopBackStack
 */
fun Fragment.safePopBackStack(
  destinationId: Int,
  inclusive: Boolean
) {
  if (!isCurrentDestination()) {
    return
  }
  findNavController().apply {
    popBackStack(destinationId, inclusive)
  }
}
```

```kotlin:MainFragmentからSubFragmentへの遷移
safeNavigate(R.id.action_mainFragment_to_subFragment)
// パラメータを渡す場合
safeNavigate(MainFragmentDirections.actionMainFragmentToSubFragment(params = params))
```

```kotlin:SubFragmentからMainFragmentへ戻る場合の遷移
safePopBackStack()
// または
safePopBackStack(R.id.MainFragment, false)
```

## さいごに
NavigationComponentでの`IllegalArgumentException`を検索してみると、
ボタン連打防止処理や、`navigate`前のヒストリとの整合性確認がよく出てきますが、
端末の戻るボタンでNavControllerを操作(popBackStack)している場合は、
popBackStackにも対策を行う必要がありました:cold_sweat: