Photo by Michael Dziedzic on Unsplash

Unlocking the power of Jetpack Compose, Fragments, Stateflow, and ViewModels: A Comprehensive Guide

Satyajit Mishra

--

With the launch of Jetpack Compose, we had a powerful declarative way to write UI, thus simplifying the process. As a developer, understanding and effectively utilizing Android Compose has become essential to stay at the forefront of Modern Android Development.

Many existing applications still rely on the traditional view system, making the migration to Compose a significant consideration. In this article, we will dive into the details of working with Fragments and Compose together to make the migration seamless. Some components and libraries that we will be using in this project are Fragments, Compose, ViewModel, Stateflow, Generics, and State Hoisting.

Photo by Tracy Adams on Unsplash

Getting Started

Let’s consider a scenario where we have two fragments and we want to incorporate Composables into both of them. In the XML layout of each fragment, we will use ComposeView to provide a container for our Composables.

<?xml version="1.0" encoding="utf-8"?>
<androidx.compose.ui.platform.ComposeView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/compose_view"
android:layout_width="match_parent"
android:layout_height="match_parent"/>

In the first fragment, we will introduce a ViewModel instance to handle the state management and business logic of our UI. The ViewModel will serve as the bridge between the Composables and the data it requires, such as making network calls or handling user interactions.

Here’s an example of how the ViewModel code might look:

@HiltViewModel
class FragmentOneViewModel @Inject constructor(
private val repository: CustomRepository
): ViewModel() {

private val _uiState = MutableStateFlow<ScreenState>(ScreenState.Empty)
val uiState: StateFlow<ScreenState>
get() = _uiState

init {
performNetworkCallOne()
}

private fun performNetworkCallOne() {
_uiState.value = ScreenState.Loading
viewModelScope.launch(Dispatchers.IO) {
try {
val result = repository.fetchData()
_uiState.value = ScreenState.Success(result)
} catch (e: Exception) {
// Handle error
onErrorOccurred(e.localizedMessage as String)
}
}
}

private fun onErrorOccurred(error: String) {
_uiState.value = ScreenState.Error(error)
}
}

Note: I am using hilt to inject the repository class into the viewmodel.

sealed class ScreenState{
object Empty : ScreenState()
object Loading : ScreenState()
class Error(val message: String) : ScreenState()
data class Success(val data: Any): ScreenState()
/* we are using Any data type for the Success class to make it compatible
with multiple network calls */
}

In the above code, we define a ViewModel class named FragmentOneViewModel. It contains a StateFlow variable named uiState that represents the current state of our UI. Initially, the state is set to Empty. When performNetworkCallOne is triggered, it performs a network request. Upon completion, it updates the state to either Success with the retrieved data or Error associated with an error message.

By using Any as the data type for a successful call, we make the ScreenState class generic and can use it for multiple network calls regardless of their return type.

A bit about StateFlow — StateFlow is a Kotlin-based library that provides a simple and efficient way to manage UI states in Android apps. When developing in Compose, it is helpful to create reactive UIs that are automatically updated in response to state changes. It is an alternative to traditional approaches like LiveData or RxJava, enabling us to handle complex UI interactions and data flows with ease.

By separating the state and business logic in the ViewModel, we ensure a clean separation of concerns and maintain a clear flow of data throughout the UI.

Creating a global composable

To handle various UI states and provide a consistent approach across our app, we create a global composable that takes in the UI state and provides multiple callbacks for different states such as loading, error, empty, and success.

@Composable
fun StateView(
uiState: ScreenState,
loadingComposable: @Composable () -> Unit = { Loading() },
errorComposable: @Composable (String) -> Unit = { ErrorOrEmpty(errorMessage = it) },
successComposable: @Composable (Any) -> Unit,
) {
when (uiState) {
ScreenState.Empty -> {
loadingComposable()
}
ScreenState.Loading -> loadingComposable()
is ScreenState.Error -> errorComposable(uiState.message)
is ScreenState.Success -> {
successComposable(uiState.data)
}
}
}

@Composable
fun Loading() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
Text(
text = "Loading...",
modifier = Modifier.padding(16.dp)
)
}
}

@Composable
fun ErrorOrEmpty(errorMessage: String = "Unknown error occurred") {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "error found - $errorMessage",
modifier = Modifier
.padding(16.dp)
)
}
}

Here, we define the StateView global composable function that takes in the uiState of type ScreenState and three callbacks. Within the composable, we use a when block to handle different UI states. If the UI state is Loading/Empty, we display a circular loading indicator. For Error, we display an error message. For Success, we invoke the successComposable callback, which can be used to inflate the appropriate UI. This provides flexibility to use different Composables for success cases throughout our app.

By encapsulating the handling of different UI states in a global composable, we can ensure consistent and reusable UI patterns across our app, making it easier to manage and maintain UI logic.

Next, we will explore how to utilize this global composable within our fragments, combining it with ViewModel and Compose.

Utilizing the Global Composable in Fragments

To showcase how the global composable can be used in multiple fragments, we’ll start by setting up a fragment. The ViewModel will handle the network call, and the fragment will observe the UI state. Upon triggering, we’ll use a custom function that takes the UI state as a parameter and utilizes the global composable to bind it with the ComposeView in the fragment. Additionally, we can verify the data type from Any within the success callback and inflate the appropriate composable for that particular fragment.

@AndroidEntryPoint
class FragmentOne : Fragment() {
private lateinit var binding: FragmentOneBinding
private val viewModel: FragmentOneViewModel by viewModels()

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
binding = FragmentOneBinding.inflate(inflater, container, false)
return binding.root
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

lifecycleScope.launch {
viewModel.uiState.collect { uiState ->
setContent(uiState)
}
}
}

private fun setContent(uiState: ScreenState) {
binding.composeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MaterialTheme {
StateView(
uiState = uiState
) {
// on success callback logic

val data= it as MyDataModel // Verify the data type

// Inflate the appropriate composable based on the fragment
FragmentOneComposable(data)
}
}
}
}
}
}

By following this approach, we can leverage the global composable in multiple fragments, allowing seamless integration of Compose, ViewModel, and UI state handling throughout the app.

In Conclusion

Understanding the integration of Android Compose and the View System is crucial for Modern Android Development. Harnessing the power of Compose’s declarative UI, StateFlow’s reactive state management, ViewModel’s separation of business logic and Fragment’s modularization, we can simplify the migration process and unlock new possibilities.

My handles-

LinkedIn — https://www.linkedin.com/in/smish01

Twitter — https://twitter.com/smish__

--

--