Using SwiftUI With a Plugin System

Bao Lei
The Startup
Published in
4 min readFeb 27, 2021

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

--

--