Mastering UI State Management with Kotlin Sealed Classes in Android XML Projects

Kotlin’s sealed classes are a powerful feature for representing a restricted class hierarchy. They are especially useful for modeling UI states in Android applications. While ViewModels are commonly used for managing UI data, sealed classes offer a structured way to represent different states of the UI, particularly in the context of traditional Kotlin XML development. In this blog post, we’ll explore how to use Kotlin sealed classes for UI state management, going beyond the standard ViewModel examples, and show how they can be applied in the Kotlin XML environment.

What are Sealed Classes?

Sealed classes are an extension of enums that allow you to represent hierarchies in a more expressive way. Each subclass of a sealed class can have its own state, which is a key benefit over using simple enums. This is particularly useful for modeling UI states, where each state may require specific data.

Why Use Sealed Classes for UI State Management?

  • Exhaustive when Expressions: The compiler forces you to handle all possible cases when using a sealed class in a when expression, ensuring that no state is missed.
  • State Preservation: Each state can hold specific data relevant to that particular state, providing more flexibility than enums.
  • Improved Code Readability: They make it easier to understand the state transitions in your UI, promoting maintainability.

Implementing Sealed Classes for UI State Management in Kotlin XML Android Development

Let’s walk through an example of using sealed classes to manage the UI state of a simple data-fetching screen in Android, which uses traditional XML layouts.

Step 1: Define the Sealed Class

First, define a sealed class that represents the possible states of your UI.


sealed class UIState {
    object Loading : UIState()
    data class Success(val data: List<String>) : UIState()
    data class Error(val message: String) : UIState()
    object Empty : UIState()
}

In this example, the UIState sealed class has four possible states:

  • Loading: Indicates that the data is being fetched.
  • Success: Represents the successful retrieval of data, holding a list of strings.
  • Error: Represents an error state, holding an error message.
  • Empty: Indicates that the data is empty.

Step 2: Create a Data Provider Class

Here’s a class that simulates data loading and updates the MutableLiveData with the new UIState.


import androidx.lifecycle.MutableLiveData
import kotlinx.coroutines.delay

class DataProvider {
    val uiState = MutableLiveData<UIState>()

    suspend fun fetchData() {
        uiState.postValue(UIState.Loading)
        delay(1000) // Simulate network delay

        try {
            val data = listOf("Item 1", "Item 2", "Item 3")
            if (data.isNotEmpty()) {
                uiState.postValue(UIState.Success(data))
            } else {
                uiState.postValue(UIState.Empty)
            }
        } catch (e: Exception) {
            uiState.postValue(UIState.Error("Failed to fetch data: ${e.message}"))
        }
    }
}

Step 3: Implementing the UI in an Activity or Fragment (with XML Layout)

Create your activity and set up the necessary UI components in the XML layout.
activity_main.xml:


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Data:"
        android:textSize="18sp"/>

    <ProgressBar
        android:id="@+id/progressBar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:visibility="gone"/>

    <TextView
        android:id="@+id/errorTextView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textColor="#FF0000"
        android:visibility="gone"/>

    <LinearLayout
        android:id="@+id/dataLayout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:visibility="gone">

        <TextView
            android:id="@+id/item1TextView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text=""
            android:textSize="16sp"/>

        <TextView
            android:id="@+id/item2TextView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text=""
            android:textSize="16sp"/>

        <TextView
            android:id="@+id/item3TextView"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text=""
            android:textSize="16sp"/>

    </LinearLayout>
</LinearLayout>

The corresponding Activity Kotlin code:


import android.os.Bundle
import android.view.View
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch

class MainActivity : AppCompatActivity() {

    private lateinit var textView: TextView
    private lateinit var progressBar: ProgressBar
    private lateinit var errorTextView: TextView
    private lateinit var dataLayout: View
    private lateinit var item1TextView: TextView
    private lateinit var item2TextView: TextView
    private lateinit var item3TextView: TextView

    private val dataProvider = DataProvider()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // Initialize views
        textView = findViewById(R.id.textView)
        progressBar = findViewById(R.id.progressBar)
        errorTextView = findViewById(R.id.errorTextView)
        dataLayout = findViewById(R.id.dataLayout)
        item1TextView = findViewById(R.id.item1TextView)
        item2TextView = findViewById(R.id.item2TextView)
        item3TextView = findViewById(R.id.item3TextView)

        // Observe UI State
        dataProvider.uiState.observe(this) { state ->
            when (state) {
                UIState.Loading -> {
                    progressBar.visibility = View.VISIBLE
                    errorTextView.visibility = View.GONE
                    dataLayout.visibility = View.GONE
                    textView.text = "Loading data..."
                }
                is UIState.Success -> {
                    progressBar.visibility = View.GONE
                    errorTextView.visibility = View.GONE
                    dataLayout.visibility = View.VISIBLE
                    item1TextView.text = state.data[0]
                    item2TextView.text = state.data[1]
                    item3TextView.text = state.data[2]
                    textView.text = "Data loaded:"
                }
                is UIState.Error -> {
                    progressBar.visibility = View.GONE
                    errorTextView.visibility = View.VISIBLE
                    dataLayout.visibility = View.GONE
                    errorTextView.text = state.message
                    textView.text = "Error:"
                }
                UIState.Empty -> {
                    progressBar.visibility = View.GONE
                    errorTextView.visibility = View.VISIBLE
                    dataLayout.visibility = View.GONE
                    errorTextView.text = "No data available."
                    textView.text = "Status:"
                }
            }
        }

        // Fetch Data
        lifecycleScope.launch {
            dataProvider.fetchData()
        }
    }
}

Here’s what the Kotlin code does:

  • Initializes UI components in the onCreate method.
  • Observes changes to the uiState LiveData.
  • Updates UI elements according to the state.
    * It makes use of Kotlin’s when expression for exhaustively handling each state, setting the corresponding views visible and invisible.

Benefits of Using Sealed Classes

  • Improved State Management: Provides a clear and structured way to handle different UI states.
  • Compile-Time Safety: Ensures that all possible states are handled, reducing runtime errors.
  • Clear State Representation: Makes code more readable and maintainable by clearly representing different states with specific data.

Conclusion

Kotlin sealed classes offer a robust and structured way to manage UI states in Android applications, especially within Kotlin XML development projects. They offer compile-time safety, clear state representation, and easy state handling, enabling developers to write more maintainable and reliable code. By implementing sealed classes, you can effectively handle UI states, manage data, and create a better user experience.