Kotlin Sealed Classes in Jetpack ViewModel

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, and Error.
  • Loading is an object, representing a simple loading state without any additional data.
  • Success is a data class, encapsulating the successful data retrieved.
  • Error is also a data 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 a MutableStateFlow/MutableLiveData that holds the current UI state.
  • dataState is a StateFlow/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 the DataState 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.