Migrating an iOS app to SwiftUI - Navigation & Storyboards

If SwiftUI is great for many things, migrating completely an existing app codebase to it can be really tricky. In a series of blog posts, I’ll share how to migrate an iOS app written in Swift with UIKit to SwiftUI. Today, let’s start with the navigation and the UI components with storyboards.

Almost two years ago, I’ve released an app to help people quit their bad habits. It contains a database, an onboarding journey and a dashboard. It’s a fairly simple app.

appy-migration-swiftui

The goal of this series of blog posts will be to fully migrate it to SwiftUI. This week I start with probably the most exciting part, the design and navigation.

At the moment the app uses auto-layout across different storyboards for the UI. If it’s the first time the user launches the app, it goes through an onboarding journey to pick what habit to quit.

Once defined, the next launch will go straight to its dashboard where the user can keep track of the time being sober and log new records.

To tackle this migration, I’ll start first with the onboarding journey, then move toward the dashboard and logging screens.

Onboarding

The design of the onboarding is really simple, it includes different steps to introduce the app and its content. I create new SwiftUI View side by side to my previous UIViewController and .storyboard files to make sure I got the design right.

First challenge is to use a custom font. Apple has great set of font and weight to automatically resize them but my previous version used a custom font, so does this new SwiftUI version.

Text("Hello")
    .font(.custom(AppFont.light.name, size: 27))
    .foregroundColor(.white)

To create a reusable style, I’ve implemented different variation of modifiers to be more handy.

struct TitleModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .font(.custom(AppFont.bold.name, size: 51))
            .foregroundColor(.white)
    }
}

Regarding the navigation, there is no more UINavigationController, it got replaced with NavigationView.

So far, so good.

One thing I was really surprised is how easy it is to prepare this transition. My app relies on an AppDelegate for the launch (amongst other things) but it only required to define an App with @main to define the new journey.


//@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // prepare views and window
    }
    
    // ...
}

@main
struct AppyApp: App {
    
    var body: some Scene {
        WindowGroup {
            NavigationView {
                OnboardingFirstView()
            }
        }
    }
}

If I want to launch against the UIKit version, I can comment @main from the app and uncomment @UIApplicationMain to fall back on the previous version. That makes the transition much safer for me to compare behaviors.

Most of the design is straightforward and makes the code migration really enjoyable.

uikit-to-swiftui-migration

Before the end of the onboarding, the mobile user can pick an activity he wants to quit or create its own. At the moment, I haven’t migrated the database structure to be identifiable through SwiftUI so I’ll come back to it later.

Finally, if the user wants to add its own activity to quit, I’ll also need to add this into the database. This isn’t ready yet either, but I can user UserDefaults layer to mock the onboarding has been completed.

Something I noticed through the implementation of the navigation between those screens is the usage of Button over NavigationLink. In my previous UIKit version, I tend to reuse components to sometimes present or push a new view.

However, this is isn’t easily available in SwiftUI. I want to avoid having a condition to define a NavigationLink or a Button depending of the journey. It means that if small views should be reusable, I need to be careful of the reusability of the whole screen itself for a different journey.

Alright, the on boarding is ready, we can dive into the dashboard side.

Dashboard

The dashboard is the main view of the app. This is where the user will land most of the time. Aside of huge timer, it’s set with two mains buttons, one to get more information, one to log the progress for your journey.

Making those small time counter as separate view tidy up a lot of code and with some binding system, it’s going to be really easy to refresh it. SwiftUI and Combine looks really powerful for those implementation

uikit-to-swiftui-migration-2

On the top right corner, the user can access the app information view. It’s also a very simple content. Like many other apps I’ve built, it’s aimed to help people, so I’m always available for feedback. At the bottom of the view, users can send open a mail to send me feedbacks of questions.

This button navigation to the email become a much bigger challenge that I anticipated. In the UIKit version, I’ve used MFMailComposeViewController to open Mail client with an email pre-filled. However, this view isn’t available from SwiftUI.

Straight from the top, MFMailComposeViewControllerDelegate is expecting to be an NSObjectProtocol where the SwiftUI view is a struct. No choice, I’ll have to bridge back to UIKit.

First, I need a new class that will be the delegate of my view controller and handle when to dismiss it.

import MessageUI
import SwiftUI

class MailControllerDelegate: NSObject, MFMailComposeViewControllerDelegate {

    @Binding var isPresented: Bool

    init(isPresented: Binding<Bool>) {
        _isPresented = isPresented
    }

    func mailComposeController(_ controller: MFMailComposeViewController,
                               didFinishWith result: MFMailComposeResult,
                               error: Error?) {
        isPresented = false
    }
}

I will also need a new view representation to bridge it back to SwiftUI world.

struct MailView: UIViewControllerRepresentable {
    typealias Coordinator = MailControllerDelegate

    @Binding var isPresented: Bool
    
    func makeCoordinator() -> MailControllerDelegate {
        MailControllerDelegate(isPresented: $isPresented)
    }

    func makeUIViewController(context: UIViewControllerRepresentableContext<MailView>) -> MFMailComposeViewController {
        let viewController = MFMailComposeViewController()
        viewController.mailComposeDelegate = context.coordinator
        return viewController
    }

    func updateUIViewController(_ uiViewController: MFMailComposeViewController,
                                context: UIViewControllerRepresentableContext<MailView>) {

    }
}

Finally, I can use it into my SwiftUI views with a specific condition to display it.

struct InformationBottomView: View {

    @State private var isEmailPresented = false

    var body: some View {
        VStack(alignment: .center, 
                spacing: 5) {
            
            if MFMailComposeViewController.canSendMail() {
                emailButton
                    .sheet(isPresented: $isEmailPresented) {
                        MailView(isPresented: $isEmailPresented)
                    }
            }
            
            closeButton
        }
    }
}

Unfortunately, it’s not available on simulator but it works decently on physical device.

uikit-to-swiftui-migration-3

The final part of the application is to be able to log the user daily progress. That will be the last step.

Logging

To add new record to the user progress, the app captures it through two views:

  • If the user stayed sober
  • How the user feel today.

The flow is presented as a sheet but with a NavigationView at its root, to be able to go through those two views.

Unfortunately, when the user finishes the flow, I cannot use presentationMode to dismiss the modal display and here is why.

If you Google how to dismiss or pop a view, everything point to the presentationMode. However, to dismiss a modal navigation view from a detailed one is not that easy.

presentationMode: A binding to the current presentation mode of the view associated with this environment.

Reading the documentation, if I use this variable, I can only pop to the previous screen, I cannot dismiss the whole stack.

In UIKit, we could get access to the navigation controller from a child view controller and turn things around, but not in SwiftUI.

To work around this, I’ve came across this solution that explain how to create our own environment key, so that the detail view will get access to the sheet state.

struct ShowingSheetKey: EnvironmentKey {
    static let defaultValue: Binding<Bool>? = nil
}

extension EnvironmentValues {
    var showingSheet: Binding<Bool>? {
        get { self[ShowingSheetKey.self] }
        set { self[ShowingSheetKey.self] = newValue }
    }
}

Now, I can bind the dashboard toggle to the presented view through the environment storage.

struct DashboardView: View {
    
    @State private var isLoggerPresented = false

    var body: some View {
        VStack {
            // ...

            createButton
                .sheet(isPresented: $isLoggerPresented, content: {
                    NavigationView {
                        LogActivityView()
                    }
                    .environment(\.showingSheet, $isLoggerPresented)
                })
        }
    }
}

struct RatingView: View {
    @Environment(\.showingSheet) var showingSheet

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(data, id: \.self) {
                    RatingViewRow(rating: $0)
                    .onTapGesture {
                        // dismissing from detail view on tap
                        showingSheet?.wrappedValue = false
                    }
                }
            }
        }
    }

Here is how it looks.

uikit-to-swiftui-migration-4

Great, that’s exactly what we wanted!


That’s it for the navigation and the design code migration. At the end, I got rid of the AppDelegate, view controllers and storyboards for a simpler set of SwiftUI views.

If SwiftUI makes it really easy to prototype and get this new code ready, there are still some challenges regarding the navigation and older components.

It almost always requires to bridge back to UIKit. It happened when using AVFoundation framework, it happened again today when using MessageUI framework.

Those are pain points that you want to anticipate before starting a code migration at a bigger scale, but we managed to work around it today.

Next step will be to tackle database and prepare the business logic to make the app work.

See you next week


© 2023 Benoit Pasquier. All Rights Reserved
Author's picture

Benoit Pasquier

Software Engineer πŸ‡«πŸ‡·, writing about career development, mobile engineering and self-improvement

ShopBack πŸ’°

Singapore πŸ‡ΈπŸ‡¬