Move ViewModel out of Compose functions

The Compose framework has already become firmly established in our lives as Android developers. And when creating composable functions, there is a temptation to add viewModel as a parameter. And then in the compose function itself, subscribe to the states that are inside viewModel.

Don't forget that to display Preview, we need to enable compose via gradle. Surprisingly, the framework itself works without it. But Preview and LayoutInspector don't work.

 buildFeatures {
        compose true
    }

And let's not forget about the ui-tooling dependency.

//в toml
ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "ui-tooling" }

//в gradle модуля
debugImplementation(libs.ui.tooling)

Here's an example of the code I'll be working with:

@Composable
private fun LoginScreenRoot(
    viewModel: LoginViewModel
) {
    LoginScreen(viewModel)
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LoginScreen(viewModel: LoginViewModel) {
    Scaffold(
        topBar = {
            TopAppBar(title = { Text(text = "Авторизация") })
        },
        modifier = Modifier.background(Color.White),
    ) { padding ->
        when (viewModel.state.loadingState) {
            is LoginState.LoadingState.Error -> {
                CommonErrorView(
                    modifier = Modifier.padding(padding),
                    onRepeat = { viewModel.onAction(LoginAction.OnRepeat) },
                    error = (viewModel.state.loadingState as LoginState.LoadingState.Error).error
                )
            }

            LoginState.LoadingState.NotSent -> {
                if (viewModel.state.isRegistering) {
                    Register(padding, viewModel.state, viewModel::onAction)
                } else {
                    Login(padding, viewModel.state, viewModel::onAction)
                }
            }

            LoginState.LoadingState.Progress -> ProgressView(2, Modifier.padding(padding))
        }
    }
}

The point of this code is to show the login screen and change the UI if the state needs to be changed. We are now concerned with the parameters of the LoginScreen function. As you can see, we pass the viewModel parameter inside.

At first glance, everything is great. Already inside the function, we can access the viewModel fields. This is very convenient and saves a minimum of parameters for the LoginScreen function.

However, if we try to call this function for the preview, we will encounter a problem. To create a viewModel, we could use a call inside the Fragment or Activity class. DI frameworks like hilt or koin would help us here. But they give an error: ViewModels creation is not supported in Preview. This is the first thing.

This is the first problem. The second is in the tests. After all, in order to use library screenshot testing from android, we need to dampen our viewmodel. And this can be done using additional libraries.

How can we get out of this? In fact, it's very simple and with minimal effort. In the additional LoginScreenRoot function, which will not be used in the Preview, we use the onAction and state methods. With the MVI approach, the set of function parameters will also not grow. Only the contract inside the LoginState and LoginAction classes will change.

@Composable
private fun LoginScreenRoot(
    navigateAction: (LoginNavigation) -> Unit,
    viewModel: LoginViewModel
) {
    LoginScreen(
        viewModel::onAction,
        viewModel.state
    )
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LoginScreen(
    onAction: (LoginAction) -> Unit,
    state: LoginState
) {
    Scaffold(
        topBar = {
            TopAppBar(title = { Text(text = "Авторизация") })
        },
        modifier = Modifier.background(Color.White),
    ) { padding ->
        when (state.loadingState) {
            is LoginState.LoadingState.Error -> {
                CommonErrorView(
                    modifier = Modifier.padding(padding),
                    onRepeat = { onAction(LoginAction.OnRepeat) },
                    error = state.loadingState.error
                )
            }

            LoginState.LoadingState.NotSent -> {
                if (state.isRegistering) {
                    Register(padding, state, onAction)
                } else {
                    Login(padding, state, onAction)
                }
            }

            LoginState.LoadingState.Progress -> ProgressView(2, Modifier.padding(padding))
        }
    }
}

And now we can safely call Preview of our function. And also write screenshot tests. When they leave alpha.

@Preview(name = "login")
@Composable
private fun PreviewLoginScreen() {
    LoginScreen(
        {}, LoginState(false, "test", "test", LoginState.LoadingState.NotSent)
    )
}
preview for LoginScreen

preview for LoginScreen

Here is a short article for those who are just starting to understand the framework that Google gave us.

Thanks for reading. I hope it was helpful.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *