NavController.currentBackstackEntryをみながら戻りたい画面までpopBackStack()する
前提とやりたいこと
関係しそうな環境:
"androidx.navigation:navigation-fragment-ktx:2.3.0" "androidx.navigation:navigation-ui-ktx:2.3.0"
ツイッタークライアントを作っている。起動したらホームタイムラインを表示して、そこからツイートの詳細やユーザーのプロフィールのFragmentに遷移したり、会話を読んだりできる。また、自分で管理しているユーザーリストを表示して、そのユーザーリストのタイムラインを見ることもできる。他にもいくつか機能はあるけど、今回は割愛。
基本的な画面遷移の構成はいわゆるmaster/detail flowとかいうやつで、このアプリでは次の3つの遷移ができるようになっている。
- リスト(master)→詳細(detail)
- 詳細(detail)→リスト(master) 例:会話とかタグで検索(未実装だけどこれから作る)とか
- リスト(master)→リスト(master) 例:会話とかユーザーリスト(ナビゲーションドロワーから飛べる)とか
リストのFragmentにはどのエンドポイントを叩くかを決めるパラメータをArgumentとして渡している。論理的には無限に画面遷移できるので、たくさん画面遷移した後でも簡単にホームタイムラインに戻ってこられるように、ナビゲーションドロワーでホームタイムラインへ遷移する機能を作りたい。
困ったことと解決策
普通にandroidx.navigationのNavController.popBackstack(Int, Boolean)
を呼ぶと最初に見つかったFragment
までしか戻れない。
setGraph()
をやる時にバックスタックの状態をクリアするかな?と考えて内部の処理を読んでみたら、今まで持ってたグラフのIDとinclusive: true
を渡していたので、inclusive: false
に変えてやってみた。すると画面自体は巻き戻るものの、バックスタックの状態が変わった時に呼ばれるコールバックが呼ばれず、画面の状態に追従できなくなっていた。
実際のところ、これはナビゲーショングラフのルートまで戻る処理であって、ホームタイムラインのFragment
はNavController
のバックスタックからポップされている。ここでようやく、NavController
のバックスタックというのがFragment
のバックスタックとは違うのだという事に気づいた。
NavController
はcurrentBackstackEntry
というプロパティを持っていて、これがNavController
が管理しているバックスタックである。私はこれをFragment
が管理しているバックスタックエンティティ(か、あるいはそれに追従するもの)だと思っていたのだが実際のところはそこまで関連がない。
currentBackstackEntry
はFragment
のIDや渡したArgumentを持っている。また、popBackStack()
を呼んだらすぐに変わるので、ホームタイムラインを表示するためのクエリパラメータを持ったBackstackEntry
が出てくるまでpopBackStack()
を呼び続ければよい。
fluxとかMVIみたいな構造のアプリを作ってみたかった その7
前回: akihito104.hatenablog.com の最後に、
とくにActionsはもっと定型化できるように思う。
というようなことを書いた。ここまでの実装は、
class HogeActions( private val dispatcher: EventDispatcher, ) : HogeEventListener { val selectAccount: Flow<HogeEvent.AccountSelected> = dispatcher.toActionFlow() // EventDispatcherから所望のAppEventを取り出して下流に流すFlowを作る関数 override fun onAccountSelected(account: UserAccount) { dispatcher.post(HogeEvent.AccountSelected(account) } val login: Flow<HogeEvent.LoginClicked> = dispatcher.toActionFlow() override fun onLoginClicked() { dispatcher.post(HogeEvent.LoginClicked) } ... }
のように、イベントバスにイベントを流す処理と、イベントバスを監視して所望のイベントオブジェクトが流れてきたら下流に流す処理とを別々に書いている。それぞれが対応しているということをお互いに近づけて書くことで表現しているのだが、流すイベントのクラスと受け取るイベントのクラスを取り違える危険性があるし、同じような言葉を何度も書かなければならず冗長すぎる。本家Cycle.jsのIntentクラスのようにすっきりさせたい。例えば次のようなイメージで、プロパティの方だけ書けばいいというようなもの。
val selectAccount: ??? = dispatcher.to??? { account -> HogeEvent.AccountSelected(account) } val login: ??? = dispatcher.to???(HogeEvent.LoginClicked)
EventListener
の関数でもありFlow
でもあるようないい感じのクラスと、それを作るファクトリ関数を考えたい。関数をfun
として*1定義するとこのようにはできないので、関数型のクラスを定義して置き換えてみる。例えば、
interface AppEventListener { fun dispatch() } interface AppAction<E : AppEvent> : AppEventListener, Flow<E : AppEvent> fun <E : AppEvent> EventDispatcher.toAction(event: E): AppAction<E> { return object : AppAction<E>, Flow<E> by this.toActionFlow() { override fun dispatcher() { this@toAction.post(event) } } }
のようなinterface
を定義して、ついでにEventDispatcher
からAppAction
を作る便利関数を作ると、例で挙げたHogeActions
クラスは次のように書ける。
interface HogeEventListener { - fun onLoginClicked() + val login: AppEventListener ... } class HogeActions( private val dispatcher: EventDispatcher, ) : HogeEventListener { ... - val login: Flow<HogeEvent.LoginClicked> = dispatcher.toActionFlow() + override val login = dispatcher.toAction(HogeEvent.LoginClicked) - override fun onLoginClicked() { - dispatcher.post(HogeEvent.LoginClicked) - } ... }
ちなみに、DataBindingでレイアウトリソースからEventListener
のプロパティを呼び出すと android:onClick="@{ viewModel.login.dispatch() }"
のような感じになる。レイアウトリソースファイルはJavaの世界なので、AppEventListener
の中の関数をoperator fun invoke
にしてもinvoke
と書かなければならない。invoke
でもいいような気もするけどEventDispatcher
でも使っている言葉をここでも使うことにしてdispatch
を選んだ。
EventListener
の関数が引数をとるような場合も同様に、と言いたいところだけどいろいろ考えだしたらよく分からなくなってしまったので、とりあえず引数を1つだけとるものを用意してみた。
interface AppEventListener1<T> { fun dispatch(t: T) } interface AppAction1<T, E : AppEvent> : AppEventListener1<T>, Flow<E : AppEvent> fun <T, E : AppEvent> EventDispatcher.toAction(block: (T) -> E): AppAction1<T, E> { return object : AppAction1<T, E>, Flow<E> by this.toActionFlow() { override fun dispatcher(t: T) { val event = block(t) this@toAction.post(event) } } }
このようなinterface
及びファクトリ関数を使うことで、次のように書ける。
interface HogeEventListener { - fun onAccountSelected(account: UserAccount) + val selectAccount: AppEventListener1<UserAccount> ... } class HogeActions( private val dispatcher: EventDispatcher, ) : HogeEventListener { - val selectAccount: Flow<HogeEvent.AccountSelected> = dispatcher.toActionFlow() + override val selectAccount = dispatcher.toAction { account: UserAccount -> + HogeEvent.AccountSelected(account) + } - override fun onAccountSelected(account: UserAccount) { - dispatcher.post(HogeEvent.AccountSelected(account) - } ... }
少しはすっきりしただろうか。それとも悪乗りがすぎるだろうか。引数を2つ以上とるものや、1つの関数から複数種類のイベントを発生させるもの(MenuItem
を受けとってそれぞれのイベントに変換するようなものなど)はまだ従来のままなので、なるべくシンプルな記述ですむ方法を考えたいと思っている。
ここまでの話をまとめてzennに投稿した
fluxとかMVIみたいな構造のアプリを作ってみたかったシリーズの主にその5と6をまとめたりまとめなかったりしてZennに投稿した。
FeaturedにのったりPick upということで公式のTwitterアカウントで流れたりして、自分としては初めてのことなのでよかった。
🤔 ピックアップ ✨
— Zenn公式 (@zenn_dev) 2021年4月9日
ViewModelのプロパティを更新する処理を一つにまとめたい
by @akihito104https://t.co/8nYGGW44x3
以下、雑感。
- いいね数:平日の深夜に投稿して数時間で2、朝になってもう2、全部で4。決して多くはないけど反応がもらえるのがよかった。
- いいねされつくした後にGAを入れた:いわゆるPVは40ぐらい、平均エンゲージメント時間は11秒らしい。ちゃんと計れてるのか分からないけどあんまり読まれてない様子で厳しい。
- AndroidとKotlinのtrendingにはもうしばらく残り続けそうなのでまだ何か起きるかな?
- タイトルとか内容とかをもう少し見直した方がいいだろうか。やりたいことが伝わっているのか分からない。こんなんMVIじゃないやろ的なツッコミがあるかなとも思っていたけどそこまで優しくなかった。
- 今年はもう少しZennに書いていきたいと思っている。いろんな人の役に立てる記事を1本でもいいから書けるようになりたい。