TopBarでviewModelを使いたいときに困ったこと

やりたいこと
現状のアプリの構成が以下の通りとなっている
GameApp>GameNavGraph>GameScreen(GameViewModelの各種処理)
GameApp>TopBar
GameViewModelの中に、ゲームが開始したかどうかを管理するisStartedがある。
TopBarからisStartedを参照して、trueなら戻るアイコンを消しておきたい
(戻るアイコンからもどれないように制御)
問題点
GameScreenとTopBarの両方から別々にGameViewModelを呼び出すと
インスタンスが2つできてしまい、想定外の動きとなる可能性あり。
GameAppからGameViewModelを呼び出すとGameScreenから別のScreenに
遷移しても、GameViewModelが破棄されない。
考え方
GameScreenの上位であるGameAppにviewModelではなく使いたいisGameStartedを管理させる。
ホイストというらしい。
viewModelではonStart関数やonCancel関数の中でisStartedのフラグを変更しているが、
ここで管理するisGameStartedはviewModelのisStartedとは別物!
とはいえ、同じようにフラグが動くので連動しているといえる。
◆アプリの構成
GameApp(TopBar)>NavGraph>GameScreen
①最上位であるGameAppに、isGameStartedというゲームが始まっているかどうかを
管理する変数を作成。
②TopBarではisGameStartedを参照して戻るアイコンの表示非表示を決める
③GameAppからonGameStart(isGameStartedをtrueにする関数)とonGameEnd(isGameStartedをfalseにする関数)をNavGraph経由でGameScreenに渡す
④GameScreenは「スタート」ボタンを押したときにonGameStartを実行
また、「やめる」ボタンを押したときにonGameEndを実行
GameAppの処理
GameAppでisGameStartedという可変(var)・記憶可能(remember)・監視可能(mutableStateOf)な変数を作成。
topBarにisGameStartedを渡す。
→trueなら戻るアイコンを消す、falseなら何もしない
GameNavGraph経由でGameScreenにonGameStartとonGameEndを渡す。
・onGameStart:isGameStartedをtrueに変更
・onGameEnd:isGameEndをfalseに変更
※上位にあるGameAppでisGameStartedを変更していることがキモ!
@Composable
fun GameApp() {
val navController = rememberNavController()
var isGameStarted by remember { mutableStateOf(false) } //GameViewModelのisStartedと同期させる
Scaffold(
topBar = { TopBar(
navController = navController,
isStarted = isGameStarted //ここ!
) },
//bottomBar
) { innerPadding ->
Box(modifier=Modifier.padding(innerPadding)){
GameNavGraph(
navController = navController,
onGameStart = { isGameStarted = true }, //ここ
onGameEnd = { isGameStarted = false }, //ここ
)
}
}
}
TopBar
TopBarでは、もらったisStartedのフラグがfalseだったら戻るアイコンを表示させる。
他の画面に影響を与えたくないので、currentScreen == Screen.Gameという条件を
追加で入れている。
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopBar(
navController: NavController,
isStarted:Boolean = false,
) {
//・・・省略・・・
if (currentRoute != Screen.Home.route) { //HomeにいるときはTopBarを表示しない
CenterAlignedTopAppBar(
navigationIcon = {
if (navController.previousBackStackEntry != null) {
if(!(currentScreen == Screen.Game &&isStarted)) { //ゲームが始まっていたら戻るアイコンを消す
IconButton(onClick = { navController.popBackStack() }) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.icon_back))
}
}
}
},
//・・・・省略・・・・
GameNavGraphの処理
GameNavGraphはonGameStartedとonGameEndを上位から受け取るが、
そのまま下位のGameScreenに流している。
@Composable
fun NoToreNavGraph(
navController:NavHostController,
onGameStart: () -> Unit, //GameViewModelのisStartedをtrueにする処理
onGameEnd: () -> Unit, //GameViewModelのisStartedをfalseにする処理
) {
NavHost(
navController = navController,
startDestination = Screen.Home.route, //最初の画面
){
//省略
composable(Screen.Game.route) { backStackEntry ->
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry(Screen.Home.route)
}
val GameViewModel: GameViewModel = viewModel(parentEntry,factory = AppViewModelProvider.Factory)
GameScreen(
viewModel = GameViewModel,
onGameStart = onGameStart, //下位に渡す
onGameEnd = onGameEnd, //下位に渡す
)
}
GameScreenの処理
上位であるGameNavGraphからonGameStartとonGameEndを受け取る。
「スタート」ボタンが押されると、以下2つの関数を実行
・onGameStart: onGameStart関数を呼び出す→上位に通知
→isGameStartedがtrueになる
・viewModel.onStart: viewModelのonStart関数を呼び出す
→uiStateのisStartedがtrueになる他色々な処理
@Composable
fun GameScreen(
modifier: Modifier = Modifier,
viewModel: GameViewModel,
onGameStart: () -> Unit = {},
onGameEnd: () -> Unit = {},
){
//省略
ButtonSpace(
isStarted = uiState.isStarted,
onStart = {
onGameStart() //親にゲームスタートを通知
viewModel.onStart()
},
onCancel = { viewModel.onCancel() },
onAnswer = { viewModel.onAnswer(context = context) },
onAllClear = { viewModel.onAllClear() },
)
}
つまり?
isGameStartedのような状態をGameScreen内ではなく、GameApp等の上位で管理をすることをホイストというらしい。(実際のtrue/falseの切り替えトリガーはGameScreenが担当)
わかったような?わからないような???