alpaca0984.log

Jetpack Compose — One-time request

alpaca0984

Thanks to the Jetpack Compose, building Android UI is much easier than it used to be. Within composable functions, we can do almost whatever we want. However, it is a good practice to use view models as state holders as well as sources of access to business logic. The simple example will be like this:

data class UiState(
    val name: String = "",
)
 
class ExampleViewModel : ViewModel() {
 
    var uiState by mutableStateOf(UiState())
        private set
 
    fun updateName(name: String) {
        // some business logic like saving the name into database
 
        uiState = uiState.copy(name = name)
    }
}
 
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ExampleScreen(
    viewModel: ExampleViewModel = viewModel()
) {
    val uiState = viewModel.uiState
    var name by remember { mutableStateOf("") }
 
    Column(modifier = Modifier.padding(24.dp)) {
        Text("Registered name: ${uiState.name}")
 
        Spacer(Modifier.height(16.dp))
        OutlinedTextField(
            value = name,
            onValueChange = { text ->
                name = text
            },
            placeholder = {
                Text("Your name")
            }
        )
 
        Spacer(Modifier.height(16.dp))
        Button(onClick = { viewModel.updateName(name) }) {
            Text("Submit")
        }
    }
}

Whenever the ExampleViewModel updates its uiState, it's notified to ExampleScreen and ExampleScreen triggers recomposition.

That looks good. But how about this case, a view model wants to pass data to UI and recomposes it, but the data should not be persisted as a state.

For example, let's think of this screen. When one of those text buttons is tapped, the screen notifies it to a view model. Then, the view model processes something related to domain logic and publishes the URL. In the end, the web view loads the URL and shows the content on the screen.

In this case, we don't want to hold the URL as a state because, otherwise every time recomposition is triggered, the web view loads the same URL again and again. Instead, the view model should notify the URL only once and doesn't save it anywhere.

How do we achieve it? To address this, we can use SharedFlow which is Kotlin's built-in hot flow mechanism. Let's take a look at how it will be done.

In the below code snippet, the first data class is nothing special. When a text button is clicked, one of the enum objects will be passed to the EmbeddedWebViewViewModel.loadUrlFor(). and the view model does some business logic and emits the url through the UiEvent instance.

data class UiEvent(
    val url: String = "",
)
 
enum class Content(val url: String) {
    YouTube("https://youtube.com/"),
    Instagram("https://instagram.com/"),
    Twitter("https://twitter.com/");
}
 
class EmbeddedWebViewViewModel : ViewModel() {
 
    private val _uiEvent: MutableSharedFlow<UiEvent> = MutableSharedFlow()
    val uiEvent: SharedFlow<UiEvent> = _uiEvent
 
    fun loadUrlFor(content: Content) {
        // some business logic like adding query parameters
        val url = content.url
        viewModelScope.launch {
            _uiEvent.emit(UiEvent(url = url))
        }
    }
}

And on the screen, shared flows can be collected as a state. It means that every time the shared flow emits data, it triggers recomposition to update UI.

@SuppressLint("SetJavaScriptEnabled")
@Composable
fun EmbeddedWebViewScreen(
    viewModel: EmbeddedWebViewViewModel = viewModel()
) {
    val uiEvent by viewModel.uiEvent.collectAsState(UiEvent())
 
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .padding(24.dp)
    ) {
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceAround,
        ) {
            TextButton(onClick = { viewModel.loadUrlFor(Content.YouTube) }) {
                Text("YouTube")
            }
            TextButton(onClick = { viewModel.loadUrlFor(Content.Instagram) }) {
                Text("Instagram")
            }
            TextButton(onClick = { viewModel.loadUrlFor(Content.Twitter) }) {
                Text("Twitter")
            }
        }
 
        Spacer(Modifier.height(16.dp))
        AndroidView(
            factory = { context ->
                WebView(context).apply {
                    webViewClient = WebViewClient()
                    settings.javaScriptEnabled = true
                }
            },
            update = { webView ->
                if (uiEvent.url.isNotEmpty())
                    webView.loadUrl(uiEvent.url)
            }
        )
    }
}

This is how it works. To store view state, we can use MutableState and, for one-time requests to the view, we can use SharedFlow :)