By now, you have heard of SwiftUI... this new, declarative, and "exceptionally simple" way of building user interfaces for Apple devices. Ever since its announcement last year, the Apple development community has been buzzing with articles, podcasts, tutorials, and many other resources that all amplify the hype. According to Apple, it has the potential to be "the most powerful UI code you've ever written." So what are we all waiting for? Let's choose SwiftUI and never look back... but then again, why not choose both?

Like many long-standing apps, OkCupid has users across a handful of different iOS versions. While the percentage of users on older versions is small, it is not dismissible. Neither are the work hours that would be needed to rewrite the user interface using SwiftUI. There are also some significant odds and ends to consider like the fact that SwiftUI doesn't have an equivalent for UICollectionView, a core component that is heavily utilized within the OkCupid app (and many others).

With these various considerations in mind, this article explores how to integrate SwiftUI sensibly without dropping support for iOS 12 and older, as well as lessons learned from building in SwiftUI for the first time. At OkCupid our hope is to begin selectively choosing certain features to build side by side using both UIKit and SwiftUI, with shared business logic code. With that in mind, I have created an Xcode project called LoginSample to illustrate a common user journey of logging into an application. Feel free to follow along by cloning the project repo on GitHub. To keep things simple, authentication with a server is replaced with local validation using regex. Like the OkCupid app, LoginSample supports iOS versions 10 and up.

Goals

One of the two main goals of the project is to utilize shared abstractions when building the user interface, whether it's built using UIKit or SwiftUI. The main example of this is the LoginViewModel which provides most of the values and information needed to configure the login view.

The other main goal is that the views should look nearly identical when they are rendered on screen, as well as behave the same.

login-sample-screenshots

Jumping in

The first step to switching between SwiftUI and UIKit is creating a view controller that serves as the container for either the SwiftUI or UIKit elements. In this case, it's the LoginContainerViewController. The main responsibility of the container is to contain a child view controller with a view that supports logging into the app. A version check is used to decide which child view controller to display:

// LoginContainerViewController.swift

override func viewDidLoad() {
    super.viewDidLoad()
        
    addChildViewController()
}
    
private func addChildViewController() {
    if #available(iOS 13, *) {
        let loginView = LoginView(delegate: delegate)
        add(UIHostingController<LoginView>(rootView: loginView), frame: view.frame)
    } else {
        add(LoginViewController(delegate: delegate), frame: view.frame)
    }
}

The code above is very straightforward. If the user is using iOS 13 and up, a UIHostingController can be used where its root view is the SwiftUI LoginView, otherwise use the UIKit view controlled by the LoginViewController. Like Apple's documentation states, the UIHostingController is the perfect solution "when you want to integrate SwiftUI views into a UIKit view hierarchy." Also included in the above snippit is a handy extension from Swify by Sundell on UIViewController that makes it easy to add a child view controller.

Sharing is caring

Slowly integrating SwiftUI side by side with UIKit requires writing some code twice. It's unavoidable. User interface construction, configuration, and state is coded using its respective framework. However, the values used for configuration can be shared. By sharing data from one source, specifically the LoginViewModel, updates only need to be made in one place which maximizes efficiency and minimizes the chance for discrepancy between the views.

In addition to the LoginViewModel, there is the LoginViewDelegate that is adopted by the LoginCoordinator and passed down to the login view. The other shared dependency is the LoginDataManager, the object responsible for communicating with the LoginValidator to determine whether to create a UserLogin object or return an error to display to the user. Sharing dependencies can be seen from the beginning of each lifecycle. The initializers for the LoginView and the LoginViewController are almost identical:

// LoginView.swift (SwiftUI)

init(viewModel: LoginViewModel = LoginViewModelFactory.create(),
     dataManager: LoginDataManager = .init(),
     delegate: LoginViewDelegate?) {
    
    self.viewModel = viewModel
    self.dataManager = dataManager
    self.delegate = delegate
}

// LoginViewController.swift (UIKit)

init(viewModel: LoginViewModel = LoginViewModelFactory.create(),
     dataManager: LoginDataManager = .init(),
     delegate: LoginViewDelegate?) {
    
    self.viewModel = viewModel
    self.dataManager = dataManager
    self.delegate = delegate
    
    super.init(nibName: nil, bundle: nil)
}

A closer look

The LoginViewModel is comprised of nested view models each containing the property values used to configure the login view:

struct LoginViewModel {
    let backgroundColor: UIColor
    let buttonModel: LoginButtonModel
    let contentStackModel: LoginStackModel
    let emailTextEntryModel: LoginTextEntryViewModel
    let formStackModel: LoginStackModel
    let imageModel: LoginImageModel
    let passwordTextEntryModel: LoginTextEntryViewModel
    let titleModel: LoginTextModel
}

While the view model is shared, it's not perfect. Part of the imperfection lies in attempting to simultaneously provide values needed for both frameworks from one source. The view model is the single source of truth but some property values are applied differently. Additionally, there are other values that require different types depending on the framework. This can be seen when looking more closely at the LoginTextModel:

// LoginTextModel.swift

struct LoginTextModel {
    let font: UIFont
    let numberOfLines: Int
    let text: String
    let textColor: UIColor
}

// LoginViewControllerConfigurator.swift (UIKit)

controller.titleLabel.font = viewModel.titleModel.font
controller.titleLabel.numberOfLines = viewModel.titleModel.numberOfLines
controller.titleLabel.text = viewModel.titleModel.text
controller.titleLabel.textColor = viewModel.titleModel.textColor

// LoginText.swift (SwiftUI)

Text(viewModel.text)
    .font(Font(viewModel.font as CTFont))
    .lineLimit(viewModel.numberOfLines)
    .foregroundColor(Color(viewModel.textColor))

Strings are easy to share because String is the type used by both frameworks. The font used by each view can be shared but in order to use UIFont in SwiftUI, initializing Font with a Core Text font reference is required. This is accomplished by casting a UIFont as a CTFont, the identifier of the opaque type used by Core Text. Color in SwiftUI can be initialized with a UIColor. Lastly, a UILabel expects numberOfLines whereas Text uses a lineLimit(_:).

While assigning a font can take advantage of toll-free bridging, there are a few values in other view models that are not straightforward to share like stack view alignment and content mode. These are addressed by creating a convertible protocol for each that has one convert function and default implementation to convert one type to another. This approach is not ideal and I hope to see it evolve as we start implementing features side by side.

The last bit of sharing worth mentioning is a UI component, the ErrorAlertView which is subclassed from UIView. Thankfully, SwiftUI does offer some built-in interoperability. In this case, it's UIViewRepresentable, a wrapper that allows integrating a UIView in the SwiftUI view hierarchy. ErrorAlertView can be the specific type returned by makeUIView(context:) as long as it's of type, UIView.

struct ErrorAlert: UIViewRepresentable {
    
    let message: String
    let width: CGFloat
    
    func makeUIView(context: Context) -> ErrorAlertView {
        return ErrorAlertView(message: message, width: width)
    }

    func updateUIView(_ uiView: ErrorAlertView, context: Context) {
        // not in use
    }
    
}

If this project was a fully functioning application, it's very likely the error view displayed to the user would be the same across all screens, not just the login view. By taking advantage of UIViewRepresentable, the same error view can be used eliminating duplication of effort for both frameworks.

Other lessons learned

As this was my first time trying to build something substantial using SwiftUI, I learned a few things, usually after some trial and error. While the project ultimately accomplishes the goals mentioned earlier, there are some slight differences. For example, a TextField in SwiftUI has the option to include placeholder text but you can't specify a color for the text. If you look closely at both renderings of the login view side by side, you're notice the gray color is slightly different. I did come across a work around where you could overlay a Text view but for the sake of this exercise, I decided... meh. Ultimately my lackadaisical attitude towards placeholder text color did not extend to other issues that demanded more research, perseverance, and attention to detail.

Backwards compatibility

As with any new framework, bugs abound (well, maybe not "abound" but I'm a sucker for alliteration). When I first attempted to build and run the LoginSample project on versions lower than iOS 13, the app would crash. Thanks to YichenBman's answer on stack overflow, all I needed to do was add -weak_framework SwiftUI as the value of Other Linker Flags under Linking in Build Settings. Problem solved.

Another step required for backwards compatibility was including the @available attribute for iOS 13 and up to enclosing structs in files importing SwiftUI:

@available(iOS 13.0.0, *)
struct LoginView: View {
    ...
}

Transitions

In the LoginView, showing and hiding the ErrorAlert view is dependent on the showError boolean property. showError uses the @State property wrapper to allow the value to be modified. When the value is modified, SwiftUI destroys and recreates the view struct without losing track of state. ErrorAlert has a custom modifier that includes a custom transition, moveTopEdgeInOutWithOpacity that uses a function asymmetric(insertion:removal:) to specify different transition animations to occur when inserting and removing a view. In simple terms, when showError is true, the view slides in from the top onto the screen and when its value is false, the view slides up off the screen.

// AnyTransition+Extension.swift

extension AnyTransition {
    
    static var moveTopEdgeInOutWithOpacity: AnyTransition {
        let insertion = AnyTransition.move(edge: .top)
            .combined(with: .opacity)
        let removal = AnyTransition.move(edge: .top)
            .combined(with: .opacity)
        return .asymmetric(insertion: insertion, removal: removal)
    }
    
}

// LoginView.swift

@State var showError: Bool = false

var body: some View {

    ...
            
    if self.showError {
        // Show or hide error alert view using transition:
        // `moveTopEdgeInOutWithOpacity`
    }
    
    ...
    
}

When the value of showError changed, this worked somewhat as expected but the view jumped on and off the screen without the transition:

login-sample-transition-not-working

There were two missing steps to make it work. The first step was to use explicit animation by wrapping the change to the value of showError in a call to withAnimation(). By changing the value of showError in the animation block, SwiftUI knows to animate any views that have transitions that depend on it. Note that withAnimation() is being used any time the value changes otherwise the insertion or the removal would not work.

// LoginView.swift

func buttonTapped() {

    ...

    withAnimation {
        self.showError = true
    }

    ...
    
}

func textEntryTextFieldTapped() {

    ...
    
    withAnimation {
        self.showError = false
    }

    ...
    
}

The second missing step was explicitly setting the zIndex within the LoginView view hierarchy. Thanks to Scott Gribben's answer on stack overflow, I learned when nested views come and go within the parent view hierarchy, "their zIndex doesn't [always] stay the same." The view may be inserted using one zIndex and removed using another zIndex depending on how SwiftUI redraws the view. In simple terms, the transition is happening but you may not see it because the zIndex is not consistent. Therefore you must explicitly specify the zIndex of the views within the overall view hierarchy:

var body: some View {

    ...

    ZStack(...) {
        Color(...)
            .zIndex(0)
        
        LoginVStack(...) {
            ... 
        }
        .zIndex(1)
        
        if self.showError {
            ErrorAlert(...)
                ...
                .zIndex(2)

        }
    }

    ...

}

After implementing those two missing steps, the error alert view behaves as expected:

login-sample-transition-working

Et cetera

Lastly, there were two resources that were critical in helping me learn and get the LoginSample project across the finish line. The first was Paul Hudson's 100 Days of SwiftUI, "a free collection of videos, tutorials, tests, and more" and the second was Vadim Bulavin's Keyboard Avoidance for SwiftUI Views article.

Wrapping up

I really enjoyed sinking my teeth into SwiftUI and coding this project. Even though SwiftUI is declarative and easy to read, it still took time to understand and implement. There were moments I thought to myself, "Well, what about this? How do you do that? How will I ever get this view to vertically align to the top of screen?" Thankfully the Apple/Swift community was (as is) always there to help. I look forward to seeing the framework evolve and especially how other developers approach building user interfaces side by side with UIKit. As mentioned previously, you can check out the LoginSample project on GitHub. Thanks for reading!