· 6 min read Posted by Gustavo Fão Valvassori

Consuming SKIE Flows in SwiftUI

SKIE already makes the interop between Kotlin and Swift smooth, but SwiftUI adds a layer of complexity. We've included a series of extensions in SKIE to enable easy KMP usage from SwiftUI
Javier Allegue Barros - https://unsplash.com/pt-br/fotografias/silhueta-da-sinalizacao-viaria-durante-a-hora-dourada-C7B-ExXpOIE
Credit: Javier Allegue Barros - https://unsplash.com/pt-br/fotografias/silhueta-da-sinalizacao-viaria-durante-a-hora-dourada-C7B-ExXpOIE

One of SKIE’s most useful features is the ability to consume Kotlin Flows in Swift. However, when you want to consume it in a SwiftUI Component, you need to initiate a task, collect the flow, and assign it to a state. Even though this is a simple process, repeating it on every screen quickly adds a lot of boilerplate, making the UI layer harder to maintain.

To make life easier for developers, SKIE offers an optional feature that reduces boilerplate. In this article, we will show you how to enable and use it.

The Project

Before we chat about the SKIE APIs, we will build a quick sample app. To keep things simple, it will be just one screen showing two things:

  1. A square showing a random color;
  2. A button to change the color to another random value;

As we want to demo the SwiftUI integration, it will be a SwiftUI View:

struct ColorSample: View {
    let color: Color
    let onRandomize: () -> Void

    var body: some View {
        VStack(spacing: 32) {
            RoundedRectangle(cornerRadius: 16)
                .fill(color)
                .frame(width: 200, height: 200)

            Button("Random color") {
                onRandomize()
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
        .padding()
    }
}

Then, to control the state, we will create a simple “view model” in KMP with a StateFlow holding the color. This view model will be the same on all our samples and will not change after the initial implementation.

class RandomColorViewModel {

    private val _color = MutableStateFlow(UIColor.grayColor)
    val color: StateFlow<UIColor> = _color.asStateFlow()

    fun randomize() {
        _color.value = UIColor(
            red = Random.nextDouble(),
            green = Random.nextDouble(),
            blue = Random.nextDouble(),
            alpha = 1.0,
        )
    }
}

Here is a quick look at how it shows to the user:

Enabling the extensions

SKIE lets you enable features like the ones discussed in this post in your Gradle configuration. Inside the skie {} block in your Gradle file, you can choose which features are enabled and disabled. To access the SwiftUI APIs, you can enable the configuration as follows:

skie {
    features {
        enableSwiftUIObservingPreview = true
    }
}

Collecting the states

To collect the states, you have three main options:

  1. Collect the flow inside an async block
  2. Use the collect modifier
  3. Use the Observing view wrapper

Async Collect

If you already used SKIE, you probably know about the first option. When you compile your KMP Framework with SKIE, it automatically converts your flows into a Swift AsyncSequence. To hook that with SwiftUI, you can simply use it inside a SwiftUI .task {} modifier.

struct ContentView: View {
    let viewModel: RandomColorViewModel

    @State private var color = Color.gray

    var body: some View {
        ColorSample(color: color, onRandomize: viewModel.randomize)
            .task {
                do  {
                    for try await item in viewModel.color {
                        self.color = Color(item)
                    }
                } catch {}
            }
    }
}

collect modifier

The second layer of abstraction for StateFlows, provided by SKIE, is the collect modifier. This modifier takes a flow and calls a callback each time the value changes, abstracting the code from the Async collection. You can also apply it to the initial sample to simplify it.

// SKIE extension signature
extension View {
    public func collect<Flow : SkieSwiftFlowProtocol>(
        flow: Flow, 
        perform: @escaping (Flow.Element) async -> Void
    ) -> some View { /** ... **/ }
}

// Updated Sample
struct ContentViewCollect: View {
    let viewModel: RandomColorViewModel

    @State private var color = Color.gray

    var body: some View {
        ColorSample(color: color, onRandomize: viewModel.randomize)
            .collect(flow: viewModel.color) { color in
                self.color = Color(color)
            }
    }
}

To make this even easier for devs, we also provide a variant function that takes a state binding and assigns the value directly.

// SKIE extension signature
extension View {
    public func collect<Flow : SharedLogic.SkieSwiftFlowProtocol, U>(
        flow: Flow,
        into binding: SwiftUI.Binding<U>,
        transform: @escaping (Flow.Element) async -> U?
    ) -> some SwiftUI.View {
}

// Updated Sample
struct ContentViewCollect: View {
    let viewModel: RandomColorViewModel

    @State private var color = Color.gray

    var body: some View {
        ColorSample(color: color, onRandomize: viewModel.randomize)
            .collect(flow: viewModel.color, into: $color) { color in
                Color(color)
            }
    }
}

The collect(flow: into:) method allows you to avoid the lambda entirely, as long as the bind that you provide has the same signature as the value you are collecting. If the types differ (like in the sample above), you still need a lambda to convert them (from UIKit.UIColor to SwiftUI.Color here).

The Observing wrapper

The last API you need to know is the Observing View wrapper. That’s a normal SwiftUI View that takes many Flows (or StateFlows) and just gives you the values as arguments. This View uses the same ideas presented earlier, but removes all state management from your side.

// SKIE View Signature
extension Observing where InitialContent == EmptyView {
        public init<Flow1 : SkieSwiftFlowWithInitialValue>(
        _ flow1: Flow1,
        @ViewBuilder content: @escaping (Flow1.Element) -> Content
    ) where Flow1 : SkieSwiftFlowWithInitialValue, Values == (Flow1.Element) { /** ... **/ }
}

// Updated Sample
struct ContentViewObserving: View {
    let viewModel: RandomColorViewModel

    var body: some View {
        Observing(viewModel.color) { color in
            ColorSample(color: Color(color), onRandomize: viewModel.randomize)
        }
    }
}

Bonus: Making it animated

One interesting SwiftUI feature is that you can animate state changes by updating state inside the withAnimation {} block. To make sure you don’t need to find a workaround manually, all SKIE APIs contain an animation: parameter that allows you to define how you want to animate it.

By default, no animations are applied, but setting a value for the parameter enables them. If you want the “default” animation configuration SwiftUI already provides, use animation: .default. However, you can use any other animation configuration to fine-tune it to your needs.

With the animation, the Observing sample becomes like this:

struct ContentViewObserving: View {
    let viewModel: RandomColorViewModel

    var body: some View {
        // Including the default animation
        Observing(viewModel.color, animation: .default) { color in 
            ColorSample(color: Color(color), onRandomize: viewModel.randomize)
        }
    }
}

Conclusion

The SwiftUI APIs provided by SKIE can help you abstract a lot of complexity from your app. The decision of which level of abstraction or API to use depends on what you need for your project.

Each step in this article builds on the last. We make it as flexible as possible, so you can adapt as much as you need.

For more information, see the SKIE Official docs for Flows in SwiftUI. The code we’ve built and demonstrated in this post is available in the faogustavo/SkieSwiftUIDemo repo on GitHub.

Previous