Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SwiftUI sample/boilerplate #654

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

rickclephas
Copy link

@rickclephas rickclephas commented May 27, 2023

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..).

Simulator.Screen.Recording.-.iPhone.14.Pro.-.2023-05-27.at.20.58.25.mp4

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)")
                }
            }
        }
    }
}

@salesforce-cla
Copy link

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

@ZacSweers
Copy link
Collaborator

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
Copy link
Collaborator

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

@rickclephas
Copy link
Author

rickclephas commented May 27, 2023

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
Copy link
Author

@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.

@ZacSweers
Copy link
Collaborator

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

@rickclephas
Copy link
Author

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 😁.

@ZacSweers
Copy link
Collaborator

ZacSweers commented Sep 6, 2023

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 👍

@rickclephas
Copy link
Author

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
Copy link
Author

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)

Copy link
Collaborator

@ZacSweers ZacSweers left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this is technically really cool, does this really require rewriting much of circuit's runtime itself in Swift? That seems counter to the point of trying to share UI. The hope was to get to a point where one could write presentation logic in kotlin and circuit's existing APIs, and only the UI would be SwiftUI. This seems to be more akin to a pure swift reimplementation of core components

@rickclephas
Copy link
Author

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?

@ZacSweers
Copy link
Collaborator

ZacSweers commented Sep 9, 2023

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
Copy link
Collaborator

@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

@chrisbanes
Copy link
Contributor

chrisbanes commented Sep 9, 2023

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.

@rickclephas
Copy link
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants