Open1

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が担当)
わかったような?わからないような???