Since 2020, Widgets fully embrace SwiftUI by being a system extension fully based on them.

Building SwiftUI views instead of UIKit views is advantageous, including:

  • Declarative syntax is easier than dealing with old school constraints
  • Less code for equivalent user interfaces
  • Live Preview to iterate faster over the development life cycle

Because Padlok consisted on a lot of SwiftUI views, and also to leverage the advantages cited above, I wanted to implement a Notification Content Extension using mostly exclusively SwiftUI views. But this method also work for any system extension that present views, including:

Custom Keyboard Extension
UIInputViewController that extends UIViewController
iMessage Extension
MSMessagesAppViewController that extends UIViewController
Notification Content Extension
Any UIViewController implementing UNNotificationContentExtension
Share Extension
SLComposeServiceViewController that extends UIViewController

And any other extension…

Padlok notification content extension Padlok Notification built in SwiftUI

Containing UIHostingController with constraints

As we cannot use UIHostingController directly because most of root controller are already some subclasses of UIViewController. Instead we are going to use a child view controller

/// Exemple of an iMessage app extension
final class MessagesViewController: MSMessagesAppViewController {
    private var contained: UIViewController? {
        willSet {
            guard let contained else {
                return
            }
            // When removing the contained view, call UIKit required lifecycle methods
            contained.willMove(toParent: nil)
            contained.view.removeFromSuperview()
            contained.removeFromParent()
        }
        didSet {
            guard let contained else {
                return
            }
            // To add contained view, call UIKit required lifecycle methods
            addChild(contained)
            view.addSubview(contained.view)
            contained.didMove(toParent: self)
            // We need to add some constraints to fill the view
            contained.view.translatesAutoresizingMaskIntoConstraints = false
            contained.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
            contained.view.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
            contained.view.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
            contained.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
        }
    }

    ...
}

Clear background color

In some extensions like in Keyboard extensions, you might wanna keep the background clear so you get the neat gray used by default for keyboards.

But it turns out UIHostingController is adding a white background by default. Luckily it’s easy enough to remove:

// Create your Hosting Controller
let controller = UIHostingController(rootView: MyView())
// Remove the background
controller.view.backgroundColor = .clear
// Use it
contained = controller

Tint color

Somehow, I couldn’t auto-wire tint color in my extensions, but it’s easy enough to do by being able to access the Color asset:

override func viewDidLoad() {
    super.viewDidLoad()
    view.tintColor = ...
}

Notification Content Extension: Self sizing issue

Notification Content extension expect us to size ourself using .preferredContentSize API.

It’s easy to get the minimal size of a SwiftUI view using UIKit APIs on the UIHostingController itself in the didSet method for the contained attribute:

let size = contained.view.sizeThatFits(view.bounds.size)
preferredContentSize = CGSize(width: size.width, height: size.height)

Specifically for Padlok, I found that adding a 8pt top padding worked well:

...
preferredContentSize = CGSize(width: size.width, height: size.height + 8)
contained.view.topAnchor.constraint(equalTo: view.topAnchor, constant: 8).isActive = true
...

The case of live Previews

Sadly, almost every extensions will forbid you to preview directly in the extension target

Error: Previews are unsupported in Message Extension

This sadly have no solution, beside having your views included in your App Target as well, and using this target when previewing your views.

In Padlok, my shared views, and my extension views are in a Local Package that support Previews. The extension then consist only in the small bridge between UIKit and SwiftUI, and a little extension logic.

And the view is imported from this package, allowing me to leverage the whole Live Preview experience for my extensions.