The guide
Compose
The same screens in Kotlin: where the marker stands, what differs in spelling, and the Gradle setup.
The Kotlin runtime is the same machine written for its platform: the same compiler, the same plans, the same fixtures under spec/, in Kotlin's words. The figures on this site show the Compose spelling beside the SwiftUI one; this page is what differs, and the setup.
Where the marker stands#
In Swift the marker is a macro on a stored property; in Kotlin it is an annotation on the composable that renders, holds or acts. A fragment's composable takes the lens as a parameter; a query's composable resolves the operation value with rememberQuery; a mutation's takes its action from rememberMutation. The markers repeat, so one composable may carry a fragment and two mutations.
StarButton.kt
package app
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import baton.Fragment
import baton.Mutation
import baton.Phase
import baton.Query
import baton.rememberMutation
import baton.rememberQuery
import kotlinx.coroutines.launch
@Fragment("""
fragment StarButton_repository on Repository {
id
viewerHasStarred
stargazerCount
}
""")
@Mutation($$"""
mutation StarButtonAddStar($input: AddStarInput!) {
addStar(input: $input) {
starrable { id viewerHasStarred ... on Repository { stargazerCount } }
}
}
""")
@Mutation($$"""
mutation StarButtonRemoveStar($input: RemoveStarInput!) {
removeStar(input: $input) {
starrable { id viewerHasStarred ... on Repository { stargazerCount } }
}
}
""")
@Composable
fun StarButton(repository: StarButton_repository) {
val addStar = rememberMutation(StarButtonAddStar)
val removeStar = rememberMutation(StarButtonRemoveStar)
val scope = rememberCoroutineScope()
val starred = repository.viewerHasStarred
OutlinedButton(
onClick = {
scope.launch {
val id = repository.id
val count = repository.stargazerCount
if (starred) {
removeStar(
input = RemoveStarInput(starrableId = id),
optimistic = StarButtonRemoveStar.OptimisticResponse(
StarButtonRemoveStar.OptimisticResponse.RemoveStar(
StarButtonRemoveStar.OptimisticResponse.RemoveStar.Starrable(
__typename = "Repository", id = id, viewerHasStarred = false, stargazerCount = count - 1,
),
),
),
)
} else {
addStar(
input = AddStarInput(starrableId = id),
optimistic = StarButtonAddStar.OptimisticResponse(
StarButtonAddStar.OptimisticResponse.AddStar(
StarButtonAddStar.OptimisticResponse.AddStar.Starrable(
__typename = "Repository", id = id, viewerHasStarred = true, stargazerCount = count + 1,
),
),
),
)
}
}
},
enabled = !addStar.isInFlight && !removeStar.isInFlight,
) {
Text(if (starred) "★ ${repository.stargazerCount}" else "☆ ${repository.stargazerCount}")
}
}
@Query($$"""
query RepositoryQuery($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
nameWithOwner
...StarButton_repository
}
}
""")
@Composable
fun RepositoryScreen(owner: String, name: String) {
val query = rememberQuery(RepositoryQuery(owner = owner, name = name))
val phase = query.phase
if (phase is Phase.Ready) phase.data.repository?.let { repo ->
Text(repo.nameWithOwner)
StarButton(repo.starButton)
}
}A document with a variable is written in a $$"""…""" raw string, because $ starts a template in a plain one and GraphQL's variables need it; a document without a variable may be a plain string. The compiler reads the string dedented, so it equals the .graphql file an author would write.
The same words, Kotlin's shapes#
| Swift | Kotlin | |
|---|---|---|
| The document | @Fragment("""…""") on a stored property | @Fragment($$"""…""") on the composable |
| A query | @Query(…) var issue: IssueQuery, whose value the parent passes | val issue = rememberQuery(IssueQuery(id = id)) |
| A mutation | @Mutation(…) var addComment: CommentComposerAddComment.Action | val addComment = rememberMutation(CommentComposerAddComment) |
| Calling it | try await addComment(input: …, connections: [id], optimistic: …) | addComment(input = …, connections = listOf(id), optimistic = …) in a coroutine |
| A subscription | @Subscription(…) var noteAdded: NoteAdded | rememberSubscription(NoteAdded(…)) |
| The phase | .loading, .ready(data), .failed(error) | Phase.Loading, Phase.Ready(data), Phase.Failed(error) |
@catch | A Result<T, FieldErrors> | A kotlin.Result<T> whose failure is a FieldErrors |
@required(action: THROW) | A get throws accessor | A getter that throws RequiredFieldError |
| A plural link | Baton.List, a collection of lenses identifiable by record | LensList, a List of lenses; key items by recordID.key |
| The next page | try await issues.loadNext() in a Task | issues.loadNext() in a coroutine |
| A retention | Retention, released with its holder | Hold, released by release(), since Kotlin has no deinit |
| An unknown enum value | .unknown(String) | Undeclared(scalarText) |
| Observation | Observation, through the record's registrar | Compose snapshot state, one channel per slot read |
| The store's thread | The main actor | The thread that made the store; the environment's main dispatcher |
Everything else is the same by construction: the same operation text, the same records, the same notifications, the same phases, proven by the same fixtures run through code the compiler generates for Kotlin.
IssuesScreen.kt
package app
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import baton.Phase
import baton.Query
import baton.rememberQuery
@Query($$"""
query IssuesQuery($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
nameWithOwner
issues(first: 20, states: OPEN) {
nodes { id ...IssueRow_issue }
}
}
}
""")
@Composable
fun IssuesScreen(owner: String, name: String) {
val query = rememberQuery(
IssuesQuery(owner = owner, name = name),
)
when (val phase = query.phase) {
is Phase.Ready -> phase.data.repository?.let { repo ->
LazyColumn {
item { Text(repo.nameWithOwner) }
items(repo.issues.nodes.orEmpty()) {
IssueRow(it.issueRow)
}
}
}
Phase.Loading ->
CircularProgressIndicator()
is Phase.Failed -> ErrorView(phase.error) {
query.retry()
}
}
}rememberQuery holds the retention while the composable stays and releases it when the composable leaves.The environment in a composition#
// From the GitHub sample: Exchange is the sample's own wrapper, under
// kotlin/samples/exchange; on Android the image also takes a directory.
val environment = remember(token) {
Environment(
Exchange(HttpTransport("https://api.github.com/graphql", credentials = { mapOf("Authorization" to "Bearer $token") })),
store = Store(persistence = Persistence.named("GitHubTriage", version = Types.schemaDigest)),
)
}
CompositionLocalProvider(LocalBaton provides environment) { Triage() }The store belongs to the thread that made it and its entry points check the caller's; in a composition that is the composition's dispatcher. A test outside a composition passes one test dispatcher as both the main and the ingest dispatcher and runs under runTest.
Android#
- The runtime starts at API 23 and needs no R8 or ProGuard rules: no reflection, no serialization, no lookup by name.
- The runtime holds no
Context, so an image named is placed by the app:Persistence.named(name, directory = cacheDir.path). - The platform has no WebSocket client, so a subscription over a socket goes through
baton-okhttp'sOkHttpWebSocketClientor an app's own client. - The process lifecycle tells the environment its activity: the sample's
ActivationobservesProcessLifecycleOwner, setsisActiveand callsrevalidate()on the return. - An Android application module adds the generated directory through the variant API; Gradle has the lines.
The build#
The runtime is com.shergin.baton:baton on Maven Central, with baton-testing, baton-okhttp and baton-inspector beside it, and the Gradle plugin com.shergin.baton runs the compiler as a task with every input and output declared, fetching the release's prebuilt binary on first use. Gradle is the page; the Kotlin runtime is the tree's own README, with the layout, the dependencies and how it is built and published.
Without Compose UI#
A view model behind Android Views, a desktop window, a service: each asks the environment for the handle, holds the retention, and reads what it shows through snapshotFlow, which emits again when a field it read changed. Views and view models is the recipe, with the code the runtime's tests compile.
Baton