📌

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

2021/03/13に公開

この記事はKotlin Advent Calendar 2020の 4 日目の記事です。
Android アプリ開発初心者ですが、空きがあったので勢いだけで参加してみました。

はじめに

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

普通の画面遷移

MainFragmentからSubFragmentへの遷移
findNavController().navigate(R.id.action_mainFragment_to_subFragment)
// パラメータを渡す場合
findNavController().navigate(MainFragmentDirections.actionMainFragmentToSubFragment(params = params))
SubFragmentからMainFragmentへ戻る場合の遷移
findNavController().popBackStack()
// または
findNavController().popBackStack(R.id.MainFragment, false)

特に popBackStack()を使った場合は、連打すると NavController 内で管理されている BackStack がズレやすくなります。(戻りすぎる)

対策済み

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

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)
  }
}
MainFragmentからSubFragmentへの遷移
safeNavigate(R.id.action_mainFragment_to_subFragment)
// パラメータを渡す場合
safeNavigate(MainFragmentDirections.actionMainFragmentToSubFragment(params = params))
SubFragmentからMainFragmentへ戻る場合の遷移
safePopBackStack()
// または
safePopBackStack(R.id.MainFragment, false)

さいごに

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

GitHubで編集を提案
株式会社ナンバーフォー

Discussion