musicLineアプリ開発日記

作曲を誰でも楽しく簡単に♪

DialogFragmentでNavigationを使用する

皆様はAndroid開発において、画面推移をNavigation Componentで実装していますか。
今回はDialogFragmentでNavigationを使用したらハマった話。


musicLineでは、新しく作成する「データを移行」ダイアログにNavigationを使用しました。全体としては従来通りの方法で画面推移するけど、一部のダイアログ内ではNavigationを使用して画面推移したい場合に、実装事例とつまずきポイントを紹介します。


実装事例

musicLineでは作曲データを移行する時、以下の流れでデータ移行を操作します。

移行の流れ

移行項目の選択で

  • 一部のデータを移行
  • すべてのデータを移行

によって、異なる画面へ推移します。

一部のデータを移行
移行する作曲データを選択
すべてのデータを移行
移行する作曲データの数を確認

ナビゲーショングラフを作成して、操作によって4つの画面へ推移するように制御しました。

ナビゲーショングラフ

Navigation コードを表示

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/passing_song_data_navigation"
    app:startDestination="@id/dataPassingUserSelectorFragment"
    xmlns:tools="http://schemas.android.com/tools">

    <!--移行ユーザーの選択-->
    <fragment
        android:id="@+id/dataPassingUserSelectorFragment"
        android:name="...DataPassingUserSelectorFragment"
        android:label="DataPassingUserSelectorFragment"
        tools:layout="@layout/fragment_data_passing_user_selector">
        <action
            android:id="@+id/action_dataPassingUserSelectorFragment_to_dataPassingItemSelectorFragment"
            app:destination="@id/dataPassingItemSelectorFragment" />
    </fragment>

    <!--移行項目の選択-->
    <fragment
        android:id="@+id/dataPassingItemSelectorFragment"
        android:name="...DataPassingItemSelectorFragment"
        android:label="DataPassingItemSelectorFragment"
        tools:layout="@layout/dialog_list">
        <action
            android:id="@+id/action_dataPassingItemSelectorFragment_to_mySongsListUpFragment"
            app:destination="@id/mySongsListUpFragment" />
        <action
            android:id="@+id/action_dataPassingItemSelectorFragment_to_dataPassingConfirmationFragment"
            app:destination="@id/dataPassingConfirmationFragment" />
    </fragment>

    <!--移行データの選択-->
    <fragment
        android:id="@+id/mySongsListUpFragment"
        android:name="...MySongsListUpFragment"
        android:label="MySongsListUpFragment"
        tools:layout="@layout/fragment_list_my_songs_list_up"/>

    <!--移行データの確認-->
    <fragment
        android:id="@+id/dataPassingConfirmationFragment"
        android:name="...DataPassingConfirmationFragment"
        android:label="DataPassingConfirmationFragment"
        tools:layout="@layout/fragment_data_passing_confirmation">

        <argument
            android:name="dataPassingItem"
            android:defaultValue="SelectedSongs"
            app:argType="...DataPassingItemSelectorFragment$DataPassingItem" />
    </fragment>

</navigation>

移行ユーザーの選択
移行項目の選択
移行データの選択
移行データの確認
移行する4つの画面


従来の方法

musicLineリリース当初(2014年)はNavigation Componentがなく、FragmentTransactionを使った従来からの方法で実装していました。

でもこの従来の方法では、どのように画面推移するかがぱっと見でわかりずらいという欠点があります。
というのも、画面推移する方向を各コントローラー(ActivityやFragment)で記述するので、画面推移が複雑だと複数のファイルを辿っていかないと推移の流れがわからないからです。

Navigationを使用すると、画面がどう推移するかの関係性のみをXmlファイルに記述できるため、そのファイルのみで画面推移方向がわかります。

そのため、今回はNavigationの使用を試みたのですが、Navigationは基本的にActivity全体で使用することが想定されている?ようで、一部のダイアログのみにNavigationを使用することで注意点があります。


実装

まずは以下のページに沿ってNavigationを実装してみました。

  1. gradleに依存関係を追記
  2. ナビゲーショングラフのファイルを追加
  3. ナビゲーショングラフに推移関係を記述
  4. 使用するレイアウトにNavHostFragmentを追加
  5. 画面推移の実装


つまずきポイント

ダイアログを作成する時に例外発生

ダイアログでNavigationを使用するため、レイアウトファイルにNavHostFragment(Navigationを制御するFragment)を張り付けます。Fragmentの張り付けはFragmentContainerViewを使用しました。

<layout 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">
   ...

    <!--Fragmentを張り付け-->
    <androidx.fragment.app.FragmentContainerView
        ...
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:navGraph="@navigation/data_passing_navigation"
        />
    ...

</layout>


しかし、ダイアログの作成でレイアウトファイルをinflateする時にランタイムエラーが発生!

class PassingDataDialogFragment : DialogFragment() {
    ...

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        // 例外発生!!
        binding = DataBindingUtil.inflate(LayoutInflater.from(requireActivity()), R.layout.dialog_data_passing, null, false)
        
        ...
    }
}
android.view.InflateException: Binary XML file line #21: Binary XML file line #21: Error inflating class androidx.fragment.app.FragmentContainerView
Caused by: android.view.InflateException: Binary XML file line #21: Error inflating class androidx.fragment.app.FragmentContainerView
Caused by: java.lang.IllegalStateException: FragmentManager is already executing transactions

FragmentManagerのトランザクション実行中(FragmentManagerを操作している状態)に他のトランザクションを実行しようとして例外が発生しています。
つまり、ダイアログを表示する操作でFragmentManagerがトランザクション実行中となり、そのトランザクションが終了しないうちに、さらにFragment追加要求をしたことが原因のようでした。

トランザクション実行中にFragment追加要求


管理するFragmentManagerを見直す

例外が発生しないように、DialogFragmentのFragmentManagerでNavHostFragmentを管理することで解決しました。特に指定せずにDialogFragmentでFragmentContainerViewを使用すると、ActivityのFragmentManagerでFragmentが管理されるようです。
スコープの観点からも、ダイアログを閉じた時にNavHostFragmentを削除したいため、DialogFragmentのFragmentManagerで管理することが正しいFragmentの持ち方だと思います。

DialogFragmentのFragmentManagerで管理

なお、xmlのFragmentContainerViewからFragmentManagerを指定してFragmentを追加することはできなさそうでした。そのため、静的に宣言することを諦めて、ダイアログ作成時に動的にFragmentContainerViewにFragmentを設定することで解決しました。

<layout 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">

   ...
    <!--空のContainerを配置して、ダイアログ作成時にFragmentを設定-->
    <androidx.fragment.app.FragmentContainerView />
    ...

</layout>
class PassingDataDialogFragment : DialogFragment() {
    ...

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        binding = DataBindingUtil.inflate(LayoutInflater.from(requireActivity()), R.layout.dialog_data_passing, null, false)

        if (savedInstanceState == null) {

            // DialogFragmentのFragmentManagerでNavHostFragmentを管理し、ContainerにFragmentを設定
            childFragmentManager.commitNow {
                setReorderingAllowed(true)
                val fragment = NavHostFragment.create(R.navigation.data_passing_navigation)
                replace(R.id.passing_song_navigation, fragment)
            }
        }
        
        ...
    }

}


Fragmentの管理状態を確認

DialogFragmentはActivityのFragmentManagerで管理され、NavHostFragmentはDialogFragmentのFragmentManagerで管理する構造になりました。
さらに、画面推移に必要なFragmentはNavHostFragmentのFragmentManagerで管理されているため、適切な親子関係となりました。

FragmentManagerの親子関係と管理Fragment

これで、ダイアログを閉じるときはDialogFragmentのFragmentManagerで管理されているNavHostFragmentやその下の管理のFragmentを削除するようにできました。
FragmentManagerの階層を適切な親子関係に構築しておかないと、ダイアログを再度開いた時に画面推移の状態が維持されていたり、予期せぬ動作となるため注意が必要です。

解決できなかったボツ実装を表示

Fragmentを挟む

ダイアログの表示とNavHostFragmentの追加が同じFragmentManagerで衝突することが例外の原因になるため、ActivityとDialogFragmentの間にFragmentを挟んでFragmentManagerを分けてみました。
FragmentからchildFragmentManagerにより、FragmentのFragmentManagerでDialogFragmentを表示・管理します。これにより、ダイアログの表示とNavHostFragmentの追加を要求するFragmentManagerを別々にして解決しました。

別のFragmentManagerに追加要求

この方法でも、ダイアログ作成時の例外を解決できましたが、FragmentManagerの階層が不適切な親子関係なため、特定の操作で問題が出ました。


再度ダイアログを開くときに状態が初期化されない

ダイアログを閉じて、再度開くときにNavigationの状態が初期化されずに、前回の状態を維持していました。NavHostFragmentはActivityのFragmentManagerで管理しているため、ダイアログを破棄してもNavHostFragmentは残り続けます。

ダイアログを閉じた時、NavHostFragmentが残る

これは、ダイアログが閉じるときに、NavHostFragmentからparentFragmentManagerを辿り、手動でFragmentを削除することやNavigationの状態を戻すことで解決できました。

手動でフラグメントを削除

    override fun onDestroy() {
        super.onDestroy()
        // NavHostFragmentを手動で削除
        val fragmentManager = binding.dataPassingNavigation.getFragment<NavHostFragment>().parentFragmentManager
        fragmentManager.findFragmentById(R.id.data_passing_navigation)?.let {
            fragmentManager.beginTransaction().remove(it).commit()
        }
    }

この問題は解決できました。
しかし、この方法では次の問題が解決できませんでした。


画面を回転した時にダイアログの中身が空になる

ダイアログを開いている時に、画面を回転させると一旦すべての画面を閉じて再作成されますが、その時にNavHostFragmentで管理しているFragmentの画面が空になり、真っ白に表示されました。

画面回転で中身が空に

そもそも例外が発生してました。
onDestoryでトランザクション実行することはできないようです。
この例外はonPauseで回転時のみ(isChangingConfigurations)実行する等の無理やり回避することはできましたが、中身が空になる不具合は結局解決できず。。

java.lang.RuntimeException: Unable to destroy activity : java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState

問題の解決方法を見直してFragmentManagerの階層を適切な親子関係で構築する方向に切り替えました。


おわりに

DialogFragmentにNavigationを使用して画面推移を実装する話でした。
通常は全ての画面推移をNavigationで管理すると思うので、ダイアログからNavigation管理は特殊なパターンだと思います。
今回はNavigationでハマったというよりは、FragmentContainerViewとFragmentManagerをよく理解しないまま使っていたことによるものでした。FragmentContainerViewの挙動とFragmentManagerの知識がちゃんとあれば、そこまで苦戦することではなかったかもしれません。

でも同じようなパターンで悩んでる人がいればご参考に。


参考