MySwimPro

Building the MySwimPro App’s SwimGauge™ with SwiftUI

Building Complex UI Solutions Using SwiftUI vs. UIKit

Two years ago, we got an innovative product initiative to implement a new workout screen in MySwimPro, the best swimming workout app for the iOS platform. We call it the SwimGauge™. 

The MySwimPro SwimGauge provides a new, visual overview of workouts for MySwimPro members. It shows total distance, time, calories, laps, and Effort Levels for the entire swim. It also uses MySwimPro’s Dynamic Swim Algorithm, which quickly adjusts the workout structure for personal needs. 

The goal was to create an amazing visual experience for members to personalize their workouts. You can find more information about the visual components we built here and  personalizing MySwimPro workouts here.

This article will open the door a little bit to what was going on under the hood from an engineering perspective. Hopefully, it will help someone make better decisions when building complex UI solutions using SwiftUI versus UIKit.

SwiftUI was first released in 2019, introducing a powerful new way to build user interfaces in a declarative, state-driven style. It was a radical shift in Apple’s platforms, and everyone was excited about that. 

SwiftUI was created for cross-platform use to build applications with less code than UIKit, but with the same complexity. The technology was raw compared to UIKit: many constraints with SwiftUI prevented us from doing many things that were possible or even easy in UIKit. On the other hand, the new framework allowed us to build and iterate custom UI components quickly. Because of the ability to quickly build and iterate, we decided to take a risk and go with SwiftUI. Spoiler! We didn’t regret it.

First Design

The first mockup looked like this:

The screen required the following main components:

First Traps

SwiftUI List isn’t fully customizable for the OS versions we need to support. It’s not even possible to customize the background color of the content. The only way to work around this is to make changes via UITableView.appearance(). Today you can customize it, but only in iOS 16 code and later.

UITableView.appearance().separatorStyle = .none
UITableView.appearance().backgroundColor = .clear

There was no way to implement drag and drop in the table with pure SwiftUI because it did not support this functionality for iOS 13. We decided to go with UICollectionView. UICollectionView supports this built-in functionality with UICollectionViewDragDelegate and UICollectionViewDropDelegate. So there was no reason to reinvent the wheel.

This wasn’t as bad as it sounded, though, because Apple released contentConfiguration for UICollectionViewCell or UITableViewCell starting with iOS 14. This API allows you to build SwiftUI views inside cells super quickly. We still needed to support iOS 13 at that moment, so we had to make that on our own.

import UIKit
import SwiftUI

/// Subclass for embedding a SwiftUI View inside of UICollectionViewCell
open class SwiftUICollectionViewCell<Content>: UICollectionViewCell where Content: View {

    /// Controller to host the SwiftUI View
    public private(set) var host: UIHostingController<Content>?

    /// Resets the cell
    open override func prepareForReuse() {
        super.prepareForReuse()

        if let hostView = host?.view {
            hostView.removeFromSuperview()
        }
        host = nil
    }

    /// Embeds host controller to the hierarchy
    public func embed(in parent: UIViewController? = nil, withView content: Content) {
        if let host = host {
            host.rootView = content
            host.view.layoutIfNeeded()
        } else {
            let host = UIHostingController(rootView: content)
            if let parent = parent {
                parent.addChild(host)
                host.didMove(toParent: parent)
            }
            contentView.addSubview(host.view)
            host.view.backgroundColor = .clear
            contentView.backgroundColor = .clear
            self.host = host
        }
    }

    deinit {
        host?.willMove(toParent: nil)
        host?.view.removeFromSuperview()
        host?.removeFromParent()
        host = nil
    }
}

We love using SwiftUI views inside UIKit code, as it’s super portable and much easier to build custom UI for cells, and still allows us to use the power of the more mature UIKit framework.

The most challenging part of building the cells was to make them expandable. This is because the cell’s content is in SwiftUI, but UIKit controls the cell’s size. SwiftUI code never knows what the size of the cell is, which is required to make things like smooth animations work.

To work around this, we built two states of the view separately in the cell and made an expand/collapse function by re-rendering the table on tap. But I’d recommend implementing this using UIStackView in the UIKit code because UIStackView takes care of animations automatically. It handles all of the animations when the app hides or shows some parts of the stack. This approach gives you more flexibility to achieve smooth animations.

In summary, for the table we used UICollectionView with SwiftUI views embedded inside of UIStackView.

Performance

Overall, SwiftUI met our performance expectations. A complexity of this task was that users could adjust the distance and other parameters of the workout according to their personal needs and the app rebuilds the workout structure whenever the user drags the slider knob.

The architecture of the table was something like this:

Does it sound ridiculous? Yes, it does.

The UI needed to update rapidly but the code path was long and potentially slow if users dragged the slider super fast.

How did we solve this problem? We used models for cells as ObservableObject that listened for updates to the underlying model using Bindable attributes. So, we only needed to implement a shallow update. The app doesn’t need to go through the UI hierarchy; the needed variable is updated and immediately goes to the cell’s model via the bindable properties. The one exception could be when rows are added or deleted, but our workout personalization algorithm doesn’t add or remove sets – it only modifies them – so this was out of scope for us.

Issues

We used UIHostingController as our main container, and there is a known defect that you can’t ignore safe areas from SwiftUI code. This can cause cells in the UICollectionView to blink at the bottom when the table’s content intersects the safe area.

There is a magic solution for that: We can listen for the safe area insets on the UIView and apply them to the SwiftUI view.

import UIKit
import SwiftUI

extension UIHostingController {

    convenience public init(rootView: Content, ignoreSafeArea: Bool) {
        self.init(rootView: rootView)

        if ignoreSafeArea {
            disableSafeArea()
        }
    }

    func disableSafeArea() {
        guard let viewClass = object_getClass(view) else {
            return
        }

        let viewSubclassName = String(cString: class_getName(viewClass)).appending("_IgnoreSafeArea")
        if let viewSubclass = NSClassFromString(viewSubclassName) {
            object_setClass(view, viewSubclass)
        } else {
            guard let viewClassNameUtf8 = (viewSubclassName as NSString).utf8String else {
                return
            }
            guard let viewSubclass = objc_allocateClassPair(viewClass, viewClassNameUtf8, 0) else {
                return
            }

            if let method = class_getInstanceMethod(UIView.self, #selector(getter: UIView.safeAreaInsets)) {
                let safeAreaInsets: @convention(block) (AnyObject) -> UIEdgeInsets = { _ in
                    return .zero
                }
                class_addMethod(viewSubclass, #selector(getter: UIView.safeAreaInsets), imp_implementationWithBlock(safeAreaInsets), method_getTypeEncoding(method))
            }

            objc_registerClassPair(viewSubclass)
            object_setClass(view, viewSubclass)
        }
    }
}

Check out this reference for an explanation of the solution.

If your project is mostly in UIKit and you are looking to add a new screen, I’d recommend creating a UIViewController and attaching SwiftUI inside this control versus just pushing UIHostingController into the stack. UIHostingController brings a bunch of issues, especially if you need to support older versions. For instance:

Testing

After all these changes to the UI, we needed to write some tests to make sure the UI doesn’t change unexpectedly in the future. The best way we’ve found to test SwiftUI code is Snapshot Testing. You can test small components with different states, or you can test the whole screen at once. That depends on your project. Initially, this may seem quite frightening and flaky. 

However, if you test small things and use the fuzzy threshold correctly, then it becomes helpful and easily maintainable. The hardest parts of snapshot testing are:

A good example is to test small components. For instance, table cells. You test visual presentation and data at the same time. Here are some of our snapshot tests:

Conclusion

Building this complicated UI in SwiftUI was hard, but our code is much more flexible and portable now. Plus our customers love the new screen! It helped to get a visual experience working with workouts on a new level and brought a lot of value for members.

We don’t regret using SwiftUI code in an entirely UIKit project. That was bumpy initially, but it gives us more flexibility and portability now and in the future.

Let’s summarize why we prefer SwiftUI over UIKit:

However, we should keep in mind:


20% Off Coupon 💸

Sign up for free swim tips in your inbox & unlock 20% off the MySwimPro app. 

Exit mobile version