Using SwiftUI With a Plugin System

Bao Lei
Bao Lei
Feb 27 · 4 min read

I’m proud to announce that the iOS version of Super Simple Draw is partially using SwiftUI in production. While some might dismiss this as a not-so-good idea citing SwiftUI being too new and potentially too risky for prime time. But with some adventurous mindset, trying this out is kinda fun.

While the benefits of SwiftUI are obvious and well documented, like more readable view layout code, declarative UI being less error-prone on state changes, etc. I’d share some lessons I learned on areas that are not so straightforward.

The part that I converted to SwiftUI is the app menu. And with this rewrite, I adopted the plugin system which helps to isolate different features in a scalable way. The idea is that each type of menu item is a plugin, which provides a certain feature and is completely isolated from other plugins. Then all plugins are following the same interface and registered in a plugin point.

Now the SwiftUI View code would look like this (a simplified version with lots of details omitted):

struct MenuView: View {
// some other vars ...
@ObservedObject var plugins: MenuPlugins
let items: [MenuItem]
init(...) { ... } var body: some View {
VStack {
Text("Menu title")
List(items.filter {
return plugins.hasPlugin($0.type)
}, id: \.title) {
VStack {
Text($0.title)
}.onTapGesture(perform: {
if let plugin = plugins.map[$0.type] {
plugin.onTap(item: item)
}
}
}
}
}
}

MenuPlugins is the plugin point. It contains a dictionary (the map property in the code above) that maps a MenuItem type to a plugin.

Each plugin (aka a feature integrated into the menu) conforms to the MenuPlugin protocol, which looks like this

protocol MenuPlugin {
var isApplicable: Bool { get }
func onTap(item: MenuItem)
}

isApplicable tells whether the menu item type represented by this plugin should be shown. For example, the “Apple Pencil only toggle” plugin is applicable only when the device is an iPad. The “restore purchase” plugin is applicable only if the product from the in-app purchase hasn’t been enabled yet. MenuPlugins class checks the return of this function in the hasPlugin function, so when it returns false, hasPlugin also returns false even if the map contains a plugin for this type.

onTap is the place to implement the function of this menu item type. E.g. this can be opening a web page, toggling a setting, importing an image, or launching a secondary menu.

To add a feature into the menu, just follow 2 steps: (1) create a class that conforms to the MenuPlugin protocol, (2) add it into the map in MenuPlugins class.

Now we can see the benefit of using a plugin system: every feature integrated into this system is isolated. To add another feature into the menu (1) we don’t have to touch the MenuView class, (2) we have a protocol to follow so it’s clear how it should integrate into the menu.

Everything is pretty straightforward so far. Except that there is one thing kinda harder to do with SwiftUI: what if the plugin can change what is shown in the menu?

This applies to all the settings-related plugins. E.g. if the user changes the canvas background to “grid”, then we want to have the menu to display that selection.

Example of a state that depends on a plugin

So let’s say we have a params variable ([String: String] type) with property wrapper. In a simple Swift UI setup, this would be taken care of by default: params lives within an ObservableObject, which is declared in the View with an property wrapper, so as soon as params updates, the UI just updates automatically.

However, the problem is that the plugin point created an extra layer between the plugin (which can be an ObservableObject) and the View, so that the View doesn’t directly access each individual plugin.

With a little bit of searching, I found a simple solution to address the two-level observation scenario. This can be done through a manual observation & republishing process, like in this hypothetical example:

struct SomeView: View {
@ObservedObject var a: A()
var body: some View {
Text(a.b.text)
}
}
class A: ObservableObject {
@ObservedObject var b = B()
}
class B: ObservableObject {
@Published var text = "some text"
}

Now in order to let A inform changes of B.text to the View, we just have to add this into A’s constructor:

b.text.sink { [weak self] in
self?.objectWillChange.send()
}.store(in: &cancellables)

But now here’s a second problem: with the plugin point architecture, A (the plugin point) doesn’t know the concrete type of B (the plugin), rather it refers to B through the protocol, and property wrapper isn’t allowed on a protocol (at least for now). So we can’t do something like:

protocol MenuPlugin {
@ObservedObject var params: [String: String] { get } // this won't compile
// other var/func ...
}

So another workaround is needed here. Luckily there is a solution: define the publisher manually, rather than relying on the property wrapper.

protocol MenuPlugin {
var params: [String: String] { get }
var paramsPublisher: Published<[String: String]>.Publisher? { get }
// other var/func ...
}

Then in MenuPlugins class:

plugins.forEach { plugin in
plugin.paramsPublisher?.sink { [weak self] _ in
self?.objectWillChange.send()
}.store(in: &cancellables)
}

Now if a plugin wants to publish some params, it will need to implement these two vars:

@Published var params: [String: String] = [:]
var paramsPublisher: Published<[String: String]>.Publisher? {
$params
}

And for other plugins that don’t need to publish any params, we can skip the boilerplate by providing a default implementation of params and paramsPublisher with an extension on the protocol, which provides an empty dictionary for params and nil for paramsPublisher.

So eventually two workarounds were needed, but overall not too bad. Maybe in future versions SwiftUI/Combine can evolve so that two-level observation can happen automatically, and/or protocols will support property wrappers. At least for now with these two workarounds it is nice to get the plugin system working on top of SwiftUI.

The Startup

Get smarter at building your thing. Join The Startup’s +788K followers.

Sign up for Top 10 Stories

By The Startup

Get smarter at building your thing. Subscribe to receive The Startup's top 10 most read stories — delivered straight into your inbox, once a week. Take a look.

By signing up, you will create a Medium account if you don’t already have one. Review our Privacy Policy for more information about our privacy practices.

Check your inbox
Medium sent you an email at to complete your subscription.

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +788K followers.

Bao Lei

Written by

Bao Lei

Maker Super Simple Draw, learn more at: draw.theiosapp.com

The Startup

Get smarter at building your thing. Follow to join The Startup’s +8 million monthly readers & +788K followers.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store