Skip to main content

Flows in SwiftUI (preview)

SKIE has a preview support for observing Flows in SwiftUI views. When enabled, SKIE will include two APIs for observing Flows in SwiftUI. Each has its own uses, and we'd like your feedback on both.

The first one is a view modifier collect, which you can use to collect a flow inside a view. Either into a @State property directly using a SwiftUI Binding, or through a provided async closure.

The other one is a SwiftUI view Observing, which you can use to collect one or multiple flows and display a view based on the latest values. This is similar to the builtin SwiftUI view ForEach. Where ForEach takes a synchronous sequence (e.g. an array), Observing takes an asynchronous sequence (e.g. a Flow, a SharedFlow, or a StateFlow).

Both of these can be enabled by adding the following to your build.gradle.kts:

skie {
features {
enableSwiftUIObservingPreview = true
}
}

Let's consider the following Kotlin view model which we'll want to interact with from SwiftUI.

class SharedViewModel {

val counter = flow<Int> {
var counter = 0
while (true) {
emit(counter++)
delay(1.seconds)
}
}

val toggle = MutableStateFlow<Boolean>(false)
}

Then we can use the SKIE-included helpers to interact with the SharedViewModel from SwiftUI. In the samples below, we'll use all of them as an example, not a real-world scenario.

Observing SwiftUI view

For most scenarios, you'll want to use the Observing SwiftUI view. It takes one or more Flows and a closure to create the view based on the latest values. That means you don't need a @State property to store the latest value.

The Observing view has two variants. One that requires each passed flow to be a StateFlow or have an initial value provided. It's the simplest way to observe one or more StateFlows. In the example below, we're observing both the counter Flow and toggle StateFlow. Notice that we need to provide an initial value for the counter Flow using the withInitialValue method.

struct ExampleView: View {
let viewModel = SharedViewModel()

var body: some View {
// Observing multiple flows with attached initial values, only requiring a single view closure for content.
Observing(viewModel.counter.withInitialValue(0), viewModel.toggle) { counter, toggle in
Text("Counter: \(counter), Toggle: \(toggle)")
}
}
}

The other variant requires an initial content view closure, but can observe any type of Flow. It's useful when it doesn't make sense to provide an initial value for the flow. In the example below we're observing both the counter Flow and toggle StateFlow. Until counter produces a value, we'll show a ProgressView, and then we'll replace it with a Text view showing the latest values.

struct ExampleView: View {
let viewModel = SharedViewModel()

var body: some View {
// Observing multiple flows with a "initial content" view closure.
Observing(viewModel.counter, viewModel.toggle) {
ProgressView("Waiting for counters to flows to produce a first value")
} content: { counter, toggle in
Text("Counter: \(counter), Toggle: \(toggle)")
}
}
}

collect view modifier

When you need more control over how a Flow is collected, you can use the collect view modifier. It allows collecting any Flow into a @State property using a SwiftUI Binding, or through a provided async closure. The async closure is useful when you need to do extra work for each value emitted by the Flow.

struct ExampleView: View {
let viewModel = SharedViewModel()

@State
var boundCounter: KotlinInt = 0

@State
var manuallyUpdatedCounter: Int = 0

var body: some View {
// Collecting a flow into a SwiftUI @State property using a Binding, which requires the property to be of the same type.
Text("Bound counter using Binding: \(boundCounter)")
.collect(flow: viewModel.counter, into: $counter)

Text("Manually updated counter: \(manuallyUpdatedCounter)")
.collect(flow: viewModel.counter) { latestValue in
manuallyUpdatedCounter = latestValue.intValue
}
}
}