circuit icon indicating copy to clipboard operation
circuit copied to clipboard

SwiftUI sample/boilerplate

Open rickclephas opened this issue 2 years ago • 15 comments

I know you are not really accepting contributions, so feel free to close this, just wanted to demonstrate an approach to use Circuit with SwiftUI.

Highlights

  • The SwiftUI "navigator" is shared through the SwiftUIPresenter
  • 1 extension is needed in a client project to link the Swift an Kotlin implementations
  • With some code generation it might even be possible to auto generate the iOS screen classes
  • Requires iOS 16 due to the use of NavigationStack

Demo

I have added a PrimeView to the iOS sample similar to the desktop sample. Otherwise the UI hasn't changed much (git isn't smart enough to detect this after the file rename..).

https://github.com/slackhq/circuit/assets/7353419/3e6cb2c1-000a-4e7c-9634-48fde9549e20

Components

There are a couple main components required to make this work.

SwiftUIPresenter

It's a small wrapper around an existing Presenter and is the main link between Kotlin and Swift. SwiftUIPresenter exposes a function to set a listener that is used to notify Swift of any state changes. Besides exposing the state to SwiftUI it also provides a way for SwiftUI to store a navigator and cancel the coroutine scope.

SwiftUINavigator / CircuitNavigator

Which brings us to SwiftUINavigator. It forwards navigation actions to a Swift navigator. To accomplish that it relies on a ObjC protocol that is implemented in the Swift part of the library.

CircuitPresenter

Is a helper protocol. It allows us to rely on our Kotlin code. User just need to add the following line to their iOS project:

extension Circuit_swiftuiSwiftUIPresenterProtocol: CircuitPresenter { }

CircuitNavigationStack

It is a wrapper around NavigationStack. You provide it with a CircuitNavigator and a closure that generates a CircuitView based on the provided screen. It also stores the CircuitNavigator as an environment object such that CircuitViews can access it.

CircuitView

This view connects a presenter to a SwiftUI view. It observes the SwiftUIPresenter and forwards the navigator to it.

Usage

So how do you use this in an iOS project? First you need to add the above mentioned extensions. After that you'll go ahead and create your views. There is nothing special about those. They are just SwiftUI views accepting a state value:

struct CounterView: View {
  var state: CounterScreenState
  var body: some View { }
}

The only thing remaining is to connect the presenters to their view and setup the navigator:

@main
struct iOSApp: App {
    
    // We create a CircuitNavigator with the initial root screen
    @StateObject private var navigator = CircuitNavigator(IosCounterScreen.shared)
    
    var body: some Scene {
        WindowGroup {
            // And here we map screen objects to a CircuitView
            CircuitNavigationStack(navigator) { screen in
                switch screen {
                case let screen as IosCounterScreen:
                    CircuitView(screen.presenter(), CounterView.init)
                case let screen as IosPrimeScreen:
                    CircuitView(screen.presenter(), PrimeView.init)
                default:
                    fatalError("Unsupported screen: \(screen)")
                }
            }
        }
    }
}

rickclephas avatar May 27 '23 18:05 rickclephas

Thanks for the contribution! Before we can merge this, we need @rickclephas to sign the Salesforce Inc. Contributor License Agreement.

salesforce-cla[bot] avatar May 27 '23 18:05 salesforce-cla[bot]

This is great! Going to take a deeper dive into the code in a bit, but in the meantime could you do three things for us?

  • Can you fill out the description a bit more. Assume none of us have much iOS experience 😅. Especially with how it differs from what our current sample does. Eager to learn more
  • Could you add build/development instructions to the description?
  • Could you add a screenrecording or screenshot of the new UI?

ZacSweers avatar May 27 '23 18:05 ZacSweers

Could you also merge latest main or articulate why some of the dependencies were lowered? Also any commented code bits

ZacSweers avatar May 27 '23 18:05 ZacSweers

Could you also merge latest main or articulate why some of the dependencies were lowered? Also any commented code bits

Ah right forgot to mention that. Was having some issues building the iOS counter project due to the following change:

 add(NATIVE_COMPILER_PLUGIN_CLASSPATH_CONFIGURATION_NAME, libs.androidx.compose.compiler)

Removing that just left me with some errors about incompatible Compose/Kotlin version, which is why I downgraded them. Not sure what the above line is supposed to do, but can take another look at it if you like.

Will merge the latest main and take a look at your other questions 👍🏻.

rickclephas avatar May 27 '23 18:05 rickclephas

@ZacSweers I updated the description, let me know if there are other areas of which you would like some more details.

Could you add build/development instructions to the description?

Not much has changed. I have added a local Swift package dependency to the sample project. It uses a relative path, so it should work right away.

rickclephas avatar May 27 '23 19:05 rickclephas

Apologies for the delays, we haven't had much time to pick this up again yet. I'd be curious for your thoughts on SKIE though? The biggest annoyance I had in my own sample was getting UI state updates propagating to swift and it looks like this can help with that, possibly with molecule.

https://touchlab.co/skie-is-open-source

ZacSweers avatar Sep 06 '23 03:09 ZacSweers

No problem.

The biggest annoyance I had in my own sample was getting UI state updates propagating to swift

OMG that is genius! We have been thinking about this way to much from the Kotlin side.

So short answer: I don't think you'll need SKIE (or any library/tool).

Long answer: So I am no wear near done reading up on SKIE, but at it's core it helps with Kotlin - ObjC - Swift interop by generating Kotlin and Swift boilerplate. Basically there are two areas where SKIE could possibly help (based on the current PR):

  1. Flow to AsyncSequence
  2. Generating Swift boilerplate for Circuit (although that isn't supported out-of-the box)

However your comment made me realise we don't need anything like this. What SwiftUI needs is something like the following:

class CircuitPresenter: ObservableObject {
    @Published var state: State
}

That's it, we don't need a Flow, just a property that will notify subscribers of any changes.

I think we can completely drop all external dependency (except for Molecule). Using ObjC interfaces should be enough to remove 99% of the boilerplate. I guess the only thing we can't remove is the CircuitView initialiser (due to the issues with generics).

SKIE could technically generate that for use, though not out-of-the-box. IMO it wouldn't make sense to invest in the generation of a single extension that could be easily copy-pasted.

Will try and see if I can update the PR without external dependencies if I find some time 😁.

rickclephas avatar Sep 06 '23 06:09 rickclephas

Awesome! Yeah a dependency-less solution (other than molecule) would be ideal, and what you described in your snippet is exactly what I was thinking 👍

ZacSweers avatar Sep 06 '23 15:09 ZacSweers

Updated the implementation to only depend on Molecule. Was even able to remove the extension on CircuitView from the client project with an additional abstraction and some Swift magic.

The CircuitView will convert the Kotlin SwiftUIPresenter to an ObservableObject which sets a listener on the presenter to get notified about state changes.

rickclephas avatar Sep 08 '23 17:09 rickclephas

Maybe it's even possible to move the "entry point" from the SwiftUIPresenter to the IosScreen and convert:

case let screen as IosCounterScreen:
    CircuitView(screen.presenter(), CounterView.init)

to something like:

case let screen as IosCounterScreen:
    screen.present(CounterView.init)

rickclephas avatar Sep 08 '23 18:09 rickclephas

I am not really sure what you mean. When the goal is to use SwiftUI for the UI and Circuit for the presentation logic you'll need a way to tell SwiftUI about state changes and navigation events. In order to do that and provide that functionality as a library you'll need some way to connect the Circuit Kotlin code with the Circuit Swift code. What part of the Swift code do you feel rewrites much of circuit's runtime?

rickclephas avatar Sep 08 '23 19:09 rickclephas

What part of the Swift code do you feel rewrites much of circuit's runtime?

CircuitNavigator + CircuitSwiftUINavigator + SwiftUINavigator seem to recreate Navigator, CircuitPresenter and SwiftUIPresenter seem to recreate Presenter, CircuitNavigationStack seems to recreate BackStack, etc. Some of the core circuit concepts are also conflated here, like the iOS screens being presenter factories or CircuitView replacing CircuitContent but without the automatic bindings.

I don't think we want to re-implement so much of this in Swift (±UI) to make it work there, my hope was that we would only need a thin bridge layer to convert Presenter state emissions to an observable SwiftUI state and more or less limit it to that. What's the need for a custom Swift navigator and backstack implementation? And if so, can those be implemented in a way that doesn't feel like it's a parallel set of swift analogues of existing Circuit APIs?

ZacSweers avatar Sep 09 '23 19:09 ZacSweers

@chrisbanes would be curious for your thoughts in this space as well since you've been using some of circuit's stuff on iOS already

ZacSweers avatar Sep 09 '23 19:09 ZacSweers

My direct experience with Circuit on iOS is fully within Compose, however I have spent a while recently hooking up Decompose + 'native UI' at work, and this has ended up looking very similar.

I guess there's a bigger decision here: does using Circuit make much sense without Compose UI? 🤔

By using Swift UI (or even Android Views) you lose a lot of benefits from having a single composition, consistent navigation system, cross-cutting concept (CompositionLocals), and much more.

My $0.02 would be that Circuit should be Compose only, and make that as good as it can be. There are other libraries out there which are focused on the more general x-platform problem.

chrisbanes avatar Sep 09 '23 20:09 chrisbanes

CircuitNavigator + CircuitSwiftUINavigator + SwiftUINavigator seem to recreate Navigator, CircuitPresenter and SwiftUIPresenter seem to recreate Presenter, CircuitNavigationStack seems to recreate BackStack, etc.

Correct. Although most of it is just glue code. CircuitNavigator is the Swift implementation of Navigator, CircuitSwiftUINavigator and SwiftUINavigator are only needed for the interop.

SwiftUIPresenter combined with ObservablePresenter is the "thin bridge layer" you are talking about with CircuitPresenter being the glue code.

I don't think we want to re-implement so much of this in Swift (±UI) to make it work there, my hope was that we would only need a thin bridge layer to convert Presenter state emissions to an observable SwiftUI state and more or less limit it to that.

Alright. In that case I am wondering what responsibilities you were seeing for SwiftUI? I was going for SwiftUI interface with Circuit presentation logic.

What's the need for a custom Swift navigator and backstack implementation? And if so, can those be implemented in a way that doesn't feel like it's a parallel set of swift analogues of existing Circuit APIs?

They add navigation support, allowing you to trigger navigation actions from the presenter.

I guess there's a bigger decision here: does using Circuit make much sense without Compose UI? 🤔

That is indeed a great question. To be honest, I am not sure if it does make much sense. I completely agree that Circuit should be focused on Compose. However the power of KMP is in "share what you want, when you want, how much you want". So in that context it would be awesome if you could use Circuit with a SwiftUI interface.

rickclephas avatar Sep 09 '23 21:09 rickclephas