I've got an interesting use case when it comes to ...
# compose
c
I've got an interesting use case when it comes to viewModels and scoping. I've got a NewCreditCardScreen and it has it's own NewCreditCardViewModel. Everything works great. Now I want to add in a secret panel (in debug builds) where it can tell what screen you're on, and it'll provide additional actions for you. Internally we call it a debug panel with "screen shortcuts". The idea is when you're on the NewCreditCardScreen, you activate this debug panel (via shaking the phone) and then click a button for "Enter card data" and it'll update NewCreditCardViewModel with the info. 1. NewCreditCardScreen and NewCreditCardViewModel all work correctly by themselves 2. Implemented shake to show DebugPanelBottomSheet 3. cardNumber input field auto-fills 🚫 I thought this would work because ViewModels have a larger scope than a single composable so I thought calling hiltViewModel() will give me the same NewCreditCardViewModel instance that my NewCreditCardScreen is using. Can anyone correct me where my line of thinking is wrong? I think in a typical app I would say "okay, well this state just clearly needs to live somewhere higher up", but because this is a debug only panel I really did think that just requesting a VM that already should be existing would work.
Copy code
@Composable
fun DebugPanelBottomSheet(viewModel: NewCreditCardViewModel = hiltViewModel()) {
    val myClickLambda = {
        viewModel.state.cardNumber = "1234 1234 1234 1234"
    }
//button that passes myClickLamda
So basically these docs here don't seem to be holding true, https://developer.android.com/jetpack/compose/libraries but I'm more confident that the docs are right, and it's my impl thats wrong? Or maybe it's the fact that I'm using hiltViewModel?
Copy code
@Composable
fun MyExample(
    // Returns the same instance as long as the activity is alive,
    // just as if you grabbed the instance from an Activity or Fragment
    viewModel: ExampleViewModel = viewModel()
) { /* ... */ }

@Composable
fun MyExample2(
    viewModel: ExampleViewModel = viewModel() // Same instance as in MyExample
) { /* ... */ }
i
It's the "given scope" part that is important.
viewModel()
and
hiltViewModel()
use the
LocalViewModelStoreOwner
by default. Which, if you're using Navigation, is the containing
NavBackStackEntry
b
i
I get the feeling that you're in two different scopes, so yeah, you'll get two different instances - one per scope
c
Thanks Ian. I added some logs and it does seem as though it two different instances. So now my question is... is there an easy way to retrieve the previous instance? As per the docs
Returns an existing HiltViewModel -annotated ViewModel or creates a new one scoped to the current navigation graph present on the {@link NavController} back stack.
Both my NewCreditCardScreen and my DebugPanelBottomSheet are on the screen at the same time? Actually, maybe it's how my DebugPanelBottomSheet is setup...
My app is defined as
Copy code
setContent {
    MyAppTheme() {
        DebugPanelBottomSheet() {
            AppRouter()
        }
    }
}
and AppRouter contains the actual NavHost.
Copy code
NavHost(navController, startDestination = Screen.Main.route, Modifier.padding(innerPadding)) {
Before I start reorganizing everything (I thought I was being clever by having my DebugPanel live completely outside of everything else), am I missing any other approach that I could take as a shortcut since this is a debug panel and not really something I'm shipping, I'd love to keep at as separate as I have with. I've stayed away from CompositionLocals, but maybe nows a good time to try it out?
Copy code
setContent {
    MyAppTheme() {
        DebugPanelBottomSheet() {
            AppRouter()
        }
    }
}
I suppose this would be easier if there was a legit modal bottom sheet instead of trying to have this wrapper composable at the top level.
Does anyone know how DebugBottomSheet would be able to be in the same scope as app router? Because of the way that ModalBottomSheetLayout works, I don't think my idea is possible at all.
Copy code
ModalBottomSheetLayout(sheetState = state, sheetContent = { DebugBottomSheet() }) {
    Box() { AppRouter() }
}
Got around the issue for now by adding
Copy code
var currentScreenViewModel : ViewModel? = null
to my activity. And then in the NewCreditCardScreen I just do
Copy code
val context = LocalContext.current

(context as MainActivity).currentScreenViewModel = viewModel
and then everything works in my bottom sheet since I actually have the ViewModel. Definitely prone to a memory leak here... but I'll go this route for now in lieu of better ideas.
b
If you use
hiltViewModel
before NavHost, won't they have the same activity scope?
c
They're both definitely in the same activity, yes. And hence I assumed they would have the same scope and I would retrieve the same one, but I think because of navigation there is some additional scoping going on.
i
It sounds like you could use
hiltViewModel(LocalContext.current as ViewModelStoreOwner)
Or like Bereki said, pass an instance of your activity scoped ViewModel into your NavHost/destination
Or, if you use Accompanist Navigation Material and have your bottom sheet as a destination in your graph (rather than living at the activity scope), then both destinations could use
hiltViewModel(navController.getBackStackEntry("your_root_route"))
(using the route you define at the
NavHost
level)
2
c
OoooH! Thanks Ian. Those are all really great options. In regards to the first option: hiltViewModel(LocalContext.current as ViewModelStoreOwner) Would I have to place that on each hiltViewModel() that I have (which is a bunch of them?) or only on the bottomSheet?
Re Option 1: If I add
hiltViewModel(LocalContext.current as ViewModelStoreOwner)
on the DebugBottomSheet then I still get different instances. If I add it on every instance of hiltViewModel
hiltViewModel(LocalContext.current as ViewModelStoreOwner)
then I get the same instance! This seems like what I want, but in this case I'm losing my nav scoped VMs right? I just want to make sure I understand the "risks" before accepting them. Since this concept of "screen shortcuts" is everywhere, this creditCard screen is just the first example, but we'll likely have "screen shortcuts" on just about every screen so I'm trying to find the approach that is most maintainable. But yeah, I guess what makes this tricky is that DebugBottomSheet is the same composable, but depending on what screen is currently showing, I want it to change it's "screen shortcuts" list of buttons contextually, and with that, it means that I should be able to grab any ViewModel at will and modify it. Sort of like "god mode" in a video game. If any of the above info changes your opinion on my approach, then please let me know! Thank you
i
The DebugBottomSheet is outside of the NavHost, right? That means it is already using the activity's
ViewModelStoreOwner
- that's the default provided by
LocalViewModelStoreOwner
👌 1
Every composable outside of a NavHost is all in the same global
LocalViewModelStoreOwner
. It is only when you are inside a
composable
destination in your graph do you get a
LocalViewModelStoreOwner
set on only that composition tree that scopes your ViewModel to that screen
Your usage of
ModalBottomSheetLayout
with a
sheetContent
by necessity means that it has no access to any
LocalViewModelStoreOwner
that is set on a completely different part of the compose hierarchy
👍 1
Which is why Accompanist Navigation Material is so useful in cases like this - it makes that
sheetContent
an entry in the NavHost
which means all of a sudden you do have access to all of the navigation graph scopes via
navController.getBackStackEntry("your_route")
or, what I think would be even better for your use case of launching the bottom sheet from any destination is
navController.previousBackStackEntry
- i.e., you'll know exactly what destination launched your bottom sheet (
navController.previousBackStackEntry!!.destination.route
) and be able to pass that through to
hiltViewModel
to gain access to ViewModels in that previous entry's scope
c
That means it is already using the activity's 
ViewModelStoreOwner
 - that's the default provided by 
LocalViewModelStoreOwner
That makes sense.
It is only when you are inside a 
composable
 destination in your graph do you get a 
LocalViewModelStoreOwner
 set on only that composition tree that scopes your ViewModel to that screen
Also makes sense.
Which is why Accompanist Navigation Material is so useful in cases like this - it makes that 
sheetContent
 an entry in the NavHost
Lost me slightly, there. I thought you would say "it makes bottomsheet an entry in the navhost" Any reason you specifically called out sheetContent?
(
navController.previousBackStackEntry!!.destination.route
) and be able to pass that through to 
hiltViewModel
 to gain access to ViewModels in that previous entry's scope
Oh yeah. That sounds like what I want. So I would only have to pass that through the bottom sheet contents hiltViewModel right? There's no reason to do that for each actual screen in my app?
Hey Ian, how do I grab a viewModel by passing in
navController.previousBackStackEntry!!.destination.route
into hiltViewModel?
Ooh. Actually I think I got it
Copy code
val viewModel =
    hiltViewModel<ViewModel>(
        navController.getBackStackEntry(
            navController.previousBackStackEntry!!.destination.route!!))
but it crashes because it seems like I can't declare ViewModel as a type... it wants me to declare the specific VM type. i.e. CreditCardScreenViewModel. Hm...
i
You're doing too much -
previousBackStackEntry
is a
ViewModelStoreOwner
, so pass it directly in
hiltViewModel<YourViewModel>(navController.previousBackStackEntry!!)
Yes, you need to know the exact type you want
c
Ah okay. I thought I'd be able to use the generic VM type to use in a when statement i.e.
Copy code
val viewModel =
    hiltViewModel<ViewModel>(navController.previousBackStackEntry!!)

when (viewModel)
    is CreditCardScreenViewModel { ButtonToAutoFillCreditCardDetails() }
    is SignUpScreenViewModel { ButtonToAutoFillNewUserSignUp() }
But you are right.
hiltViewModel<YourViewModel>(navController.previousBackStackEntry!!)
worked! I will just create a when statement on the previousBackStackEntry.route, then create a concrete VM and I'm in the clear. Phew! Thank you @Ian Lake . Couldn't do it without you (or @jossiwolf either) 😅