Swift and SwiftUI tutorials for Swift Developers

SwiftUI and AppKit Integration

The Apple ecosystem has experienced a radical transformation in recent years. With the arrival of the declarative paradigm, Swift programming has reached a new level of maturity, allowing software creators to design complex interfaces with a fraction of the code required by traditional frameworks. However, if you are an iOS Developer looking to make the leap to Mac desktop development, you will soon discover that the macOS ecosystem has its own rules.

Although SwiftUI is incredibly powerful and is shaping up to be the undisputed future of interface development across all Apple platforms, the reality is that there are still scenarios where its native components do not cover 100% of the needs of a complex desktop application. This is where SwiftUI and AppKit integration comes into play.

AppKit is the foundational graphical interface framework for macOS (the equivalent of UIKit on iOS). It has decades of evolution and possesses incredibly granular controls that have not yet been fully ported to the new declarative technology. In this extensive tutorial, you will learn step by step how to master this integration within Xcode, combining the best of both worlds to create robust, modern, and limitless macOS applications.


1. The Why of Integration: Understanding Both Worlds

For an iOS Developer accustomed to UIView and UIViewController, the jump to macOS involves getting to know their direct equivalents: NSView and NSViewController. The architecture is similar, but AppKit has historical quirks, such as a coordinate system that traditionally starts at the bottom-left corner (instead of the top-left like in iOS) and much more complex mouse and keyboard event handling.

When do you need AppKit?

Although SwiftUI has improved drastically, you might need to fall back on AppKit in the following scenarios:

  • Advanced text controls: If you are building a code editor or a word processor, NSTextView offers control over TextKit, rulers, tab stops, and text selection that the declarative TextEditor still cannot match.
  • Legacy views or third-party libraries: If you have an old codebase written in Swift or Objective-C, rewriting it completely is not always cost-effective.
  • System-specific visual effects: Controls like NSVisualEffectView to achieve the exact vibrant blur of macOS sometimes require precise adjustments that only AppKit allows.
  • Complex window management: Manipulating native toolbars, floating panels, or specific window behaviors (NSWindow).

Fortunately, Apple designed its new framework with interoperability in mind. The SwiftUI and AppKit integration is bidirectional: you can embed an AppKit view into your declarative hierarchy, and you can embed your declarative design within a traditional AppKit application.


2. Bringing AppKit to SwiftUI: Understanding NSViewRepresentable

The star protocol for this task is NSViewRepresentable. It is the macOS equivalent to the UIViewRepresentable that you probably already know in iOS. By adopting this protocol, you create a “wrapper” that tells the Xcode compiler how to instantiate, update, and tear down a native Mac view within a declarative flow.

The Lifecycle of NSViewRepresentable

For the SwiftUI and AppKit integration to work, your structure must implement at least two mandatory methods:

  1. makeNSView(context:): This is where you instantiate your NSView for the first time. This method is called exactly once when the view enters the hierarchy.
  2. updateNSView(_:context:): This method is called every time your application’s state changes. This is where you read SwiftUI properties and update the corresponding properties of your AppKit view to reflect those changes.

Additionally, there is a crucial concept: the Coordinator. Since AppKit views use the Delegate pattern to communicate events (like when the user types text), and declarative views are value structures that are constantly recreated, we need a reference object (a class) that survives these recreations and acts as a delegate. That is the role of the Coordinator.


3. Step-by-Step Tutorial: Creating a Rich Text Editor

Let’s put Swift programming into practice by creating a component that encapsulates a custom NSTextView. We want a text editor with no background (to blend seamlessly with the app’s design) that supports rich text, something the standard TextEditor heavily restricts.

Step 3.1: Setting up the Environment in Xcode

  1. Open Xcode and create a new “App” project for macOS.
  2. Make sure to select SwiftUI as the interface and Swift as the language.
  3. Create a new Swift file (File > New > File…) and name it RichTextEditor.swift.

Step 3.2: Writing the Wrapper

We’ll start by importing both frameworks and defining our NSViewRepresentable struct.

import SwiftUI
import AppKit

// 1. We define our struct conforming to the protocol
struct RichTextEditor: NSViewRepresentable {
    // We use a Binding so SwiftUI can read and write the text
    @Binding var text: String
    
    // 2. Native view initialization method
    func makeNSView(context: Context) -> NSTextView {
        let scrollView = NSTextView.scrollableTextView()
        
        // We get the NSTextView that is inside the ScrollView
        guard let textView = scrollView.documentView as? NSTextView else {
            return NSTextView()
        }
        
        // AppKit specific configurations
        textView.backgroundColor = .clear
        textView.isRichText = true
        textView.allowsUndo = true
        textView.font = NSFont.systemFont(ofSize: 16)
        
        // We assign the delegate (our Coordinator)
        textView.delegate = context.coordinator
        
        return textView
    }
    
    // 3. Update method
    func updateNSView(_ nsView: NSTextView, context: Context) {
        // We only update the text if it's different to avoid infinite loops
        if nsView.string != text {
            nsView.string = text
        }
    }
}

At this point, we have created the native view and configured its appearance. However, if you compile now, Xcode will throw an error if you try to type, because we are missing the communication bridge back: the Coordinator.

Step 3.3: Implementing the Coordinator

The Coordinator will be in charge of listening to when the user types in the NSTextView (using AppKit) and sending that information to our SwiftUI @Binding.

Add the following inside your RichTextEditor.swift file:

extension RichTextEditor {
    // 4. We create the Coordinator
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }
    
    // 5. We define the Coordinator class
    class Coordinator: NSObject, NSTextViewDelegate {
        var parent: RichTextEditor
        
        init(_ parent: RichTextEditor) {
            self.parent = parent
        }
        
        // This AppKit method is called every time the text changes
        func textDidChange(_ notification: Notification) {
            guard let textView = notification.object as? NSTextView else { return }
            
            // We update the state variable in SwiftUI
            DispatchQueue.main.async {
                self.parent.text = textView.string
            }
        }
    }
}

Step 3.4: Using the Component in SwiftUI

Now that our SwiftUI and AppKit integration is complete for this component, let’s use it like any other native view. Open your ContentView.swift.

import SwiftUI

struct ContentView: View {
    @State private var documentText: String = "Type your rich text here..."
    
    var body: some View {
        VStack(alignment: .leading, spacing: 20) {
            Text("Hybrid Editor")
                .font(.largeTitle)
                .bold()
                .padding(.top)
            
            // Here we use our custom component
            RichTextEditor(text: $documentText)
                .frame(minWidth: 400, minHeight: 300)
                .padding()
                .background(Color.gray.opacity(0.1))
                .cornerRadius(12)
                .overlay(
                    RoundedRectangle(cornerRadius: 12)
                        .stroke(Color.blue, lineWidth: 1)
                )
            
            Text("Characters: \(documentText.count)")
                .foregroundColor(.secondary)
        }
        .padding()
    }
}

As an iOS Developer, this structure will feel completely familiar to you. We have managed to encapsulate the complexity of delegate management and AppKit lifecycles behind a purely declarative interface.


4. The Reverse Path: Bringing SwiftUI to AppKit

Now let’s imagine the opposite scenario. You have an existing macOS application, built entirely with NSViewController and Storyboards (or XIBs), and you want to start migrating certain screens to the new technology without rewriting the entire app.

For this process of SwiftUI and AppKit integration, Swift programming provides us with the NSHostingView class (and its sibling NSHostingController). These classes act as “containers” that host a declarative hierarchy but, in the eyes of the operating system, behave exactly like AppKit views and controllers.

Step 4.1: Creating the Declarative View

First, let’s design a modern and attractive view using all the power of the declarative framework. Create a new file in Xcode called ModernDashboardView.swift.

import SwiftUI

struct ModernDashboardView: View {
    @State private var progress: Double = 0.5
    
    var body: some View {
        VStack(spacing: 30) {
            Image(systemName: "swift")
                .resizable()
                .scaledToFit()
                .frame(width: 80, height: 80)
                .foregroundColor(.orange)
            
            Text("Modern Dashboard")
                .font(.system(size: 24, weight: .bold, design: .rounded))
            
            ProgressView("Integration Progress", value: progress)
                .progressViewStyle(.linear)
                .padding(.horizontal, 40)
            
            Button("Update Data") {
                withAnimation {
                    progress = Double.random(in: 0...1)
                }
            }
            .buttonStyle(.borderedProminent)
            .controlSize(.large)
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .background(VisualEffectBlur().ignoresSafeArea()) // Assuming a custom blur
    }
}

Step 4.2: Embedding the View using NSHostingView

Now, let’s go to your traditional AppKit file, for example, a MainViewController.swift that inherits from NSViewController. I will show you how to inject our ModernDashboardView programmatically.

import Cocoa
import SwiftUI // Essential to import SwiftUI

class MainViewController: NSViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupSwiftUIView()
    }
    
    private func setupSwiftUIView() {
        // 1. We instantiate our SwiftUI view
        let swiftUIView = ModernDashboardView()
        
        // 2. We create the NSHostingView wrapping our view
        let hostingView = NSHostingView(rootView: swiftUIView)
        
        // 3. We prepare Auto Layout
        hostingView.translatesAutoresizingMaskIntoConstraints = false
        
        // 4. We add the hostingView to the AppKit hierarchy
        self.view.addSubview(hostingView)
        
        // 5. We configure the constraints so it takes up all the space
        NSLayoutConstraint.activate([
            hostingView.topAnchor.constraint(equalTo: view.topAnchor),
            hostingView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            hostingView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            hostingView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])
    }
}

With just a few lines of code, you have modernized a classic application. The NSHostingView takes care of translating mouse clicks, scrolling, and keyboard events from the macOS system directly to the modifiers of the declarative view. For the end user, the transition is completely invisible and native.


5. Advanced State Management and Data Sharing

One of the biggest challenges for any iOS Developer when facing the SwiftUI and AppKit integration is how to share state (data) fluidly between old imperative code and new reactive code.

In Swift programming, the best practice is to use ObservableObject. Imagine you have a data model managing your application’s internet connection state.

import Foundation
import Combine

class NetworkState: ObservableObject {
    @Published var isConnected: Bool = true
    @Published var ping: Int = 20
}

Injecting Dependencies into AppKit

If you are embedding your view in AppKit with an NSHostingView, you can pass this data model as an EnvironmentObject directly during initialization:

// Inside your NSViewController
let networkState = NetworkState()
let swiftUIView = ModernDashboardView().environmentObject(networkState)
let hostingView = NSHostingView(rootView: swiftUIView)

Listening to SwiftUI Changes in AppKit

If you need your AppKit controller to react to changes happening inside the declarative view, you can subscribe to the Combine publishers of the ObservableObject:

var cancellables = Set<AnyCancellable>()

networkState.$isConnected
    .receive(on: RunLoop.main)
    .sink { isConnected in
        print("AppKit detected a network change: \(isConnected)")
        // Update native AppKit UI if necessary
    }
    .store(in: &cancellables)

This pattern ensures that your architecture follows a unidirectional data flow, keeping the code clean and free of race conditions, maximizing your app’s stability on macOS.


6. Best Practices and Performance in Xcode

Integrating two such different paradigms is not without technical challenges. Below, I detail the best practices that every professional should follow when working in Xcode:

  • Minimize updates in updateNSView: The updateNSView method can be called multiple times per second if there are fluid animations or continuous state changes. Never instantiate new heavy objects or make network calls inside this function. Limit yourself to reading the context and mutating direct properties of the native view.
  • Beware of Memory Leaks: When creating the Coordinator, make sure you are not creating strong retain cycles with the delegates. In our previous example, the NSTextView delegate is weak by default under the AppKit hood, but always check the documentation for the class you are implementing.
  • The dismantleNSView method: If your AppKit view requires manual cleanup (like stopping a timer, invalidating a KVO observer, or freeing memory from a graphics engine), be sure to implement the optional static function dismantleNSView(_:coordinator:) within your representable struct.
  • Take advantage of the Canvas (Previewer): One of the biggest advantages of creating wrappers is that you can preview old AppKit views on the Xcode Canvas. Simply wrap your NSView in the representable struct and call it within a PreviewProvider struct. This speeds up design exponentially, as you don’t have to compile the entire Mac app to see changes to an old custom button.

Conclusion

The iOS Developer‘s journey toward mastery on macOS requires understanding and respecting the legacy of Apple’s desktop operating system. The SwiftUI and AppKit integration is not a “hack” or a temporary workaround; it is a fundamental design feature of modern Swift programming.

Whether you are building a complex application from scratch using declarative views and relying on NSViewRepresentable to squeeze maximum native performance out of legacy components, or injecting new life into a monolithic enterprise app using NSHostingView, Xcode provides you with all the necessary tools to achieve a perfect bridge architecture.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

What's New in Swift 6.4

Next Article

Integrating SwiftUI into UIKit

Related Posts