Home · Android & Kotlin Tutorials

Kotlin and Android: Beyond the Basics with Sealed Classes

In this tutorial, you’ll learn about Kotlin sealed classes and how to use them to manage states when developing Android apps.

4.4/5 9 Ratings

Version

  • Kotlin 1.3, Android 4.4, Android Studio 3.5

Managing UI state is one of the most important things you can do as an app developer. Done wrong, state management causes problems like poor error handling, hard-to-read code and a lack of separation between UI and business logic.

With Kotlin, sealed classes make it simpler to manage state.

In this tutorial, you’ll learn the advantages of Kotlin sealed classes and how to leverage them to manage states in your Android apps.

You’ll do this by building the RickyCharacters app, which displays a list of characters from the television show. You’ll download the character data from The Rick And Morty API and manage states in the app by using the Retrofit library to make network calls.

Note: This tutorial assumes you have experience with developing for Android in Kotlin and know the basics of sealed classes.

If you’re unfamiliar with Kotlin, take a look at our Kotlin For Android: An Introduction tutorial.

To brush up on your Android skills, check out our Android and Kotlin tutorials.

You can also learn more about sealed classes in our Kotlin Sealed Classes tutorial.

Getting Started

Download the begin project by clicking the Download Materials button at the top or bottom of the tutorial.

Extract the zip file and open the begin project in Android Studio by selecting Open an existing Android Studio project from the welcome screen. Navigate to the begin directory and click Open.

Once the Gradle sync finishes, build and run the project using the emulator or a device. You’ll see this screen appear:

Home screen

You probably expected some Rick and Morty Characters on the home screen, but that feature isn’t ready yet. You’ll add the logic to fetch the character images later in this tutorial.

Advantages of Sealed Classes

Sealed classes are a more powerful extension of enums. Here are some of their most powerful features.

Multiple Instances

While an enum constant exists only as a single instance, a subclass of a sealed class can have multiple instances. That allows objects from sealed classes to contain state.

Look at the following example:

sealed class Months {
    class January(var shortHand: String) : Months()
    class February(var number: Int) : Months()
    class March(var shortHand: String, var number: Int) : Months()
}

Now you can create two different instances of February. For example, you can pass the 2019 as an argument to first instance, and 2020 to second instance, and compare them.

Inheritance

You can’t inherit from enum classes, but you can from sealed classes.

Here’s an example:

sealed class Result {
    data class Success(val data : List<String>) : Result()
    data class Failure(val exception : String) : Result()
}

Both Success and Failure inherit from the Result sealed class in the code above.

Kotlin 1.1 added the possibility for data classes to extend other classes, including sealed classes.

Architecture Compatibility

Sealed classes are compatible with commonly-used app architectures, including:

  • MVI
  • MVVM
  • Redux
  • Repository pattern

This ensures that you don’t have to change your existing app architecture to leverage the advantages of sealed classes.

“When” Expressions

Kotlin lets you use when expressions with your sealed classes. When you use these with the Result sealed class, you can parse a result based on whether it was a success or failure.

Here’s how this might look:

when (result) {
  is Result.Success -> showItems(result.data)
  is Result.Failure -> showError(result.exception)
}

This has a few advantages. First, you can check the type of result using Kotlin’s is operator. By checking the type, Kotlin can smart-cast the value of result for you for each case.

If the result is a success, you can access the value as if it’s Result.Success. Now, you can pass items without any typecasting to showItems(data: List).

If the result was a failure, you display an error to the user.

Another way you can use the when expression is to exhaust all the possibilities for the Result sealed class type.

Typically, a when expression must have an else clause. In the example above, however, there are no other possible types for Result. The compiler and IDE know you don’t need an else clause.

Look what happens when you add an InvalidData object to the sealed class:

sealed class Result {
    data class Success(val data : List<String>) : Result()
    data class Failure(val exception : String) : Result()
    object InvalidData : Result()
}

The IDE now shows an error on the when statement because you haven’t handled the InvalidData branch of Result.

Error message: When expression must be exhaustive

The IDE knows you didn’t cover all your cases here. It even knows which possible types result could be, depending on the Result sealed class. The IDE offers you a quick fix to add the missing branches.

IDE offers a fix for the When statement error

Note: You only make a when expression exhaustive if you use it as an expression, you use its return type or you call a function on it.

Managing State in Android

Fasten your seat belt, as you’re about to travel back in time to a multiverse where sealed classes did not exist. Get ready to see how Rick and Morty survived back then.

Classical State Management

Your goal is to fetch a list of characters for the “Rick and Morty” television show from The Rick And Morty API and display them in a Recycler view.

Open CharactersFragment.kt in com.raywenderlich.android.rickycharacters.ui.views.fragments.classicalway and you’ll see the following code:

class CharactersFragment : Fragment(R.layout.fragment_characters) {
  // 1
  private val apiService = ApiClient().getClient().create(ApiService::class.java)
  private lateinit var charactersAdapter: CharactersAdapter

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    // 2
    charactersAdapter = CharactersAdapter {character ->
      displayCharacterDetails(character)
    }
    recyclerViewMovies.adapter = charactersAdapter
    fetchCharacters()
    swipeContainer.setOnRefreshListener {
      fetchCharacters()
    }
  }

  // 3
  private fun displayCharacterDetails(character: Character) {
    val characterFragmentAction =
        CharactersFragmentDirections.actionCharactersFragmentToCharacterDetailsFragment(
            character)
    findNavController().navigate(characterFragmentAction)
  }

To explain the code above:

  1. You have two variables at the very top. One represents the retrofit service class and the other represents the recyclerview adapter class.
  2. Inside onViewCreated, you initialize the adapter class and set the adapter for the Recycler view, which displays the list of characters. There’s a call to fetchCharacters and you also set a refresh listener to the SwipeRefeshLayout that calls the fetchCharacters on refresh.
  3. displayCharacterDetails(character: Character) is responsible for navigating to the CharacterDetailsFragment and showing character details once you tap on each character.

Below displayCharactersDetails(character: Characters), there’s more code responsible for requesting the characters and displaying the appropriate UI.

// 1
private fun fetchCharacters() {
    //TODO 1 Make a get characters Request

    //TODO 2 Catch errors with else statement

    //TODO 3 Catch errors with try-catch statement

    //TODO 4 Catch HTTP error codes

    //TODO 5 Add refresh dialog

    //TODO 6 Handle null response body
  }

  // 2
  private fun showCharacters(charactersResponseModel: CharactersResponseModel?) {
    charactersAdapter.updateList(charactersResponseModel!!.results)
  }

  // 3
  private fun handleError(message : String) {
    errorMessageText.text = message
  }
 
  // 4
  private fun showEmptyView() {
    emptyViewLinear.show()
    recyclerViewMovies.hide()
    hideRefreshDialog()
  }

  // 5
  private fun hideEmptyView() {
    emptyViewLinear.hide()
    recyclerViewMovies.show()
    hideRefreshDialog()
  }

  // 6
  private fun showRefreshDialog() {
    swipeContainer.isRefreshing = true
  }
  
  // 7
  private fun hideRefreshDialog() {
    swipeContainer.isRefreshing = false
  }

Here’s an explanation for the code above:

  1. fetchCharacters() is responsible for fetching the characters from the api and handling the response. There’s a couple of //TODOs here, which you’ll address one by one in this tutorial.
  2. showCharacters(charactersResponseModel: CharactersResponseModel?) takes CharacterResponseModel as an argument. This is a data class representing the response from the Rick and Morty API. The function sends the list of characters to the CharactersAdapter.
  3. handleError(message : String) takes a String as an argument and sets the message to a TextView.
  4. showEmptyView() hides the Recycler view and shows the empty view. This is a linear layout with an image and an error text view for displaying the error message. Notice recyclerViewMovies.hide() uses an extension function from com.raywenderlich.android.rickycharacters.utils.extensions.kt. This is also where you find the show() extension function.
  5. hideEmptyView() hides the empty view and shows the Recycler view.
  6. showRefreshDialog() sets the refresh property of SwipeRefreshLayout to true.
  7. hideRefreshDialog sets the refresh property of SwipeRefreshLayout to false.

With the code in this class explained, we’re ready to begin coding. In the next section, you’ll add begin to request characters from the Rick and Morty API. Time to get schwifty!

Requesting the Characters

Start by replacing the first TODO in fetchCharacters() with the following:

lifecycleScope.launchWhenStarted {
  val response = apiService.getCharacters()
  val charactersResponseModel = response.body()
  if (response.isSuccessful){
    hideEmptyView()
    showCharacters(charactersResponseModel)
  }
}
Note: lifecycleScope throws an error when added, because it needs an imported class to work To fix this, just import the missing class.

This function has a couple of important components:

  • First, there’s lifecycleScope.launchWhenStarted{}, a CoroutineScope from the architecture components that’s lifecycle-aware. It launches a coroutine to perform operations on the background thread that let you make a network call using Retrofit. The project has the Retrofit part already set up for you, along with all its required classes and interfaces.
  • Inside the lifecycleScope, you make the network call. You use response, which calls getCharacters() from Retrofit’s ApiService class to get a list of characters charactersResponseModel derives its value from the response body of the network call.
  • Finally, you check if the response is successful and call showCharacters(charactersResponseModel).
Note: Take a look at the Coroutines documentation for more info about Coroutines.

Build and run and you’ll see the list of Rick and Morty characters:

App displaying Rick and Morty characters

Hurray, the app runs as expected. But there’s a problem: With this kind of approach, this is what happens when an error occurs:

Errors crushing your app

In classic state management, errors completely crash your app.

Addressing Errors

Your next thought might be to catch all errors with an else statement.

To try this, add the following catchall else statement for //TODO 2:

else {
 handleError("An error occurred")
}

To reproduce an error, navigate to data/network/ApiService.kt and make the following change to the @GET call:

@GET("/api/character/rrr")

Notice the addition at the end of the path. Your app won’t like that.

Now, build and run and the app will show errors:

An error occurred screenshot

Though you might think that else has rescued you, a closer look reveals some errors that else doesn’t handle – and they crash the app.

The errors that you have not yet handled are HTTP errors, network exceptions and null responses from the API.

Your next step will be to handle these kinds of errors.

Using a Try-Catch Statement to Catch Errors

If else isn’t robust enough to catch all the errors, maybe a try-catch statement might do the trick? You’ll try that next.

For //TODO 3, modify the entire code in fetchCharacters with a try-catch. The code in the method should look as follows:

  lifecycleScope.launchWhenStarted {
    try {
      val response = apiService.getCharacters()
      val charactersResponseModel = response.body()
      if (response.isSuccessful) {
        hideEmptyView()
        showCharacters(charactersResponseModel)
      } else {
        handleError("An error occurred")       
      }
      
    } catch (error: IOException) {
      showEmptyView()
      handleError(error.message!!)
    }
  }
Note: IOException throws an error when added, just import the missing class to fix this.

Here, the try-catch makes sure the app no longer crashes. It displays an error instead.

Let’s try this out. Make sure your device has no internet connection, then build and run the app. Swipe down to begin requesting characters, shortly, you’ll see the following:

Unable to resolve host error

The app no longer crashes when there’s no internet connection. Instead, it displays the Unable to resolve host error message.

However, you have only addressed one type of error, making it hard to know what’s gone wrong, especially for the case of HTTP errors. You’ll address that problem in the next section.

Handling HTTP Errors

Continue testing your states and and you’ll realize that there are several different HTTP errors that help you know what the problem is.

With the current approach, you won’t see what’s causing the problem because you’ll only get the generic error message: “An error occurred”.

Catch more errors

To catch more of the HTTP errors, replace the code within the else branch in fetchCharacters with this:

 showEmptyView()
 when(response.code()) {
   403 -> handleError("Access to resource is forbidden")
   404 -> handleError("Resource not found")
   500 -> handleError("Internal server error")
   502 -> handleError("Bad Gateway")
   301 -> handleError("Resource has been removed permanently")
   302 -> handleError("Resource moved, but has been found")
   else -> handleError("All cases have not been covered!!")
 }

The code block has a when statement, which takes response.code() from the network call as a parameter. It has some specific cases – 403, 404, 500, 502, 301 and 302 – plus the default else, in case the code isn’t specified. For each case, you handle the error with the appropriate message for each HTTP code.

Navigate to data/network/ApiService.kt again and change the end of the @GET call:

@GET("/api/character/rrr")

Notice the addition at the end of the path. Again, your app won’t like that.

Build and run and you’ll see an Internal server error message instead of the generic error message:

Internal Server Error screenshot

Congratulations, you can now catch HTTP errors and show the user what exactly went wrong instead of a generic message.

Now that you’ve handled your error message nicely, your next step will be to make the download experience more transparent to the user.

Note: Make sure you go back and undo the change on ApiService.kt to remove the addition that causes errors.

Indicating Download Progress

It’s not a great experience to click on an option to download data and get no response until the network call finishes. To make your app more user-friendly, your next step is to show a progress dialog so that users can know that the app is fetching data.

You’ll now address //TODO 5 by adding showRefreshDialog() below fetchCharacters() inside OnViewCreated. Your code should look like the following:

  lifecycleScope.launchWhenStarted {
    try {
      val response = apiService.getCharacters()
      val charactersResponseModel = response.body()
      if (response.isSuccessful) {
        hideEmptyView()
        showCharacters(charactersResponseModel)
      } else {
        showEmptyView()
        when(response.code()) {
          403 -> handleError("Access to resource is forbidden")
          404 -> handleError("Resource not found")
          500 -> handleError("Internal server error")
          502 -> handleError("Bad Gateway")
          301 -> handleError("Resource has been removed permanently")
          302 -> handleError("Resource moved, but has been found")
          else -> handleError("All cases have not been covered!!")
        }
      }
    } catch (error: IOException) {
      showEmptyView()
      handleError(error.message!!)
    }
  }

  showRefreshDialog()

Build and run to see the loading icon.

The app displays a loading icon

You’re almost done, but you have one more case to handle before your app is ready to go.

Handling Null Responses

As you finish managing state the classic way, you need to handle what happens when the success response is null. Handling this case is your //TODO 6.

To address this, add a null check on the on successful response by replacing if (response.isSuccessful) {} with this:

if (response.body() != null) {
  hideEmptyView()
  showCharacters(charactersResponseModel)
} else {
  showEmptyView()
  handleError("No characters found")
}

In the code above, you check if response.body() is null. If it’s not, hide the empty view and show the characters. If it is, you handle the error with a “No characters found” message.

The final results looks like this:

private fun fetchCharacters() {
  lifecycleScope.launchWhenStarted {
    try {
      val response = apiService.getCharacters()
      val charactersResponseModel = response.body()
      if (response.isSuccessful) {
        if (response.body() != null) {
          hideEmptyView()
          showCharacters(charactersResponseModel)
        } else {
          showEmptyView()
          handleError("No characters found")
        }

      } else {
        showEmptyView()
        when (response.code()) {
          403 -> handleError("Access to resource is forbidden")
          404 -> handleError("Resource not found")
          500 -> handleError("Internal server error")
          502 -> handleError("Bad Gateway")
          301 -> handleError("Resource has been removed permanently")
          302 -> handleError("Resource moved, but has been found")
          else -> handleError("All cases have not been covered!!")
        }
      }
    } catch (error : IOException) {
      showEmptyView()
      handleError(error.message!!)
    }
  }

Problems With Classical State Management

As you went implementing state using the classical approach, you may have noticed some problems with it. Including:

  • Poor error handling.
  • Hard-to-read code.
  • No separation of concerns.
  • Small changes are hard to implement.
  • Mixes business logic with the UI. The fetchCharacters function is handling network responses as well as UI for instance
  • Unpredictable UI states.

Luckily, Kotlin gives you a better way to handle states.

Simplifying States With Sealed Classes

Sealed classes can eliminate the problems associated with the old way of managing state.

Modeling States With Sealed Classes

With sealed classes, you think about all the possible states before you start to code. You then keep the end result in mind when you start to code.

In this example, you need to follow these states:

  • Success with a list of characters.
  • Invalid data – no characters found.
  • Generic error state.
  • Network exceptions – errors caused by network failure.
  • HTTP errors that represent HTTP error codes. There can be more than one.

So with these states in mind, navigate to data ▸ states. Right-click and create a new Kotlin class named NetworkState. Then add the following code:

sealed class NetworkState {
  data class Success(val data : CharactersResponseModel) : NetworkState()
  object InvalidData : NetworkState()
  data class Error(val error : String) : NetworkState()
  data class NetworkException(val error : String) : NetworkState()
  sealed class HttpErrors : NetworkState() {
    data class ResourceForbidden(val exception: String) : HttpErrors()
    data class ResourceNotFound(val exception: String) : HttpErrors()
    data class InternalServerError(val exception: String) : HttpErrors()
    data class BadGateWay(val exception: String) : HttpErrors()
    data class ResourceRemoved(val exception: String) : HttpErrors()
    data class RemovedResourceFound(val exception: String) : HttpErrors()
  }
}

The types in the sealed class are data classes, objects and another sealed class representing the HTTP error states.

Now that you have all the states ready, you’re ready to apply the states to your network call.

Applying the States to Your App

Navigate to ui ▸ views ▸ fragments ▸ sealedclassway. Open StateCharactersFragment.kt Replace getCharacters with the following:

private fun getCharacters() {
  lifecycleScope.launchWhenStarted {
    showRefreshDialog()
    val charactersResult = fetchCharacters()
    handleCharactersResult(charactersResult)
  }
}

Here you’re setting up the Fragment to show it’s in a loading state, via showRefreshDialog, whilst the app makes a network request through fetchCharacters(). You handle the result of the request through handleCharactersResult.

Next, you need to write the fetchCharacters method to request the characters from the API. Update the fetchCharacters like so:

private suspend fun fetchCharacters() : NetworkState {
    return try {
      val response = apiService.getCharacters()
      if (response.isSuccessful) {
        if (response != null) {
          NetworkState.Success(response.body()!!)
        } else {
          NetworkState.InvalidData
        }
      } else {
        when(response.code()) {
          403 -> NetworkState.HttpErrors.ResourceForbidden(response.message())
          404 -> NetworkState.HttpErrors.ResourceNotFound(response.message())
          500 -> NetworkState.HttpErrors.InternalServerError(response.message())
          502 -> NetworkState.HttpErrors.BadGateWay(response.message())
          301 -> NetworkState.HttpErrors.ResourceRemoved(response.message())
          302 -> NetworkState.HttpErrors.RemovedResourceFound(response.message())
          else -> NetworkState.Error(response.message())
        }
      }

    } catch (error : IOException) {
      NetworkState.NetworkException(error.message!!)
    }
}

After adding this, make sure to import the NetworkState and IOException classes.

There are a few differences here from the classical approach. The function has a suspend keyword, which means it can pause and resume later when you have a response from the server.

All the condition checks are the same as before, but now instead of handling the results, you are assigning variables to NetworkState depending on the response.

For a success response, you check if the response is null or not. If it’s not, you set the state as NetworkState.Success(response.body()!!).

Notice the success state takes the response body and sets the state to NetworkState.InvalidData if its null. If the response is not successful, you handle the error along with the HTTP errors.

For HTTP errors, you set the state to NetworkState.HttpErrors, depending on the error code. For normal errors, you set the state to NetworkState.Error(response.message()). In the catch block, you set the state to NetworkState.NetworkException(error.message!!).

Notice all these states have variables that can have more than one value, which is one of the advantages of sealed states.

Also, this function only deals with fetching data and updating the states, it contains no business logic or UI logic. This helps the method be focused on doing one thing and makes the code much more readable.

To display the results, add the following function to the fragment just below fetchCharacters():

private fun handleCharactersResult(networkState: NetworkState) {
    return when(networkState) {
      is NetworkState.Success -> showCharacters(networkState.data)
      is NetworkState.HttpErrors.ResourceForbidden -> handleError(networkState.exception)
      is NetworkState.HttpErrors.ResourceNotFound -> handleError(networkState.exception)
      is NetworkState.HttpErrors.InternalServerError -> handleError(networkState.exception)
      is NetworkState.HttpErrors.BadGateWay -> handleError(networkState.exception)
      is NetworkState.HttpErrors.ResourceRemoved -> handleError(networkState.exception)
      is NetworkState.HttpErrors.RemovedResourceFound -> handleError(networkState.exception)
      is NetworkState.InvalidData -> showEmptyView()
      is NetworkState.Error -> handleError(networkState.error)
      is NetworkState.NetworkException -> handleError(networkState.error)
    }
}

This function takes NetworkState as an argument and uses a when expression as a return statement to give the exhaustive advantage. Since each state exists independent of the others, you handle all the possible states.

Displaying the Character Details

Now you’ve converted the network call code to use sealed classes, let’s hook up the UI. Since fetchCharacters is a suspend function call, we’ll need to wrap calls to it in a coroutine. In getCharacters wrap the calls to getCharacters() in lifecycle scope coroutine like so:

   
lifecycleScope.launchWhenStarted {
  hideEmptyView()
  showRefreshDialog()
  val charactersResult = getCharacters()
  handleCharactersResult(charactersResult)
}

Also in onViewCreated, add a lifecycleScope within the refresh listener:

swipeContainer.setOnRefreshListener {
  lifecycleScope.launchWhenStarted {
    getCharacters()
  }
}

Also add the lifecycle import when prompted.

Here, you call showRefreshDialog() to show the refresh dialog whilst the app fetches the characters from the api. characterResult calls getCharacters() to make a network request to fetch the characters, then the next line calls handleCharactersResult(charactersResult) with the characterResult variable.

Finally, we need to hook up our UI to use the new Fragment, as it’ll continue to use CharactersFragment instead of our shiny new StateCharactersFragment. Navigate to res ▸ navigation and open main_nav_graph.xml.

Showing the navigation graph

Choose the Text tab and change startDestination at the navigation tags with stateCharactersFragment.

<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main_nav_graph"
    app:startDestination="@id/stateCharactersFragment">

Now, build and run.

Behold, the app runs well, all states properly managed. The character list displays properly:

App displaying Rick and Morty characters

The character detail screen works, as well:

Character Details

Congratulations! You’ve completed your app. In the process, you learned about the features and advantages of sealed classes and how to use them in Android to tame states.

Where to Go From Here?

You can download the begin and end projects by using the Download Materials button at the top or bottom of the tutorial.

There’s much you can do with sealed classes in state management. If you want to dive deeper into the subject, you can read our Kotlin Sealed Classes tutorial.

This tutorial touched on several other concepts that were outside the scope of this article.

To learn more about coroutines in Kotlin check out our Kotlin Coroutines Tutorial for Android: Getting Started tutorial.

You can also take a deeper look at how to create clean architecture for your apps in our Clean Architecture for Android tutorial.

We hope you enjoyed this Beyond the Basics with Sealed Classes tutorial. If you have any questions, comments or awesome modifications to this project app, please join the forum discussion below.

Average Rating

4.4/5

Add a rating for this content

9 ratings

More like this

Contributors

Comments