2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

BottomNavigationView + Jetpack Navigation 環境におけるタブ切り替え時の Fragment#onDestroy と ViewModel#onCleared

Last updated at Posted at 2024-01-26

はじめに

BottomNavigationView + Jetpack Navigation の組み合わせで以下のようなサンプルアプリを作ってみたのだが、ふと BottomNavigationView をタップしてタブを切り替えた時に Fragment#onDestroy は呼ばれるが、ViewModel#onCleared は呼ばれないという事象に気づいた。

尚、上記アプリは以下のようなクラス構成になっている。

どうやら実装の仕方によって Fragment#onDestroy だけが呼ばれる場合と Fragment#onDestroyViewModel#onCleared がセットで呼ばれる2つのケースがあるようなのでまとめておく。

※ この記事はあくまで BottomNavigationView をタップしてタブを切り替えした時にフォーカスしており、バックキーを押したときや、各タブのサブ画面に遷移するときについては対象外となることをご了承いただきたい。

setupWithNavController による影響

公式ドキュメントでも紹介されているように
BottomNavigationViewJetpack NavigationsetupWithNavController で連携させることができる。本記事はこの setupWithNavController を使用していることを前提とする。以下は setupWithNavController を使ったサンプルコードである。

MainActivity.kt
class MainActivity : AppCompatActivity() {
    // レイアウト acivitiy_main.xml の ViewBinding インスタンス
    private lateinit var binding: ActivityMainBinding 
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val bottomNav = binding.bottomNav
        // acivitiy_main.xml に id: nav_host_fragment の FragmentContainerView を配置
        val fragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
        setupWithNavController(bottomNav, fragment.navController)
    }
・・・    
acivitiy_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".ui.MainActivity">

    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toTopOf="@id/bottom_nav"
        app:defaultNavHost="true"
        app:navGraph="@navigation/root" />

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:id="@+id/bottom_nav"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="?android:attr/windowBackground"
        app:menu="@menu/bottom_nav_menu"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toBottomOf="@id/nav_host_fragment"
        app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

そしてこの setupWithNavController の使い方が ViewModel#onCleared が呼ばれるかどうかに影響する。
上記のサンプルコードにおいて以下のように setupWithNavController を呼ぶと ViewModel#onCleared が呼ばれない。

setupWithNavController(bottomNav, fragment.navController)

以下のように setupWithNavController を呼ぶと ViewModel#onCleared が呼ばれる。

setupWithNavController(bottomNav, fragment.navController, false)

第3引数の false は何なのか? Reference を見ると

Whether the NavController should save the back stack state. This must always be false: leave this parameter off entirely to use the non-experimental version of this API, which saves the state by default.

とあり、この値が"状態の保持しない"と意味を表しているようだ。
ちなみに

setupWithNavController(bottomNav, fragment.navController, true)

とすると、以下のエラーを吐いて異常終了するので注意すること!

Caused by: java.lang.IllegalStateException: Leave the saveState parameter out entirely to use the non-experimental version of this API, which saves the state by default

BottomNavigationView#setOnItemSelectedListener による影響

各タブのサブ画面に遷移した後タブを切り替えた際にタブの選択状態を正しく反映させるため、 BottomNavigationView#setOnItemSelectedListener を使用することが多いと思われるがこれも ViewModel#onCleared が呼ばれるかどうかに影響する。

MainActitivity.kt
class MainActivity : AppCompatActivity() {
    // レイアウト acivitiy_main.xml の ViewBinding インスタンス
    private lateinit var binding: ActivityMainBinding 
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        val bottomNav = binding.bottomNav
        // acivitiy_main.xml に id: nav_host_fragment の FragmentContainerView を配置
        val fragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
        setupWithNavController(bottomNav, fragment.navController)
        
        bottomNav.setOnItemSelectedListener { item ->
            NavigationUI.onNavDestinationSelected(item, fragment.navController)
            true
        }
    }
・・・    

BottomNavigationView#setOnItemSelectedListener を使用すると常にViewModel#onCleared が呼ばれない。つまり、以下の効力がない。

setupWithNavController(bottomNav, fragment.navController, false)

まとめ

ViewModel#onCleared が呼ばれるどうかは、次にそのタブに戻ってきた時に ViewModel が再利用されるか新規に生成されるかを意味しており、重要な関心事である。画面の初期化処理を ViewModelinit に実装してしまうエンジニアもいることから注意してほしい。

補足

本記事はNavigation のパッケージバージョン 2.5.3 で検証している。それより古いバージョン(おそらく 2.4.0 未満)は本記事の内容に該当しないと思われる。

参考

本記事は reddit の Why is a ViewModel scoped to the fragment of a BottomNavigationViewnot cleared when the fragment is destroyed?というスレッドを参考にしているのでこちらも合わせて参照されたし。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?