In modern Android development with Jetpack, ViewModel is a crucial component for managing UI-related data in a lifecycle-conscious way. While ViewModels are commonly used with LiveData
or StateFlow
to handle data emission to the UI, incorporating Kotlin sealed classes can significantly improve state management and code clarity. Sealed classes offer a powerful way to represent restricted class hierarchies, making your code more robust and easier to maintain.
What are Kotlin Sealed Classes?
A sealed class in Kotlin represents a restricted class hierarchy. This means that all subclasses of a sealed class are defined within the same file. Sealed classes are implicitly abstract and cannot be instantiated directly. Their main advantage is that when used in a when
expression, the compiler can verify that all possible subtypes are covered, leading to more reliable and concise code.
Why Use Sealed Classes in ViewModel?
- Improved State Management: Encapsulates different states of a ViewModel’s data into a single, cohesive type.
- Compile-Time Safety: Ensures all possible states are handled, reducing the risk of runtime errors.
- Enhanced Readability: Makes the code easier to understand by explicitly defining all possible states.
- Code Maintainability: Simplifies future modifications and additions by providing a clear structure.
How to Implement Kotlin Sealed Classes in Jetpack ViewModel
To implement sealed classes in a Jetpack ViewModel, follow these steps:
Step 1: Define a Sealed Class for UI States
Create a sealed class to represent the different states of your UI. For example, a data loading scenario can have states like Loading
, Success
, and Error
.
sealed class DataState {
object Loading : DataState()
data class Success(val data: String) : DataState()
data class Error(val message: String) : DataState()
}
In this example:
DataState
is a sealed class with three possible states:Loading
,Success
, andError
.Loading
is anobject
, representing a simple loading state without any additional data.Success
is adata class
, encapsulating the successful data retrieved.Error
is also adata class
, encapsulating an error message.
Step 2: Use StateFlow
or LiveData
to Emit UI States
In your ViewModel, use StateFlow
(preferred for newer projects) or LiveData
to emit instances of the sealed class to the UI.
Using StateFlow
:
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class MyViewModel : ViewModel() {
private val _dataState = MutableStateFlow(DataState.Loading)
val dataState: StateFlow = _dataState.asStateFlow()
init {
loadData()
}
private fun loadData() {
viewModelScope.launch {
delay(2000) // Simulate loading data
try {
val data = fetchData()
_dataState.value = DataState.Success(data)
} catch (e: Exception) {
_dataState.value = DataState.Error(e.message ?: "Unknown Error")
}
}
}
private suspend fun fetchData(): String {
// Simulate fetching data from a remote source
delay(1000)
return "Data successfully loaded!"
}
}
Using LiveData
:
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class MyViewModel : ViewModel() {
private val _dataState = MutableLiveData()
val dataState: LiveData = _dataState
init {
loadData()
}
private fun loadData() {
viewModelScope.launch {
_dataState.value = DataState.Loading
delay(2000) // Simulate loading data
try {
val data = fetchData()
_dataState.postValue(DataState.Success(data))
} catch (e: Exception) {
_dataState.postValue(DataState.Error(e.message ?: "Unknown Error"))
}
}
}
private suspend fun fetchData(): String {
// Simulate fetching data from a remote source
delay(1000)
return "Data successfully loaded!"
}
}
In these examples:
_dataState
is aMutableStateFlow
/MutableLiveData
that holds the current UI state.dataState
is aStateFlow
/LiveData
that exposes the state to the UI.- The
loadData
function simulates loading data and updates the state accordingly.
Step 3: Observe UI States in the Activity or Fragment
In your Activity or Fragment, observe the StateFlow
/LiveData
and handle the different states using a when
expression.
Observing StateFlow
in Compose:
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.material3.*
import androidx.compose.foundation.layout.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.getValue
@Composable
fun MyScreen() {
val viewModel: MyViewModel = viewModel()
val dataState by viewModel.dataState.collectAsState()
val context = LocalContext.current
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
when (dataState) {
is DataState.Loading -> {
CircularProgressIndicator()
Text("Loading data...")
}
is DataState.Success -> {
val data = (dataState as DataState.Success).data
Text("Data: $data")
}
is DataState.Error -> {
val errorMessage = (dataState as DataState.Error).message
Text("Error: $errorMessage")
}
}
}
}
@Preview(showBackground = true)
@Composable
fun MyScreenPreview() {
MyScreen()
}
Observing LiveData
in an Activity (Traditional Android Views):
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.TextView
import android.widget.ProgressBar
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
class MainActivity : AppCompatActivity() {
private lateinit var viewModel: MyViewModel
private lateinit var textView: TextView
private lateinit var progressBar: ProgressBar
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
textView = findViewById(R.id.textView)
progressBar = findViewById(R.id.progressBar)
viewModel = ViewModelProvider(this)[MyViewModel::class.java]
viewModel.dataState.observe(this, Observer { dataState ->
when (dataState) {
is DataState.Loading -> {
progressBar.visibility = ProgressBar.VISIBLE
textView.text = "Loading..."
}
is DataState.Success -> {
progressBar.visibility = ProgressBar.GONE
textView.text = "Data: " + (dataState as DataState.Success).data
}
is DataState.Error -> {
progressBar.visibility = ProgressBar.GONE
textView.text = "Error: " + (dataState as DataState.Error).message
}
}
})
}
}
In these examples:
- The
when
expression is used to handle each state defined in theDataState
sealed class. - The UI is updated based on the current state.
- Compile-time safety is ensured because the compiler knows all possible states, and you are forced to handle each one.
Example: Real-World Use Case – Fetching and Displaying Data
Consider a scenario where you’re fetching user profiles from a remote server. You can define a sealed class to represent the different states:
sealed class UserProfileState {
object Loading : UserProfileState()
data class Success(val userProfile: UserProfile) : UserProfileState()
data class Error(val message: String) : UserProfileState()
object Empty : UserProfileState() // No data available
}
data class UserProfile(val id: Int, val name: String, val email: String)
In the ViewModel:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
class UserProfileViewModel : ViewModel() {
private val _userProfileState = MutableStateFlow(UserProfileState.Loading)
val userProfileState: StateFlow = _userProfileState
init {
loadUserProfile()
}
private fun loadUserProfile() {
viewModelScope.launch {
delay(1000) // Simulate network request
try {
val userProfile = fetchUserProfile()
if (userProfile != null) {
_userProfileState.value = UserProfileState.Success(userProfile)
} else {
_userProfileState.value = UserProfileState.Empty
}
} catch (e: Exception) {
_userProfileState.value = UserProfileState.Error(e.message ?: "Failed to load profile")
}
}
}
private suspend fun fetchUserProfile(): UserProfile? {
delay(500) // Simulate delay
// In a real app, fetch data from a remote source
return UserProfile(1, "John Doe", "john.doe@example.com")
}
}
And in the UI:
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.material3.*
import androidx.compose.foundation.layout.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.runtime.getValue
@Composable
fun UserProfileScreen() {
val viewModel: UserProfileViewModel = viewModel()
val userProfileState by viewModel.userProfileState.collectAsState()
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
when (userProfileState) {
is UserProfileState.Loading -> {
CircularProgressIndicator()
Text("Loading user profile...")
}
is UserProfileState.Success -> {
val userProfile = (userProfileState as UserProfileState.Success).userProfile
Text("Name: ${userProfile.name}")
Text("Email: ${userProfile.email}")
}
is UserProfileState.Error -> {
val errorMessage = (userProfileState as UserProfileState.Error).message
Text("Error: $errorMessage")
}
is UserProfileState.Empty -> {
Text("No user profile available.")
}
}
}
}
@Preview(showBackground = true)
@Composable
fun UserProfileScreenPreview() {
UserProfileScreen()
}
Conclusion
Kotlin sealed classes provide a robust and efficient way to manage UI states within Jetpack ViewModels. By encapsulating the possible states and ensuring comprehensive handling through when
expressions, you can significantly improve code clarity, maintainability, and safety. Whether you’re using StateFlow
in Compose or LiveData
in traditional Android views, integrating sealed classes into your ViewModel architecture will lead to more predictable and reliable Android applications.