皆様は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>
従来の方法
musicLineリリース当初(2014年)はNavigation Componentがなく、FragmentTransactionを使った従来からの方法で実装していました。
でもこの従来の方法では、どのように画面推移するかがぱっと見でわかりずらいという欠点があります。
というのも、画面推移する方向を各コントローラー(ActivityやFragment)で記述するので、画面推移が複雑だと複数のファイルを辿っていかないと推移の流れがわからないからです。
Navigationを使用すると、画面がどう推移するかの関係性のみをXmlファイルに記述できるため、そのファイルのみで画面推移方向がわかります。
そのため、今回はNavigationの使用を試みたのですが、Navigationは基本的にActivity全体で使用することが想定されている?ようで、一部のダイアログのみにNavigationを使用することで注意点があります。
実装
まずは以下のページに沿ってNavigationを実装してみました。
- gradleに依存関係を追記
- ナビゲーショングラフのファイルを追加
- ナビゲーショングラフに推移関係を記述
- 使用するレイアウトにNavHostFragmentを追加
- 画面推移の実装
つまずきポイント
ダイアログを作成する時に例外発生
ダイアログで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追加要求をしたことが原因のようでした。
管理するFragmentManagerを見直す
例外が発生しないように、DialogFragmentのFragmentManagerでNavHostFragmentを管理することで解決しました。特に指定せずにDialogFragmentでFragmentContainerViewを使用すると、ActivityのFragmentManagerでFragmentが管理されるようです。
スコープの観点からも、ダイアログを閉じた時にNavHostFragmentを削除したいため、DialogFragmentのFragmentManagerで管理することが正しいFragmentの持ち方だと思います。
なお、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で管理されているため、適切な親子関係となりました。
これで、ダイアログを閉じるときはDialogFragmentのFragmentManagerで管理されているNavHostFragmentやその下の管理のFragmentを削除するようにできました。
FragmentManagerの階層を適切な親子関係に構築しておかないと、ダイアログを再度開いた時に画面推移の状態が維持されていたり、予期せぬ動作となるため注意が必要です。
解決できなかったボツ実装を表示
Fragmentを挟む
ダイアログの表示とNavHostFragmentの追加が同じFragmentManagerで衝突することが例外の原因になるため、ActivityとDialogFragmentの間にFragmentを挟んでFragmentManagerを分けてみました。
FragmentからchildFragmentManager
により、FragmentのFragmentManagerでDialogFragmentを表示・管理します。これにより、ダイアログの表示とNavHostFragmentの追加を要求するFragmentManagerを別々にして解決しました。
この方法でも、ダイアログ作成時の例外を解決できましたが、FragmentManagerの階層が不適切な親子関係なため、特定の操作で問題が出ました。
再度ダイアログを開くときに状態が初期化されない
ダイアログを閉じて、再度開くときにNavigationの状態が初期化されずに、前回の状態を維持していました。NavHostFragmentはActivityのFragmentManagerで管理しているため、ダイアログを破棄しても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の知識がちゃんとあれば、そこまで苦戦することではなかったかもしれません。
でも同じようなパターンで悩んでる人がいればご参考に。
参考