Table of Contents
Also, Jetpack Compose for SwiftUI developers
Over the past year I’ve been playing around a lot of multiplatform toolkits, primarily Kotlin Multiplatform, which has allowed me to explore writing mobile UIs on platforms other than Android. I’ve often found myself knowing how to model or implement something in Jetpack Compose, but struggling to quickly translate it to other UI toolkits. This blog series has been written as a self-reference for exactly that, hopefully it is useful for others too!
As I’ve been an Android developer for many years, these blog posts will naturally bias towards being a reference for Compose developers to write SwiftUI. However I have tried not to assume any knowledge, so hopefully they will be just as useful for SwiftUI developers learning Compose UI too. Let me know if not!
The series will cover some of the core concepts in declarative and reactive UI programming, and how they are implemented in both Compose UI and SwiftUI, such as:
- Units of UI
- State
- Side-effects
- Building components
- Controlling UI
- Animations
Each section will introduce the equivalent concepts in each toolkit, with links to further reading material if you want to go deeper. If there’s something fundamental which you think is missing, please let me know!
In this first post, we’ll be covering the more fundamental concepts of state handling, which is probably the most important thing to know when starting to use a reactive UI framework.
Background#
SwiftUI and Compose UI are both modern UI toolkits designed to make UI development more straightforward, effective, and enjoyable. They both model UIs declaratively, allowing developers to ‘describe’ the UI as a transform of state, with the underlying system responsible for reacting to state changes and updating the UI.
SwiftUI can be used on iOS, MacOS, WatchOS, and TvOS, using the Swift programming language. SwiftUI is baked into the OS, meaning that the functionality which is available to us as developers changes based on what device it is running on.
Compose’s story is a little more complex. The Android team at Google created Jetpack Compose, and released the first version in 2020. Jetpack Compose can be used for apps on all Android devices (phones, foldables, tablets, watches, TVs), Chrome OS, as well as integrations into Android such as notifications. Jetpack Compose is unbundled, meaning that developers bundle all of the necessary bits into their apps. This enables developers to be (reasonably) sure that any thing which runs on the latest version of Android should work on old versions too.
Now comes the complex bit: JetBrains, the creators of Kotlin, soft-forked Jetpack Compose to create Compose Multiplatform. This ‘fork’ adds in support for extra platforms, such as iOS, Desktop (JVM) and Web, building on top of Kotlin Multiplatform. Since both Jetpack Compose and Compose Multiplatform are practically the same thing (in terms of programming model and APIs) I’ll refer to them collectively as Compose UI for the rest of this blog post.
So let’s get started with the concepts.
Units of UI#
The first thing we’ll look at it is, what are the basic buildings blocks of creating UI? SwiftUI and Compose UI actually differ here quite a bit, so we’ll just cover the basics.
Compose UI#
In Compose UI, the basic building block of creating UI is a ‘composable’ function:
@Composable // this annotation marks the function as composable
fun MyFancyUi()
// Text() is a function too!
Text("Hi everyone!")
A ‘composable’ function is one which the underlying system will automatically observe state which the function reads (more on state later). When an observed state changes, the system re-invokes the function to reflect the new state values. This is the basic mechanism of what Compose calls ‘recomposition’: the function is ‘recomposed’ (re-invoked).
Since this is a function, we don’t have an obvious place to be able to record values and reuse them across function calls. That’s where memoization comes into play. Compose has built-in memoization through its remember
functions, allowing you to remember values across function calls. You can even key them if required.
@Composable
fun MyFancyUi()
val id = remember randomId()
Text("Hi user-$id!")
In effect, this means that the first time the function is called, a random id will be stored. After that, each MyFancyUi
call will use the same id value.
It’s actually a little more complicated than that, as the Compose runtime will compare the call stack to disambiguate MyFancyUi
calls from different parts of your UI. This is beyond the scope of this blog post though. For more information on this, the Compose Runtime, Demystified talk by Leland Richardson is a great starting point.
SwiftUI#
In SwiftUI, the basic unit of UI is a View, which is a protocol (interface) which you implement in a struct. The protocol contains a single property: your body
content:
struct MyFancyView: View
var body: some View
Text("Hi everyone!")
Since our views are types, rather than functions, we need to create instances of our views, and therefore have an obvious place to store any values. This means that SwiftUI does not need a direct equivalent to Compose’s remember functions, as you can store them in the view instead:
struct MyFancyUi: View
private var id: Int = Int.random(in: 0..<1000)
var body: some View
Text("Hi user-\(id)!")
State#
State is fundamental to a reactive and declarative UI toolkit, as we mentioned earlier, the resulting UI is the result of a function over state. This section is the majority of this blog post, and where I spent the longest when learning SwiftUI.
Creating state#
The first thing we will look at is how to create state.
Compose UI#
In Compose UI, we have the State and MutableState interface, which are what you will use 99% of the time. There are builder functions available, allowing you to create a mutable value holder. The common one you’ll use is mutableStateOf
.
Any (Mutable)State instances read in a composable function are automatically observed, so that the function can be automatically re-invoked when the state changes. Since we’re using a holder around our value, we need to make sure that we use the same instance across function calls. This is where remember
is needed again:
@Composable
fun MyFancyUi()
// We remember the same MutableState instance across calls
val clickCount = remember mutableStateOf(0)
// We'll increment clickCount's value on each click (aka tap)
// This will trigger a recomposition...
Surface(onClick = clickCount.value = clickCount.value + 1 )
// ...and display the new clickCount value
Text("Click count: $clickCount.value")
There are quite a few different mutable state factory functions in Compose, for primitive types (Int
, Float
, Long
, etc), maps, and lists, therefore use the one which makes most sense.
For those unfamiliar with Kotlin syntax, there’s also a shortcut to referencing and updating State
values, using property delegates:
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
// 👆 These are property delegates imports, allowing us to
// unwrap State instances automatically
Composable
fun MyFancyUi()
// Note: the `by` keyword
val clickCount by remember mutableStateOf(0)
// We can now reference `clickCount` directly as a value
Surface(onClick = clickCount = clickCount + 1 )
Text("Click count: $clickCount")
In terms of what runs on devices, the two examples above are bit identical, with property delegates being the most commonly used (less code 😅). Use whichever you feel more comfortable with though.
SwiftUI#
In SwiftUI, state is created through Swift property wrappers which are bundled in SwiftUI. Which you use depends on what sort of data you’re storing.
In Kotlin, everything is either a primitive (Int, etc), or a reference type. In Swift however, you have the choice of using a value type (structs), or reference type (classes). We won’t delve too deep into this topic in this blog post, but the difference is key when we think about detecting changes.
The simplest SwiftUI property wrapper is @State
, which is primarily used with value types. Whenever the values changes, SwiftUI will trigger a rebuild:
struct MyFancyUi: View
@State private var clickCount: Int = 0
var body: some View
// We'll increment clickCount on each tap (aka click)
Text("Click count: \(clickCount)")
.onTapGesture
self.clickCount += 1
We can also encapsulate clickCount
in a struct, and it would continue to work:
struct MyFancyUi: View
@State private var state: MyFancyState = .init()
var body: some View
// We'll increment clickCount on each tap (aka click)
Text("Click count: \(state.clickCount)")
.onTapGesture
state.clickCount += 1
struct MyFancyState
var clickCount: Int = 0
So we know that @State
will work for value types, but what about reference types? If we change the example above to be a class MyFancyState
rather than a struct, we will no longer see any rebuilds. Incrementing clickCount
in a class will not result in a new MyFancyState
, therefore the @State
’s value remains the same, and no rebuild.
So how can we trigger rebuilds from class value changes? We can make our classes implement the ObservableObject
protocol, and use @Published
on any observable properties:
class MyFancyState: ObservableObject
@Published var clickCount: Int = 0
On the calling side, we need to swap out the @State
property wrapper for an @StateObject
:
class MyFancyState: ObservableObject
@Published var clickCount: Int = 0
struct MyFancyUi: View
// Use @StateObject instead
@StateObject private var state: MyFancyState = .init()
var body: some View
// same as above
This will ensure that our MyFancyState
object is created once during the lifetime of the view which created it (even if the struct is recreated). This achieves a similar memoization to Compose’s remember
.
So far we’ve looked at state created within the view, but what if the object is created externally? Let’s add a child view to our example to explore that:
class MyFancyState: ObservableObject
@Published var clickCount: Int = 0
struct MyFancyUi: View
// This view is creating the object, so we use @StateObject to scope the
// lifetime to this view
@StateObject private var state: MyFancyState = .init()
var body: some View
MyFancyUiChild(state: state)
struct MyFancyUiChild: View
// Use @ObservedObject to observe an externally created ObservableObject
@ObservedObject var state: MyFancyState
var body: some View
MyFancyUi
Further reading#
Binding state#
In the examples above, we have relied on lambdas/closures to know when values have changed, but both platforms use different patterns for more complex state changes.
SwiftUI#
Let’s switch up our example and allow the user to input some text. In SwiftUI we can use the TextField
view:
struct MyTextInput: View
@State private var name = ""
var body: some View
Form
TextField("Enter your name", text: /* todo */)
Text("Hi \(name)!")
We’re using a @State
string to store our name, along with a TextField
to gather user input, and a Text
to display whatever the user inputs. How do we receive the input changes from the text field?
If we look at the TextField
docs, the text
parameter has a type of Binding<String>
, what is that? 🤔. SwiftUI uses bindings extensively, as the primary way to support two-way binding to some other source of truth.
Going back to our example, we could create our own binding which just gets/sets our name
state like so:
struct MyTextInput: View
@State private var name = ""
var body: some View
Form
TextField(
"Enter your name",
text: Binding // get
name
set: value in
name = value
)
Text("Hi \(name)!")
Luckily SwiftUI makes this easy, as property wrappers can be projected as Binding
s using a special syntax: prefixing the property name with a $:
struct MyTextInput: View
@State private var name = ""
var body: some View
Form
TextField(
"Enter your name",
// Note the $ before the property name.
// The compiler generates a binding for us and we're using it
text: $name
)
Text("Hi \(name)!")
Compose#
In Compose, there isn’t a specific way to bind-data like in SwiftUI. Compose leans heavily into Kotlin language features, rather than creating its own constructs, to allow developers to bind state in a toolkit-agnostic way(ish).
If we go back to the text field example above, in Compose UI we can do something like this:
@Composable
fun MyTextInput()
// A mutable state to store the current value
var name by remember mutableStateOf("")
Column
TextField(
// Pass in the current value
value = name,
// Update our name state when the user inputs some text
onValueChange = name = it ,
label =
Text("Enter your name")
,
)
Text("Hi $name!")
TextField in Compose UI (actually in Material3) is a good example of state hoisting (more on this later), relying on fundamental language constructs, value passing and lambda callbacks, to know when a value changes. This relies heavily on the Compose runtime recomposing, so that state changes are reflected throughout the hierarchy.
Since we are using Kotlin property delegates, we can even mimic SwiftUI bindings by de-structuring the property, to the current value and setter like so:
@Composable
fun MyTextInput()
// name = current value, setName = (String) -> Unit lambda
val (name, setName) = remember mutableStateOf("")
Column
TextField(
// We can then pass in the destructured delegates:
value = name,
onValueChange = setName,
)
Whether you use this pattern is a question of taste and style. I’ve not seen this pattern used so much in real-world Compose development, but it is there if you prefer it.
Further reading#
Lifting state up (state hoisting)#
Lifting state up (aka state hoisting) is one of the most common things you’ll do when using a reactive UI framework. It’s a term which has been around since React (web) gained adoption (possibly before?).
If you’d like to know more, the React docs have a detailed page on sharing state:
Sharing State Between Components – React
The library for web and native user interfaces
react.dev
I personally like the description from the Jetpack Compose Basics codelab:
State that is read or modified by multiple functions should live in a common ancestor—this process is called state hoisting. Making state hoistable avoids duplicating state and introducing bugs, helps reuse [UI], and makes [UI] substantially easier to test.
As both SwiftUI and Compose UI are reactive frameworks, you will find yourself needing to regularly hoist state when using either of them.
SwiftUI#
As we learned earlier, state in SwiftUI is created using one of the SwiftUI property wrappers on a property. This means that we can’t pass around a State
instance, as the wrapper is hidden from us.
We’ve already looked at how SwiftUI supports state hoisting: Binding
, but so far we’ve only looked at binding child views to our state. How do we create our own components and allow them to be expect incoming state? The @Binding
property wrapper.
The @Binding
property wrapper is another property wrapper provided by SwiftUI, and this one basically says: ‘defer reading and writing to some other source of truth’.
Let’s use update our text field sample so that the TextField
is extracted to a reusable view:
struct MyTextInput: View
@State private var name = ""
var body: some View
Form
FancyTextField(input: $name)
Text("Hi \(name)!")
struct FancyTextField: View
// We use the Binding property wrapper allowing parents
// to bind their own state (or bindings)
@Binding var input: String
var body: some View
TextField(
"Enter your name",
// Same as before: we use a generated binding which delegates
// to our @Binding
text: $input
)
It’s bindings all the way down in SwiftUI, so get ready to use them a lot.
Compose#
Similar to the ‘Binding State’ section above, Compose leans heavily in to language constructs, and hoisting state is no different. Typically when hoisting state in Compose, you will expose a value: Foo
parameter (current value), and a onValueChange: (Foo) -> Unit
lambda parameters as a callback for value changes. The caller is then responsible for either updating its state, or proxying the change up.
Let’s update our text field sample so that the TextField
is extracted to a reusable composable:
@Composable
fun MyTextInput()
var name by remember mutableStateOf("")
Column
FancyTextField(
input = name,
onValueChange = name = it
)
Text("Hi $name!")
/**
* Our FancyTextField composable exposes two parameters:
* - `input` is the current value
* - `onInputChange` a callback for when the value has changed
*/
@Composable
fun FancyTextField(input: String, onInputChange: (String) -> Unit)
TextField(
value = input,
onValueChange =
onInputChange(it)
,
label = Text("Enter your name")
)
By leaning into language constructs such as lambdas and value passing, this enables your composables to be decoupled, encapsulated and thus easily reusable. Doing this usually results in the composable being stateless, resulting in it being more reusable and easier to test. The Jetpack Compose State docs go deeper into the benefits here.
As we mentioned earlier, state in Compose is an object which you create through one of the provided factory functions (
mutableStateOf
, etc). This means that you could mimic SwiftUI’s bindings by passing around your MutableState<Foo>
instance around:
@Composable
fun MyTextInput()
var name = remember mutableStateOf("")
Column
FancyTextField(input = name)
Text("Hi $name!")
/**
* ⚠️ Don't do this. ⚠️
*/
@Composable
fun FancyTextField(input: MutableState<String>)
TextField(
value = input.value,
onValueChange =
// Update the provided MutableState directly
input.value = it
,
)
However, this is an anti-pattern in Compose UI, as outlined by the Compose UI API Guidelines:
When a component accepts
MutableState
as a parameter, it gains the ability to change it. This results in the split ownership of the state, and the usage side that owns the state now has no control over how and when it will be changed from within the component’s implementation.
Environmental state#
There are times when manually passing state or objects between different pieces of UI is either impractical or just repetitive. In these instances, both frameworks support the idea of creating ‘environmental’ or ‘global’ state, which can be referenced from anywhere within the UI hierarchy. Common examples of global state include: theming, external factories, formatters, etc.
The way Compose UI and SwiftUI handle these are actually very similar.
Compose#
In Compose, environmental state is stored in what is called CompositionLocals. The general idea is that you create a CompositionLocal
, which acts as a key within the internal composition state. You can reference the local at any time, and get the current value to use as required.
There are two factory functions for creating a composition local: compositionLocalOf and staticCompositionLocalOf. I’ll leave learning the difference between the two as an exercise for the reader, but for now just know that using them is exactly the same, the difference is primarily for performance. We’ll use compositionLocalOf
for the examples below, but we could use staticCompositionLocalOf
instead and it would work the same.
So let’s create a composition local:
val LocalFancyDateFormatter = compositionLocalOf
// This is the default value. It could also be null
FancyDateFormatter(...)
Here we are creating a composition local to store a FancyDateFormatter
instance, but how do we use it?
Let’s create a new example, this time allowing the user to select a date, and then display the date:
@Composable
fun MyDatePicker()
var date by remember mutableStateOf<LocalDate?>(null)
Column
DatePicker(
date = date,
onDateChange = date = it
)
// Here's we displaying the date using it's toString().
// This works, but isn't particularly readable. We should
// format the date in some way 🤔
Text("Selected date: $date")
As we now have our FancyDateFormatter
available, we can use that in our composables:
@Composable
fun MyDatePicker()
var date by remember mutableStateOf<LocalDate?>(null)
Column
// DatePicker ommitted...
// Grab the current FancyDateFormatter from the
// composition local. From here, we can just use it
// as normal...
val formatter = LocalFancyDateFormatter.current
Text("Selected date: $formatter.format(date)")
So far we’ve only looked at using the default value of a CompositionLocal
, but you can also override the value so that any callers from the subtree get that value instead. To do that, you use the CompositionLocalProvider
function, providing the new value to it.
It’s easier to show this with a contrived example:
val LocalCount = compositionLocalOf 0
@Composable
fun A()
CountText() // This will display 0
// Here we override LocalCount to 1. Any composables from within
// the block (recursively) will see LocalCount.current return 1.
CompositionLocalProvider(LocalCount provides 1)
B()
@Composable
fun B()
// This will display 1, as we're being called within the
// CompositionLocalProvider above
CountText()
@Composable
private fun CountText()
// Just displays the current LocalCount value
Text("$LocalCount.current")
SwiftUI#
In SwiftUI, environmental state is stored in what is called EnvironmentValues
. They work very similar to CompositionLocal
in Compose, except that the key and holder are separated.
So let’s create some environment state to store a date formatter:
struct FancyDateFormatterKey: EnvironmentKey
static let defaultValue: FancyDateFormatter = FancyDateFormatter(...)
extension EnvironmentValues
var fancyDateFormatter: FancyDateFormatter
get self[FancyDateFormatterKey.self]
set self[FancyDateFormatterKey.self] = newValue
Let’s create a new example, this time allowing the user to select a date, and then display the date:
struct MyDatePicker: View
@State private var date: Date?
var body: some View
VStack
DatePicker("Select a date", selection: $date)
// Here's we displaying the date using it's string representation.
// This works, but isn't particularly readable. We should
// format the date in some way 🤔
Text("Selected date: \(selectedDate)")
As we now have our FancyDateFormatter
available, we can use that in our view. To reference the the environmental value, we can use the @Environment
property wrapper, providing the necessary key:
struct MyDatePicker: View
@State private var date: Date?
// This will be set to the current fancyDateFormatter value
@Environment(\.fancyDateFormatter) private var fancyDateFormatter
var body: some View
VStack
DatePicker("Select a date", selection: $date)
// We can then use it as necessary
Text("Selected date: \(fancyDateFormatter.format(date))")
So far we’ve only looked at using the default value of an environmental value, but you can also override the value so that any views in the subtree get that value instead. To do that, you use the environment
view modifier, passing the new value to it:
It’s easier to show this with a contrived example:
struct CountKey: EnvironmentKey
static let defaultValue: Int = 0
extension EnvironmentValues
var count: Int
get self[CountKey.self]
set self[CountKey.self] = newValue
struct AView: View
var body: some View
VStack
// This will display count = 0, as CountText will reference the
// default value
CountText()
// Here we override the environment of BView, so that count = 1.
// BView and any descendant views will see the count value set to 1
BView()
.environment(\.count, 1)
struct BView: View
var body: some View
// This will display 1, as our environment contains a value
// of count = 1
CountText()
struct CountText: View
@Environment(\.count) private var count
var body: some View
// Display the current count value from the environment
Text("\(count)")
Side effects#
We’ve looked at state, but what happens when you need to react to a change originating from outside the UI? This is where side effects are useful.
Run asynchronous code#
There are times when you need to run some asynchronous code from your UI, and scope it to the appropriate lifecycle of that piece of UI. Both Compose UI and SwiftUI provide easy ways to run asynchronous code, appropriate for the underlying language features of the framework.
Compose#
As Compose UI is written in Kotlin, it makes heavy usage Kotlin Coroutines to support asynchronous work. There are two main mechanisms for launching coroutines from Compose UI: LaunchedEffect and rememberCoroutineScope.
LaunchedEffect#
Let’s start with the most commonly used: LaunchedEffect
. As the name suggests, this is a side-effect which is launched within a scoped coroutine. In this example we’re going to launch a coroutine to collect a Kotlin Flow (observable):
class MovieViewModel
val flow: Flow<MovieViewState>
@Composable
fun MoviesScreen(viewModel: MovieViewModel)
// Create a state to store our current view state
var viewState by remember mutableStateOf<MovieViewState?>(null)
// Launch a coroutine to collect the ViewModel's Flow
LaunchedEffect(viewModel)
viewModel.flow.collect
// Once we get a new ViewState, update our viewState
viewState = it
// rest of screen can use viewState
LaunchedEffect
supports automatic restarting via its key parameters. If any of the keys change after a recomposition, the existing coroutine is cancelled, and a new one started.
Related to LaunchedEffect
is the produceState
factory function. That uses LaunchedEffect
internally, to create a higher-level function enabling easier transforming of external state into Compose state.
rememberCoroutineScope#
The second mechanism is rememberCoroutineScope
. This is a lower level primitive in Compose UI, allowing you to manually launch your own coroutines, automatically scoped to the lifetime of the composition.
Typically you would use this when need to call suspending functions from synchronous callbacks. In this example we have a button which allows the user to refresh the UI:
class MovieViewModel
suspend fun refresh()
@Composable
fun MoviesScreen(viewModel: MovieViewModel)
// Creates a CoroutineScope bound to the MoviesScreen's lifecycle
val scope = rememberCoroutineScope()
Column
RefreshButton(
onClick =
// onClick is synchronous, but refresh() is suspending so we
// can't call it directly. Instead we launch a coroutine from
// our scope
scope.launch
viewModel.refresh()
)
// rest of screen
SwiftUI#
Very similar to Compose UI, SwiftUI also has similar concepts, using the async await system in Swift. There are two main mechanism in SwiftUI for executing async
functions:
Task.init#
When you need to invoke an async
function from synchronous code, you can create a new Task
and invoke the async function within it. In this example we have a button which allows the user to refresh the UI:
class MovieViewModel: ObservableObject
func refresh() async
// todo
struct MoviesScreen: View {
@StateObject private var viewModel = MovieViewModel()
var body: some View
VStack
RefreshButton
// this is the synchronous onClick. To invoke the async
// refresh function we need to run it in a Task. This task
// is attached to the outer task
Task
await viewModel.refresh()
// rest of screen
}
task view modifier#
So what if we need to run an async
function scoped to the lifecycle of the view? (which is nearly always the case). That is the where the task view modifier is useful:
class MovieViewModel: ObservableObject
let viewState: AsyncSequence<MovieViewState>
struct MoviesScreen: View
@StateObject private var viewModel = MovieViewModel()
@State private var viewState: MovieViewState
var body: some View
VStack
// rest of screen, referencing viewState
.task
// This Task will be scoped to the View being visible.
// As soon as the view is removed, the task will be cancelled
for await viewState in viewModel.viewState
self.viewState = viewState
@Published
properties from ViewModels. This in contrast to most Compose developers, who keep Compose primitives out of the ViewModel, using a Flow as the observable.Conclusion#
Hopefully this post has been useful in seeing how two reactive UI toolkits are both similar, but also different, in how they handle the most important thing in a reactive UI toolkit: state. If there’s anything useful you think is missing from this post, please let me know on socials and I’ll see what I can do in adding it.
This was the first post of two (maybe more). In the next post we’re take a look at the UI side of the frameworks, and how to actually get things measured, laid out and drawn onto screen.
Acknowledgements#
- 🙌 Thanks to Nacho López and Dai Williams for reviews.
- 🙌 Thanks to Landon Epps for the pointers about StateObject.
- 🦾 Thanks to ChatGPT for speeding up writing the equivalent samples (it’s scary how good it is at this).