Backward Compatibility with SwiftUI

On your mark, get set, go! The time to begin discovering all the new SwiftUI features that the WWDC 2020 brought is here. However, as every year, excitement washes off a few milliseconds later, when you remember that dropping support for older OS versions is not an option for you.

Usually, we resort to our friend #available. For example, suppose you have a long HStack. You may decide to use the new LazyHStack, to take advantage of its performance improvements for long stacks. However, if your app is running on iOS13, you can fallback to using a plain and normal HStack:

Group {
    Text("A long vertical view is below!")
    
    if #available(iOS 14.0, *) {
        LazyHStack {
            View1()
            View2()
        }
    } else {
        // Fallback on earlier versions
        HStack {
            View1()
            View2()
        }
    }
}

This is an easy substitution, but with more complex scenarios, your fallback code will require more extreme measures, such as a UIKit/AppKit view wrapped by a Representable.

This approach looks good for a very small project, but as the number of views starts to grow, you may find it very annoying having to add the #available check every time. And also, code readability will suffer tremendously. For those cases, we can take advantage of the fact that Swift can handle the same type name in different scopes. Let me illustrate with an example:

// Now, the compiler will no longer complain about LazyHStack not being available on iOS13.
struct ContentView: View {
    var body: some View {
        LazyHStack(spacing: 30) {
            View1()
            View2()
        }
    }
}

struct LazyHStack<Content> : View where Content : View {
    let alignment: VerticalAlignment
    let spacing: CGFloat?
    let content: () -> Content
    
    var body: some View {
        Group {
            if #available(OSX 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) {
                AnyView(SwiftUI.LazyHStack(alignment: alignment, spacing: spacing, content: content))
            } else {
                // Fallback on earlier versions
                HStack(alignment: alignment, spacing: spacing, content: content)
            }
        }
    }

    init(alignment: VerticalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: @escaping () -> Content) {
        self.alignment = alignment
        self.spacing = spacing
        self.content = content
    }
}

BUG ALERT (FIXED!): The AnyView you see in the code is there to prevent a bug in the Framework. The issue has been fixed in Xcode 12, beta 5. I will remove the workaround in a few weeks, in case there is still a reader that has not updated to beta 5 yet. Learn more about the bug here.

By creating our own LazyHStack, which will be available on all OS versions, the compiler no longer complains. This is because now, LazyHStack refers to MyApp.LazyHStack and not SwiftUI.LazyHStack. Then, on our own implementation we check for the version, and there we decide whether to use the old SwiftUI.HStack, or the new SwiftUI.LazyHStack.

To learn more about swift’s @available attribute, refer to swift.org: https://docs.swift.org/swift-book/ReferenceManual/Attributes.html#ID583.

So far, this is somewhat trivial. However, now I would like to shift the focus to a specific problem that the new SwiftUI brings. I’m talking about how to use the new App and Scene API, together with the “old ways” of launching our apps.

Embracing Change

Suppose you have an app already working under older OS versions. Now, after witnessing the new wave of SwiftUI improvements, you finally decide it is time for your app to embrace the new framework. I won’t go into discussing if SwiftUI is mature enough or not. That is heavily dependent on the type of app you are writing, but for the sake of this article, let’s pretend SwiftUI is indeed a good fit for your app.

For this particular example, we will explore the possibility of keeping our old UI for users running previous OS versions, and having a redesigned SwiftUI interface from scratch, so it can benefit users running iOS14.0+/macOS11+

Starting with Xcode 12, it is now possible to design an app written completely using SwiftUI. In the past (i.e., last year), you still needed to hook up your hierarchy of scenes/windows as usual. No more, it is now possible to write a full app with few lines of code, as below:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            Text("Hello, world!")
        }
    }
}

This, although wonderful, presents a problem when we try to keep both (the old and the new UI in the same app). If we try to use “if #available” to wrap the @main declaration, we will get a compiler error.

if #available(iOS 14.0, *) {
    @main
    struct MyApp: App {
        var body: some Scene {
            WindowGroup {
                Text("Hello, world!")
            }
        }
    }
}

Using #available at the top level is not permitted, so the compiler will grace us with the following error:

top level error

Fortunately, there is still a way around it. Let’s see…

Application Entry-Point (@main)

Swift 5.3 has a new attribute called @main (SE-0281). Since this is part of the language (i.e., not a library), it will work with older OS versions, as long as you are compiling with Swift 5.3+. The real problem we have here is that the App protocol does not exist in older OS versions. So how can we go around it, considering there can only be one annotated type with @main in your entire app?

Also note that @main and @UIApplicationMain/@NSApplicationMain and top-level code in main.swift are mutually exclusive. You can only use one type of entry-point.

If we look at the documentation, we’ll see that @main provides the entry-point for your app. And in particular, your app will start by jumping to the main() function of the type that has been prefixed with @main. Looking at the code, we can infer that the App protocol must have a default implementation of the main() function. And indeed it does, check Apple’s documentation about it here.

This is all we need to know, in order to create an app that uses the new App protocol for new OS versions, and the old UIApplication/NSApplication type otherwise. With that in mind, we will need to change our code as explained below.

But First, Some Considerations

  1. The code that follows, is only intended as a starting point. Over the years, there have been many ways to start an app (e.g., main storyboard, xib files, scene manifest, manually invoking UIApplicationMain, etc). This means that each case should be approached differently. The code here will just point you in the right direction (I’m hoping).
  1. While experimenting with this, I found that sometimes the app was being a little stubborn and wouldn’t do what I thought it should. I found that removing the app from the simulator and redeploying solved the problem. This may be related to changes in the Info.plist not being updated properly… but I’m not sure. Just keep it in mind, should it happen to you.
  1. And my final word of caution: You won’t like this one, and I apologize… but we are dealing with some undocumented behaviors here, so carry on at your own risk! 😬

An iOS Example

In the following example, our old UI is using UIHostingController as the main controller, and so the main scene is not loaded from a storyboard. If in your case it does, it will require more work. I’ve yet to try that, but I did something similar for a macOS app, which I will include below.

First, don’t forget to remove the old @UIApplicationMain annotation. In the code below I will comment it out instead, so you can see what I mean.

Make sure that your Application Scene Manifest in the Info.plist does not have a UISceneStoryboardFile setup. Since you are working with a UIHostingController based app, it shouldn’t… but maybe your code was updated and it was left there. If a value for the UISceneStoryboardFile key is in the Info.plist file, the app may default to that and ignore all your efforts. So be careful. And also, if you change the Info.plist, remember you may need to remove the app from the simulator/device and redeploy to make sure the change takes effect.

With all these prerequisites out of the way, let’s start updating our code.

import SwiftUI

@main
struct MainApp {
    static func main() {
        if #available(iOS 14.0, *) {
            MyNewUI.main()
        } else {
            UIApplicationMain(
                CommandLine.argc,
                CommandLine.unsafeArgv,
                nil,
                NSStringFromClass(AppDelegate.self))
        }
    }
}

@available(iOS 14.0, *)
struct MyNewUI: App {
    var body: some Scene {
        WindowGroup {
            Text("This is my new UI! Pretty basic, huh?")
        }
    }
}

//@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate { ... }

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {

        let contentView = ContentView()

        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }

    func sceneDidDisconnect(_ scene: UIScene) { ... }
    func sceneDidBecomeActive(_ scene: UIScene) { ... }
    func sceneWillResignActive(_ scene: UIScene) { ... }
    func sceneWillEnterForeground(_ scene: UIScene) { ... }
    func sceneDidEnterBackground(_ scene: UIScene) { ... }
}

It is very important that you annotate your App struct with @available(iOS 14.0, *). This will prevent the compiler from complaining, as App does not exist in older OS versions.

A similar logic applies to macOS, let’s see another example.

A macOS Example (#1)

In this first macOS example, the old UI is using NSHostingView. The second example will use a storyboard instead.

As with the iOS example, there are some prerequisites:

  • Remove the NSMainStoryboardFile entry from your Info.plist file.
  • Remove the NSPrincipalClass entry from your Info.plist file.
  • Remove the @NSApplicationMain annotation.

If you do not remove those entries from the Info.plist file, your logic may be overridden at launch time. So don’t skip that part.

import SwiftUI

var appDelegate = AppDelegate()

@main
struct AppUserInterfaceSelector {
    static func main() {
        if #available(OSX 11.0, *) {
            NewUIApp.main()
        } else {
            OldUIApp.main()
        }
    }
}

@available(OSX 11.0, *)
struct NewUIApp: App {
    var body: some Scene {
        WindowGroup() {
            NewContentView()
        }
    }
}

struct OldUIApp {
    static func main() {
        NSApplication.shared.setActivationPolicy(.regular)
        
        let nib = NSNib(nibNamed: NSNib.Name("MainMenu"), bundle: Bundle.main)
        nib?.instantiate(withOwner: NSApplication.shared, topLevelObjects: nil)
        
        NSApp.delegate = appDelegate
        NSApp.activate(ignoringOtherApps: true)
        NSApp.run()
    }
}

class AppDelegate: NSObject, NSApplicationDelegate {
    var window: NSWindow!

    func applicationDidFinishLaunching(_ aNotification: Notification) {
        // Create the SwiftUI view that provides the window contents.
        let contentView = OldContentView()

        // Create the window and set the content view.
        window = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 480, height: 300),
            styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
            backing: .buffered, defer: false)
        
        window.title = "Test Application"
        window.isReleasedWhenClosed = false
        window.center()
        window.setFrameAutosaveName("Main Window")
        window.contentView = NSHostingView(rootView: contentView)
        window.makeKeyAndOrderFront(nil)
    }

    func applicationWillTerminate(_ aNotification: Notification) { ... }
}

Some things to note from the code above. We have created a global variable for our AppDelegate. This is because the NSApp.delegate is a weak property, and we need to keep that object around. Also note that the application menu is loaded from a xib file.

A macOS Example (#2)

In our second macOS example, the old UI will be using a storyboard, instead of a NSHostingView. So that part is gone from applicationDidFinishLaunching. The rest of the code is almost identical, but we need to add a few lines to the OldUIApp.main() function:

struct OldUIApp {
    static func main() {
        NSApplication.shared.setActivationPolicy(.regular)

        // Load MainMenu, from MainMenu.xib
        let nib = NSNib(nibNamed: NSNib.Name("MainMenu"), bundle: Bundle.main)
        nib?.instantiate(withOwner: NSApplication.shared, topLevelObjects: nil)

        // Load Main storyboard and show main window
        let sb = NSStoryboard(name: NSStoryboard.Name("Main"), bundle: .main)
        let windowController = sb.instantiateInitialController() as? NSWindowController
        windowController?.window?.makeKeyAndOrderFront(nil)

        NSApp.delegate = appDelegate
        NSApp.activate(ignoringOtherApps: true)
        NSApp.run()
    }
}

This new version of the main() function, loads a storyboard, instantiates the window controller, and presents its window. Note that the menu is still coming from a xib file. I did not find a way to get a reference to the menu from the storyboard. There’s probably a way, but I haven’t look. If you know how, please leave a comment below.

One Step Further

In the example above, we determine which UI to run based on the detected OS version. However, nothing prevents you from making that decision based on other facts. For example, a saved value in your UserDefaults, or a pressed key while the app launches, or both. For example:

@main
struct AppUserInterfaceSelector {
    static func main() {
        if #available(OSX 11.0, *) {
            // Use old interface, if SHIFT key is pressed during app launch, or
            // if UseOldUI is set to true in UserDefaults.
            if NSEvent.modifierFlags.contains(.shift) || UserDefaults.standard.bool(forKey: "UseOldUI") {
                OldUIApp.main()
            } else {
                NewUIApp.main()
            }
        } else {
            OldUIApp.main()
        }
    }
}

In the macOS example above, if there is a boolean key in the app defaults, or if the SHIFT key is pressed while the app launches, the old UI will be used, and it won’t matter what OS version the app is running on. This is very useful for testing.

Be careful if you include this type of conditional UI selection, as it could go against Apple Review Guidelines. Remember that the App Store cannot contain beta software, and being able to select a different UI could be construed as such. Especially if the “new” design is not the default. In any case, it is completely safe when distributed outside the App Store, or for your own testing.

Summary

Every year we face the challenges of deciding when to adopt the new technologies that Apple brings us during the WWDC. Finding the balance between moving forward, or maintaining compatibility with older OS versions is not an easy task. I’m hoping the tips in this article will contribute to making your decisions a tiny bit easier.

In the upcoming weeks, I will continue to post new articles about the latest additions in SwiftUI. Please feel free to comment below, and follow me on twitter if you would like to be notified when new articles come out. Until next time!

1 thought on “Backward Compatibility with SwiftUI”

  1. You have the best SwiftUI content on the internet, hands down. This article answered the exact questions I was mulling over after WWDC ended. Thank you for continuing to write such great articles!

    Reply

Leave a Comment

By continuing to use the site, you agree to the use of cookies. more information

The cookie settings on this website are set to "allow cookies" to give you the best browsing experience possible. If you continue to use this website without changing your cookie settings or you click "Accept" below then you are consenting to this.

Close