September 23, 2020

Add GraphQL to Your Jetpack Compose Apps

Martin Bonnin

Martin Bonnin

Change log: Updated on 9/15/2022 to update to the latest Compose and Apollo Kotlin versions.

Introduction

Jetpack Compose is Android’s modern toolkit for building native UI. It simplifies and accelerates UI development on Android. Quickly bring your app to life with less code, powerful tools, and intuitive Kotlin APIs.

Apollo Kotlin is a strongly-typed, caching GraphQL client for the JVM, Android, and Kotlin multiplatform. The client will generate typesafe Kotlin models to hold data to be displayed. GraphQL and Apollo Kotlin and Jetpack Compose were destined to meet. 😄

In this post, we will show how to query a GraphQL endpoint with Apollo Kotlin and connect the response to a simple Compose UI. We will also explore how the Apollo normalized cache enables reactive scenarios that play nicely with Compose unidirectional data flow.

Getting Started

This post assumes some familiarity with Android Studio, Jetpack Compose, GraphQL and Apollo Kotlin. If you are new to any of these concepts please check out the following resources before getting started:

The code for this post is available in the compose branch of the Apollo Kotlin Tutorial. to pull this code down local open Android Studio’s terminal and run:

git clone git@github.com:apollographql/apollo-kotlin-tutorial.git

then run

git checkout compose

you can then follow the instructions for setting up a new project in Android Studio.

GraphQL

For our application data we’re going to use the full-stack tutorial GraphQL endpoint. It’s an endpoint that lists all the SpaceX missions as well as additional data concerning their launches. As a bonus feature, it also allows you to book a trip to space! You can explore and test the API using the Apollo Studio Explorer.

Let’s take a look at the different GraphQL queries and mutation schemas we need for our application. To learn more about GraphQL schemas refer to our Apollo Server documentation.

First we’ll need to query a list of launches to display in our UI, the LaunchList.graphql file contains the schema we need.

1app/src/main/graphql/com/example/rocketreserver/LaunchList.graphql23query LaunchList {4    launchConnection: launches {5        launches {6            id7            site8            isBooked9            mission {10                name11                missionPatch(size: SMALL)12            }13        }14    }15}

This query fetches the launches and other data of the missions: the mission name, mission logo patch and the mission booked state. From this query, Apollo Kotlin generates the following Kotlin type-safe models.

1app/build/generated/source/apollo/service/com/example/rocketreserver/LaunchListQuery.kt2 3 public data class Data(4    public val launchConnection: LaunchConnection,5  ) : Query.Data67  public data class LaunchConnection(8    public val launches: List<Launch?>,9  )1011  public data class Launch(12    public val id: String,13    public val site: String?,14    public val isBooked: Boolean,15    public val mission: Mission?,16    public val __typename: String,17  )1819  public data class Mission(20    public val name: String?,21    public val missionPatch: String?,22  )

We’ll then need to be able to update trips that are booked with a mutation. the BookTrip.graphql file contains the schema we need.

1app/src/main/graphql/com/example/rocketreserver/BookTrip.graphql23mutation BookTrip($id:ID!) {4  bookTrips(launchIds: [$id]) {5    success6    message7    launches {8      # The id is required here to lookup the correct entry in cache9      id10      # This will be written in cache and trigger an update in any watcher that is subscribed to this launch11      isBooked12    }13  }14}

This mutation will update the state of a trip to booked. From this mutation, Apollo Kotlin generates the following Kotlin type-safe models.

1app/src/main/graphql/com/example/rocketreserver/BookTrip.graphql23 public data class Data(4    public val bookTrips: BookTrips,5  ) : Mutation.Data67  public data class BookTrips(8    public val success: Boolean,9    public val message: String?,10    public val launches: List<Launch?>?,11  )1213  public data class Launch(14    public val id: String,15    public val isBooked: Boolean,16    public val __typename: String,17  )

Finally we’ll need one more GraphQL schema to get started on our Compose UI. We’ll need to be able to cancel trips that are previously booked with another mutation. the CancelTrip.graphql file contains the schema we need.

1app/src/main/graphql/com/example/rocketreserver/CancelTrip.graphql23mutation CancelTrip($id:ID!) {4  cancelTrip(launchId: $id) {5    success6    message7    launches {8      # The id is required here to lookup the correct entry in cache9      id10      # This will be written in cache and trigger an update in any watcher that is subscribed to this launch11      isBooked12    }13  }14}

This mutation will update the state of a trip to canceled. From this mutation, Apollo Kotlin generates the following Kotlin type-safe models.

1app/src/main/graphql/com/example/rocketreserver/CancelTrip.graphql23public data class Data(4    public val cancelTrip: CancelTrip,5  ) : Mutation.Data67  public data class CancelTrip(8    public val success: Boolean,9    public val message: String?,10    public val launches: List<Launch?>?,11  )1213  public data class Launch(14    public val id: String,15    public val isBooked: Boolean,16    public val __typename: String,17  )

The Compose UI

Now that we understand exactly what we need from GraphQL we can finally get to writing our Compose UI! Compose works by composing elementary functions, producing a tree of composable functions (Composables), renderable on demand.

Our approach will be to use 3 simple Composables for our UI:

  • A LaunchList that will render all the of the LaunchItems available.
  • A LaunchItem which will contain the actual trip data.
  • A BookButton that will let someone book or cancel a trip.

At the end the UI will look like this:

Reactive UI

Apollo Kotlin comes with a normalized cache. Cache normalization is a powerful concept; it minimizes data redundancy and because your normalized cache can de-duplicate your GraphQL data (using proper cache IDs), you can use the cache as the source of truth for populating your UI. When executing an operation, you can use the ApolloCall.watch method to automatically notify that operation whenever its associated cached data changes. You can learn more about cache normalization in this post that demystifies cache normalization. In the next section you can see that we are using the .watch method on the LaunchListQuery.

Build the Launch List

The LaunchList resides in the MainActivity.kt file. In this first section of code we will be creating a container rendering the results of the LaunchList query. The @Composable annotation indicates this is a composable function.

1app/src/main/java/com/example/rocketreserver/MainActivity.kt23@Composable4fun LaunchList() {56    val context = LocalContext.current  7    // tell Compose to remember the flow across recompositions8    val flow = remember {9        apolloClient(context).query(LaunchListQuery()).watch()10            .map {11                val launchList = it12                    .data13                    ?.launchConnection14                    ?.launches15                    ?.filterNotNull()16                if (launchList == null) {17                    // There were some error18                    // TODO: do something with response.errors19                    UiState.Error20                } else {21                    UiState.Success(launchList)22                }23            }24            .catch { e ->25                emit(UiState.Error)26            }27    }

In the 2nd part of the composable we want to watch the cache to react to changes. For this we can use Coroutines Flows and the Compose collectAsState helper function. You can add this code block to bottom of code reference above. To check your work, you can refer to the MainActivity.kt file for the final code.

1app/src/main/java/com/example/rocketreserver/MainActivity.kt23// collectAsState will turn our Flow into state that can be consumed by Composables4val state = flow.collectAsState(initial = UiState.Loading)56// Display thestate7    when (val value = state.value) {8        is UiState.Success -> LazyColumn(content = {9            items(value.launchList) {10                LaunchItem(it)11            }12        })13        else -> {}14    }15}

You can now execute a GraphQL query and get a typesafe Kotlin model to feed your Compose UI. With the Apollo Normalized Cache, you can have your Compose UI react and update automatically on mutations. We’ll see how that will work in the the BookTrip Button section below.

Populate the Launch Items

The LaunchItem resides in the MainActivity.kt file. In this 2nd section of code we will be creating a container for each trip. We’ll leverage the Jetpack Compose Row and Column to do this. The @Composable annotation indicates this is a composable function.

1app/src/main/java/com/example/rocketreserver/MainActivity.kt23@Composable4fun LaunchItem(launch: LaunchListQuery.Launch) {5    Row() {6        Column() {7            AsyncImage(8                modifier = Modifier9                    .width(100.dp)10                    .height(100.dp)11                    .padding(horizontal = 12.dp, vertical = 10.dp),12                model = launch.mission?.missionPatch, contentDescription = null,13                contentScale = ContentScale.Fit14            )15        }16        Column() {17            Text(18                text = launch.mission?.name ?: "",19                fontWeight = FontWeight.Bold,20                fontSize = 20.sp,21                style = MaterialTheme.typography.subtitle1,22                modifier = Modifier23                    .fillMaxWidth()24                    .padding(horizontal = 6.dp, vertical = 8.dp)25            )26            Text(27                text = launch.site ?: "",28                fontWeight = FontWeight.Light,29                fontSize = 20.sp,30                style = MaterialTheme.typography.subtitle2,31                modifier = Modifier32                    .fillMaxWidth()33                    .padding(horizontal = 12.dp, vertical = 12.dp)34            )35            BookButton(launch.id, launch.isBooked)36        }37    }38}

Build the Book Trip Button

Finally we have just one more thing to do!

The BookButton resides in the MainActivity.kt file. In this 3rd section of code we want to add a button to the LaunchItem Composable allow a user to either book or cancel a trip quickly. this button will trigger the mutations to update the isBooked state of a trip and then also update the cache. The @Composable annotation indicates this is a composable function.

1@Composable2fun BookButton(id: String, booked: Boolean) {3    val context = LocalContext.current4    Button(onClick = {56        GlobalScope.launch {7            withContext(Dispatchers.Main) {8                try {9                    val mutation = if (booked) {10                        CancelTripMutation(id)11                    } else {12                        BookTripMutation(id)13                    }14                    val response = apolloClient(context).mutation(mutation).execute()15                    if (response.hasErrors()) {16                        val text = response.errors!!.first().message17                        val duration = Toast.LENGTH_SHORT18                        val toast = Toast.makeText(context, text, duration)19                        toast.show()20                    }21                } catch (e: ApolloException) {22                    val text = e.message23                    val duration = Toast.LENGTH_SHORT24                    val toast = Toast.makeText(context, text, duration)25                    toast.show()26                }27            }28        }29    }) {30        Text(if (booked) "Cancel" else "Book")31    }32}

Wrapping it all up

At this point if things aren’t working for you, the final code for MainActivity should look like this:

1app/src/main/java/com/example/rocketreserver/MainActivity.kt23package com.example.rocketreserver45import android.os.Bundle6import android.widget.Toast7import androidx.activity.compose.setContent8import androidx.appcompat.app.AppCompatActivity9import androidx.compose.foundation.layout.*10import androidx.compose.foundation.lazy.LazyColumn11import androidx.compose.runtime.collectAsState12import androidx.compose.runtime.remember13import androidx.compose.ui.platform.LocalContext14import com.apollographql.apollo3.cache.normalized.watch15import kotlinx.coroutines.flow.catch16import kotlinx.coroutines.flow.map17import androidx.compose.foundation.lazy.items18import androidx.compose.material.Button19import androidx.compose.material.MaterialTheme20import androidx.compose.material.Text21import androidx.compose.ui.Modifier22import androidx.compose.ui.layout.ContentScale23import androidx.compose.ui.text.font.FontWeight24import androidx.compose.ui.unit.dp25import androidx.compose.ui.unit.sp26import coil.compose.AsyncImage27import com.apollographql.apollo3.exception.ApolloException28import kotlinx.coroutines.Dispatchers29import kotlinx.coroutines.GlobalScope30import kotlinx.coroutines.launch31import kotlinx.coroutines.withContext32import androidx.compose.runtime.Composable as Composable333435class MainActivity : AppCompatActivity() {3637    override fun onCreate(savedInstanceState: Bundle?) {38        super.onCreate(savedInstanceState)39        setContent { LaunchList() }40    }41}4243sealed class UiState {44    object Loading : UiState()45    object Error : UiState()46    class Success(val launchList: List<LaunchListQuery.Launch>) : UiState()47}4849@Composable50fun LaunchList() {5152    val context = LocalContext.current53    // tell Compose to remember our state across recompositions54    val state = remember {55        apolloClient(context).query(LaunchListQuery()).watch()56            .map {57                val launchList = it58                    .data59                    ?.launchConnection60                    ?.launches    61                    ?.filterNotNull()62                if (launchList == null) {63                    // There were some error64                    // TODO: do something with response.errors65                    UiState.Error66                } else {67                    UiState.Success(launchList)68                }69            }70            .catch { e ->71                emit(UiState.Error)72            }73    }74// collectAsState will turn our flow into State that can be consumed by Composables75        .collectAsState(initial = UiState.Loading)7677// Display the com.example.rocketreserver.UiState as usual78    when (val value = state.value) {79        is UiState.Success -> LazyColumn(content = {80            items(value.launchList) {81                LaunchItem(it)82            }83        })84        else -> {}85    }86}8788@Composable89fun BookButton(id: String, booked: Boolean) {90    val context = LocalContext.current91    Button(onClick = {9293        GlobalScope.launch {94            withContext(Dispatchers.Main) {95                try {96                    val mutation = if (booked) {97                        CancelTripMutation(id)98                    } else {99                        BookTripMutation(id)100                    }101                    val response = apolloClient(context).mutation(mutation).execute()102                    if (response.hasErrors()) {103                        val text = response.errors!!.first().message104                        val duration = Toast.LENGTH_SHORT105                        val toast = Toast.makeText(context, text, duration)106                        toast.show()107                    }108                } catch (e: ApolloException) {109                    val text = e.message110                    val duration = Toast.LENGTH_SHORT111                    val toast = Toast.makeText(context, text, duration)112                    toast.show()113                }114            }115        }116    }) {117        Text(if (booked) "Cancel" else "Book")118    }119}120121@Composable122fun LaunchItem(launch: LaunchListQuery.Launch) {123    Row() {124        Column() {125            AsyncImage(126                modifier = Modifier127                    .width(100.dp)128                    .height(100.dp)129                    .padding(horizontal = 12.dp, vertical = 10.dp),130                model = launch.mission?.missionPatch, contentDescription = null,131                contentScale = ContentScale.Fit132            )133        }134        Column() {135            Text(136                text = launch.mission?.name ?: "",137                fontWeight = FontWeight.Bold,138                fontSize = 20.sp,139                style = MaterialTheme.typography.subtitle1,140                modifier = Modifier141                    .fillMaxWidth()142                    .padding(horizontal = 6.dp, vertical = 8.dp)143            )144            Text(145                text = launch.site ?: "",146                fontWeight = FontWeight.Light,147                fontSize = 20.sp,148                style = MaterialTheme.typography.subtitle2,149                modifier = Modifier150                    .fillMaxWidth()151                    .padding(horizontal = 12.dp, vertical = 12.dp)152            )153            BookButton(launch.id, launch.isBooked)154        }155    }156}

What’s next!

Having a unidirectional data flow that reacts to cache changes makes it very easy to write robust UIs that always show the latest available data. I’m looking forward to seeing even more of what we can build with Compose! As Compose becomes more of a standard for Android Developers, we expect many UI best practices will start to emerge. For example, in this post, we made the queries from the Composables, but we can also accomplish this from ViewModels or Repositories; this would account for better testing and separation of concerns.

Finally, if you have ideas about how to best integrate your GraphQL queries with Jetpack Compose, please reach out on Github; we’re looking forward to making the experience as smooth as possible and planning better support of Jetpack Compose in the Apollo Kotlin client in the future.

Happy composing!

Written by

Martin Bonnin

Martin Bonnin

Read more by Martin Bonnin