Mixing Swift, Objective-C and C++ in an App

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Few long-lived Apple apps are pure Swift. An app that began before 2014 usually still carries Objective-C, and apps that do audio, imaging, geometry or text processing often link a C++ library shared with other platforms. Swift was designed to live alongside both. This page covers the app-project side of that coexistence, on the section’s baseline: iOS, iPadOS, macOS, watchOS and visionOS 27, Xcode 27 and Swift 6.4, with a deployment minimum of 26. It explains which files and build settings connect the languages, how a mixed-language FieldNotes target is wired, and how to move code from Objective-C to Swift without a rewrite.

The language rules (how the Clang importer maps C types, how Objective-C generics, errors and blocks look in Swift, and what C++ constructs Swift supports) are in the reference sections, so this page doesn’t repeat them:

The FieldNotes Mixed-Language Slice

FieldNotes is written in Swift, but two pieces of it come from elsewhere, and both appear in every example below:

  • FNLegacyImporter, an Objective-C class inherited from the team’s older notes app. It reads exported notebooks from disk and hands back FNImportedNote objects. It lives in the FieldNotes app target under FieldNotes/Legacy/, next to a small helper, FNNoteFormatter, which is the class migrated to Swift at the end of this page.

  • fieldnotes::TextStats, a C++ library that also ships in the team’s Android and web back-end builds. It counts words, estimates reading time and ranks frequent terms for the note inspector. It lives in a local package, Packages/TextStats, next to FieldNotesKit and FieldNotesUI.

Figure: How FieldNotes’s languages see each other

flowchart LR subgraph AppTarget["FieldNotes app target"] SW["Swift sources
NoteStore, ImportService, NoteInspector"] BH["FieldNotes-Bridging-Header.h
imports FNLegacyImporter.h"] GH["FieldNotes-Swift.h
(generated by the Swift compiler)"] OC["Objective-C sources
FNLegacyImporter.m, FNNoteFormatter.m"] CPA["C++ sources
TagIndexBridge.cpp"] end subgraph Pkg["Packages/TextStats"] MM["module map
(generated from the umbrella header)"] CXX["C++ headers and sources
fieldnotes::TextStats"] NS["NoteStatistics
(Swift wrapper, C++ interop mode on)"] end OC -- "headers listed in" --> BH BH -- "Objective-C → Swift" --> SW SW -- "@objc declarations emitted into" --> GH GH -- "imported by .m files: Swift → Objective-C" --> OC CXX -- "described by" --> MM MM -- "import CxxTextStats" --> NS NS -- "import NoteStatistics
(app target: C++ interop mode on)" --> SW SW -. "public Swift APIs, C++ bindings in" .-> GH GH -. "included by .cpp / .mm files" .-> CPA

Every arrow into Swift is an import: a header or module that the Clang importer embedded in the Swift compiler reads and turns into Swift declarations. Every arrow out of Swift goes through the generated header, an Objective-C (and optionally C++) header that the Swift compiler writes on each build. The dashed arrows are active only when C++ interoperability is turned on for the target.

Bridging Header or Module Map?

Swift never reads .h files directly. It imports Clang modules, and there are two ways to give it one.

A bridging header is the app-target mechanism. It is a single header, named <ProductModuleName>-Bridging-Header.h by convention, that imports (#import) every Objective-C (or C) header the target’s Swift code should see. Everything it exposes is visible in every Swift file of that target, without an import statement. Xcode offers to create it the first time you add an Objective-C file to a Swift app, or the reverse, and stores its path in the Objective-C Bridging Header build setting (SWIFT_OBJC_BRIDGING_HEADER):

// FieldNotes/FieldNotes-Bridging-Header.h
//  Every header listed here is visible to all Swift code in the FieldNotes target.
#import "Legacy/FNLegacyImporter.h"
#import "Legacy/FNImportedNote.h"
#import "Legacy/FNNoteFormatter.h"
// Config/FieldNotes.xcconfig
// Written by Xcode when it creates the bridging header:
SWIFT_OBJC_BRIDGING_HEADER = FieldNotes/FieldNotes-Bridging-Header.h
// Left at its default; names the generated header described in the next section:
SWIFT_OBJC_INTERFACE_HEADER_NAME = $(SWIFT_MODULE_NAME)-Swift.h

A module map is the mechanism for frameworks and packages. A module.modulemap file declares a named module and lists the headers it contains. Swift code then imports that module by name, like any other module:

// Packages/TextStats/Sources/CxxTextStats/include/module.modulemap
// (SwiftPM generates an equivalent file automatically from the umbrella header CxxTextStats.h;
//  it is written out here only to show what Swift reads.)
module CxxTextStats {
    header "CxxTextStats.h"
    requires cplusplus
    export *
}

In a framework target, Xcode generates the module map from the framework’s umbrella header when Defines Module (DEFINES_MODULE) is YES. In a SwiftPM C or C++ target, SwiftPM generates it when the target’s include/ directory holds a header named after the target. The choice therefore follows the target type:

Bridging header Module map

Used by

App targets, test targets and command-line tools.

Framework targets, SwiftPM C/C++ targets, system libraries.

Visible in Swift

Implicitly, in every file of the one target.

Explicitly, with import ModuleName, in any target that depends on it.

Scales to

One target. A bridging header can’t be shared, and frameworks don’t support one.

Any number of clients. The module is reusable and cached.

FieldNotes uses it for

The legacy Objective-C code in the app target.

The CxxTextStats C++ library.

The long-term direction is clear. As the legacy code shrinks, the bridging header shrinks with it, and code that several targets need moves into a package with its own module. Packages and module maps are covered in more depth on Swift Packages and Modularizing an App and in the Swift reference’s module map section.

The Generated FieldNotes-Swift.h Header

The reverse direction needs no configuration. On every build, the Swift compiler writes an Objective-C header that declares the target’s Objective-C-compatible Swift API. Its name is the product module name followed by -Swift.h. Non-alphanumeric characters become _, so the FieldNotes target produces FieldNotes-Swift.h. An Objective-C .m file in the same target imports it like any other header:

// FieldNotes/Legacy/FNLegacyImporter.m
#import "FNLegacyImporter.h"
#import "FieldNotes-Swift.h"          // the Swift half of the FieldNotes target

@implementation FNLegacyImporter
- (void)didReadFileAtIndex:(NSUInteger)index ofTotal:(NSUInteger)total {
    // FNImportProgressReporter is a Swift class, visible here through the generated header.
    [FNImportProgressReporter.shared reportWithFraction:(double)(index + 1) / (double)total];
}
@end

What goes into the header depends on the target type:

  • In an app target, the header contains public and open declarations that Objective-C can represent. If the target also has a bridging header, it contains internal ones too, which is the common case in a mixed app like FieldNotes. private and fileprivate declarations never appear, unless they are marked @objc, @IBAction or @IBOutlet.

  • In a framework target, only public and open declarations appear, because the header is part of the framework’s public interface. It is imported as #import <FieldNotesLegacy/FieldNotesLegacy-Swift.h>.

The header is generated during the build, not stored in the repository. To read it, Command-click a Swift class name from an Objective-C file, or open it from the build products. An abridged excerpt for FieldNotes looks like this (the exact macros vary between compiler versions):

// FieldNotes-Swift.h (generated, abridged)
SWIFT_CLASS_NAMED("ImportProgressReporter")
@interface FNImportProgressReporter : NSObject
@property (nonatomic, class, readonly, strong) FNImportProgressReporter * _Nonnull shared;
- (void)reportWithFraction:(double)fraction;
- (nonnull instancetype)init OBJC_DESIGNATED_INITIALIZER;
@end

One structural rule causes most build cycles in mixed targets. An Objective-C header must never import FieldNotes-Swift.h. The generated header refers to the Objective-C types its Swift declarations use, so a header that imports it creates a cyclical reference. Headers use forward declarations instead, and only .m files import the generated header:

// FieldNotes/Legacy/FNLegacyImporter.h (excerpt)
@class FNImportProgressReporter;          // a Swift class, forward-declared
@protocol FNNoteSyncObserving;            // a Swift @objc protocol, forward-declared

@interface FNLegacyImporter : NSObject
@property (nonatomic, weak, nullable) id<FNNoteSyncObserving> observer;
@end

Exposing Swift with @objc and @objcMembers

A Swift declaration reaches the generated header only if it can be represented in Objective-C and is marked for export:

  • Classes must inherit, directly or indirectly, from NSObject (or another Objective-C class). Swift can’t export a root Swift class, a struct, an enum with associated values, a generic type or a protocol with associated types. Objective-C has no equivalent for any of them.

  • Members need @objc individually, unless the class is marked @objcMembers. That attribute exports every member that can be represented, including those added in extensions.

  • @objc(Name) chooses the Objective-C name, which is how FieldNotes keeps the FN class prefix that Objective-C needs, without putting it on the Swift name.

  • Integer-backed enums can be exported with @objc enum Name: Int. They become NS_ENUMs.

// FieldNotes/Import/ImportProgressReporter.swift
import Foundation
import FieldNotesServices

/// Called from FNLegacyImporter.m as imports progress.
@objc(FNImportProgressReporter)
@objcMembers
final class ImportProgressReporter: NSObject {
    static let shared = ImportProgressReporter()   // exported as the class property `shared`

    private(set) var fraction = 0.0

    func report(fraction: Double) {                // exported as -reportWithFraction:
        self.fraction = fraction
    }

    func finish(with notes: [Note]) { }            // NOT exported: Note is a Swift struct
}

@objc(FNImportStage)
enum ImportStage: Int {
    case reading, converting, saving              // FNImportStageReading, ...
}

@objc(FNNoteSyncObserving)
protocol NoteSyncObserving: AnyObject {
    func importerDidFinish(count: Int)
}

@objcMembers silently skips members that Objective-C can’t represent, such as finish(with:) above. Plain @objc on such a member is a compile error instead, which is often more useful. Prefer @objc on exactly the members Objective-C calls, and keep @objcMembers for small bridge classes like this one. Each exported member also costs binary size and Objective-C metadata. The attribute rules themselves are in Attributes and Compiler Control.

Actor isolation crosses the bridge too. New Xcode 27 app targets default to main-actor isolation (SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor), so an exported class in the app target is @MainActor unless it says otherwise. Objective-C doesn’t enforce isolation. A .m file that calls -reportWithFraction: from a background dispatch queue compiles cleanly, but it breaks the class’s isolation, and Swift 6’s runtime checks can trap on it. Either call the exported API on the main thread, or mark the members that background code calls as nonisolated and make them thread-safe. The build settings are explained on Swift Concurrency in Apps, and the model behind them on Actors, Isolation and Sendable.

Shaping the Swift API from Objective-C Headers

Without annotations, an Objective-C header imports poorly. Every object pointer becomes an implicitly unwrapped optional (String!), prefixed names stay prefixed, and C-style out-parameters stay UnsafeMutablePointers. A few macros in the header fix all of that, with no change to the Objective-C implementation. Here is FNLegacyImporter.h after FieldNotes’s cleanup pass:

// FieldNotes/Legacy/FNLegacyImporter.h
#import <Foundation/Foundation.h>
@class FNImportedNote;

NS_ASSUME_NONNULL_BEGIN                                   // every pointer below is nonnull unless marked

typedef NS_ENUM(NSInteger, FNImportFormat) {
    FNImportFormatPlainText,
    FNImportFormatMarkdown,
    FNImportFormatLegacyArchive,
} NS_SWIFT_NAME(LegacyImporter.Format);                   // suffix position for enums

NS_SWIFT_NAME(LegacyImporter)                             // prefix position for classes
@interface FNLegacyImporter : NSObject

- (instancetype)initWithDirectoryURL:(NSURL *)directoryURL NS_DESIGNATED_INITIALIZER;
- (instancetype)init NS_UNAVAILABLE;

@property (nonatomic, readonly, copy) NSURL *directoryURL;
@property (nonatomic, nullable, copy) NSString *defaultNotebookName;

- (nullable NSArray<FNImportedNote *> *)notesInFileAtPath:(NSString *)path
                                                    error:(NSError **)error
    NS_SWIFT_NAME(notes(inFileAtPath:));

- (void)importAllWithFormat:(FNImportFormat)format
          completionHandler:(void (^)(NSArray<FNImportedNote *> * _Nullable notes,
                                      NSError * _Nullable error))completionHandler;

- (void)getProgressFraction:(double *)fraction
                 notesTotal:(NSInteger *)total NS_REFINED_FOR_SWIFT;

@end

NS_ASSUME_NONNULL_END

Swift sees roughly the following interface. Xcode shows the exact one: open the header, then choose Related Items → Generated Interface (Related Items is the grid button at the top left of the editor).

// Generated Swift interface for FNLegacyImporter.h (approximate)
extension LegacyImporter {
    enum Format: Int, @unchecked Sendable {
        case plainText = 0
        case markdown = 1
        case legacyArchive = 2
    }
}

class LegacyImporter: NSObject {
    init(directoryURL: URL)
    var directoryURL: URL { get }
    var defaultNotebookName: String?

    func notes(inFileAtPath path: String) throws -> [FNImportedNote]

    func importAll(with format: LegacyImporter.Format,
                   completionHandler: @escaping ([FNImportedNote]?, (any Error)?) -> Void)
    func importAll(with format: LegacyImporter.Format) async throws -> [FNImportedNote]

    func __getProgressFraction(_ fraction: UnsafeMutablePointer<Double>,
                               notesTotal total: UnsafeMutablePointer<Int>)
}

Each annotation did one job:

Annotation Effect on the Swift interface Where in FieldNotes

NS_ASSUME_NONNULL_BEGIN/END

Unannotated pointers import as non-optional instead of !.

The whole header.

nullable / _Nullable

Marks the exceptions, which import as optionals.

defaultNotebookName, the block’s arguments.

NS_SWIFT_NAME(...)

Renames or nests a type, method or enum for Swift only. Objective-C keeps its name.

LegacyImporter, LegacyImporter.Format, notes(inFileAtPath:).

NSError ** last parameter

The method becomes throws, and the error parameter disappears.

notes(inFileAtPath:).

NS_REFINED_FOR_SWIFT

Imports the method with a __ prefix, so a hand-written Swift wrapper can replace it.

__getProgressFraction(_:notesTotal:).

NS_DESIGNATED_INITIALIZER, NS_UNAVAILABLE

Controls which initializers Swift offers and which subclasses must override.

init(directoryURL:) only.

NS_REFINED_FOR_SWIFT is completed in Swift. The double-underscore original stays callable, but only the wrapper appears in code completion:

// FieldNotes/Legacy/LegacyImporter+Swift.swift
extension LegacyImporter {
    /// The Swift-shaped replacement for -getProgressFraction:notesTotal:.
    var progress: (fraction: Double, total: Int) {
        var fraction = 0.0
        var total = 0
        __getProgressFraction(&fraction, notesTotal: &total)
        return (fraction, total)
    }
}

Three related macros follow the same pattern. NS_SWIFT_UNAVAILABLE("…") hides a declaration from Swift. NS_TYPED_EXTENSIBLE_ENUM groups string constants into a Swift struct with static members. API_AVAILABLE(...) is the Objective-C counterpart of the @available attribute described on One Codebase for iOS, iPadOS, macOS, watchOS and visionOS:

// FieldNotes/Legacy/FNImportedNote.h (excerpt)
typedef NSString *FNNoteSource NS_TYPED_EXTENSIBLE_ENUM;   // Swift: struct FNNoteSource, e.g. .evernote
extern FNNoteSource const FNNoteSourceEvernote;
extern FNNoteSource const FNNoteSourcePlainFolder;

@interface FNImportedNote : NSObject
@property (nonatomic, copy) FNNoteSource source;
- (void)setTitleUsingCString:(const char *)title
    NS_SWIFT_UNAVAILABLE("Set the title property instead");   // absent from the Swift interface
- (void)attachSummary:(NSString *)summary
    API_AVAILABLE(ios(27.0), macos(27.0), visionos(27.0));      // Swift: @available(iOS 27, ...)
@end

The full catalog, with more examples, is on the Objective-C reference’s Swift Interoperability page.

Completion Handlers Imported as async

The interface above contains two importAll methods. When an Objective-C method returns void and takes a completion block as its last parameter, Swift imports it twice: once as written, with a closure, and once as an async method. The async form drops the block, returns what the block would have received, and throws if the block has a nullable NSError * parameter. This conversion runs on your own headers exactly as it does on Apple’s frameworks, so legacy Objective-C code gets a modern call site for free:

// FieldNotes/Import/ImportService.swift
import Foundation
import FieldNotesModel
import FieldNotesServices

struct ImportService {
    let store: NoteStore

    func importLegacyNotebooks(from directory: URL) async throws {
        let importer = LegacyImporter(directoryURL: directory)
        importer.defaultNotebookName = "Imported"

        // -importAllWithFormat:completionHandler: called as an async throwing method.
        let imported = try await importer.importAll(with: .markdown)

        for legacy in imported {
            store.add(Note(legacy))       // main actor, like NoteStore; Note.init(_: FNImportedNote) is a Swift extension
        }
    }
}

The importer recognizes a completion handler by its selector. A single-parameter method must end in WithCompletion, WithCompletionHandler, WithCompletionBlock, WithReply or WithReplyTo. For a method with more parameters, the last selector piece must be completion, completionHandler, completionBlock, reply, replyTo, or one of these with a with prefix. The Swift name then drops the completion piece and a leading get or a trailing Asynchronously. A block that receives several values produces a tuple.

The conversion has a correctness condition the compiler can’t check: the Objective-C method must call its block exactly once on every path. If FNLegacyImporter returned early on a cancelled import without calling the block, the await above would hang forever. In Swift 6 language mode, a missed or double call from the continuation behind the async form traps or is reported as a leak. Audit each legacy method before relying on its async form. When a method can’t be fixed, wrap it by hand with withCheckedThrowingContinuation, as shown on Swift Concurrency in Apps. The general language semantics are on Async/Await and Tasks.

Turning On C++ Interoperability

Swift imports C and Objective-C by default. C++ is opt-in, per target, because turning it on changes how the target imports every C-family header, including some existing C and Objective-C APIs. It is a Swift 5.9 feature, supported since Xcode 15, and is still evolving. Swift.org documents which C++ constructs are supported, and new toolchains keep compatibility versions so existing code isn’t broken.

In an Xcode target

Set Build Settings → Swift Compiler - Language → C++ and Objective-C Interoperability to C++ / Objective-C++. The underlying setting is SWIFT_OBJC_INTEROP_MODE:

// Config/FieldNotes.xcconfig
SWIFT_OBJC_INTEROP_MODE = objcxx     // default: objc (C and Objective-C only)
CLANG_CXX_LANGUAGE_STANDARD = c++20

With the setting on, a target can do three things:

  • mix Swift and C++ (or Objective-C++) in the same target. An app target exposes its own C++ headers to Swift through the bridging header, with #include. A framework target exposes them through its public headers;

  • import C++ APIs from framework targets and packages it depends on;

  • call its own public Swift APIs from .cpp and .mm files through the generated header, which now contains C++ bindings as well.

In a Swift package

In a package manifest, the equivalent is the interoperabilityMode(_:_:) Swift setting, available from PackageDescription 5.9. It goes on the Swift target that imports C++. The C++ target itself needs only an umbrella header named after the target in its include/ directory, from which SwiftPM generates the module map:

// swift-tools-version: 6.4
// Packages/TextStats/Package.swift
import PackageDescription

let package = Package(
    name: "TextStats",
    platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26), .visionOS(.v26)],
    products: [
        .library(name: "NoteStatistics", targets: ["NoteStatistics"]),
    ],
    dependencies: [
        .package(path: "../FieldNotesKit"),
    ],
    targets: [
        // Plain C++: Sources/CxxTextStats/{include/CxxTextStats.h, TextStats.cpp}
        .target(name: "CxxTextStats"),

        // Swift wrapper that imports the C++ library.
        .target(
            name: "NoteStatistics",
            dependencies: ["CxxTextStats", .product(name: "FieldNotesModel", package: "FieldNotesKit")],
            swiftSettings: [.interoperabilityMode(.Cxx)]
        ),
    ],
    cxxLanguageStandard: .cxx20
)

The C++ header is ordinary, modern C++:

// Packages/TextStats/Sources/CxxTextStats/include/CxxTextStats.h  (umbrella header)
#pragma once
#include <string>
#include <vector>

namespace fieldnotes {

struct TermCount {
    std::string term;
    int count;
};

class TextStats {
public:
    explicit TextStats(const std::string &text);
    int wordCount() const;
    int readingTimeSeconds(int wordsPerMinute) const;
    std::vector<TermCount> topTerms(int limit) const;

private:
    std::vector<std::string> words_;
};

} // namespace fieldnotes

The Swift wrapper calls the C++ class directly, without an intermediate C layer. The fieldnotes namespace becomes a Swift enum, std::string converts with the CxxStdlib module, int imports as Int32, and std::vector conforms to RandomAccessCollection, so map and for-in work:

// Packages/TextStats/Sources/NoteStatistics/NoteStatistics.swift
import CxxStdlib
import CxxTextStats
import FieldNotesModel

public struct NoteStatistics: Sendable {
    public let wordCount: Int
    public let readingTime: Duration
    public let topTerms: [String]

    public init(note: Note) {
        let stats = fieldnotes.TextStats(std.string(note.body))   // calls the C++ constructor
        wordCount = Int(stats.wordCount())
        readingTime = .seconds(Int(stats.readingTimeSeconds(200)))
        topTerms = stats.topTerms(5).map { String($0.term) }      // std::vector -> Swift Array
    }
}

Two consequences shape how FieldNotes adopts this:

  • C++ interop is contagious. A Swift target built with it must be imported by targets that also enable it. Here, that means the FieldNotes app target sets SWIFT_OBJC_INTEROP_MODE = objcxx, because it imports NoteStatistics. For a published package, enabling the mode is a breaking change that needs a major version. NoteStatistics keeps its public API pure Swift (Int, Duration, [String]) so no C++ type leaks further than necessary.

  • The Objective-C++ shim is still valid. Before Swift 5.9, the only route was an Objective-C class whose .mm implementation calls C++, listed in the bridging header. It still makes sense when turning on C++ mode for the whole app target is too disruptive, or when the C++ API uses features Swift can’t import, such as C++20 modules.

Calling Swift from C++

With the mode on, the generated FieldNotes-Swift.h also declares the target’s public Swift functions and types inside a C++ namespace named after the module. SwiftPM doesn’t yet support this direction for packages, but Xcode app and framework targets do. FieldNotes uses it once, so the shared C++ tagging code can reuse the Swift tag-normalization rules:

// FieldNotes/Tags/TagRules.swift
public func normalizedTag(_ raw: String) -> String {
    raw.trimmingCharacters(in: .whitespaces).lowercased().replacingOccurrences(of: " ", with: "-")
}
// FieldNotes/Tags/TagIndexBridge.cpp
#include <string>
#include "FieldNotes-Swift.h"

std::string normalizeForIndex(const std::string &raw) {
    swift::String tag = FieldNotes::normalizedTag(swift::String(raw));
    return (std::string)tag;
}

Apple’s two sample projects show both layouts in full: Mixing Languages in an Xcode project (Swift and C++ in one framework target), and Calling APIs Across Language Boundaries (several targets and frameworks).

Migrating Objective-C to Swift, One Class at a Time

Interop is what makes a gradual migration possible: nothing forces a rewrite, and every step leaves a working, shippable app. Apple’s recommended unit of work is one class at a time. FieldNotes’s first candidate is FNNoteFormatter, which turns an FNImportedNote into Markdown. It is a good choice because it has no subclasses: Objective-C can’t subclass a Swift class, so a class with Objective-C subclasses must wait until they move first.

  1. Clean up the Objective-C first. Add nullability (NS_ASSUME_NONNULL_BEGIN), lightweight generics (NSArray<FNImportedNote *> *) and NS_SWIFT_NAME to the header, and check that the imported interface reads well. The Swift code that already calls the class improves immediately, and the new Swift class has a well-defined contract to match.

  2. Create the Swift replacement in one .swift file. Swift has no header, so interface and implementation go together. Keep the Objective-C name with @objc(FNNoteFormatter), and inherit from NSObject so existing Objective-C callers keep compiling:

    // FieldNotes/Legacy/NoteFormatter.swift -- replaces FNNoteFormatter.h/.m
    import Foundation
    
    @objc(FNNoteFormatter)
    final class NoteFormatter: NSObject {
        @objc(markdownForNote:)
        func markdown(for note: FNImportedNote) -> String {
            var lines = ["# \(note.title)"]
            if !note.tags.isEmpty {
                lines.append(note.tags.map { "#\($0)" }.joined(separator: " "))
            }
            lines.append("")
            lines.append(note.body)
            return lines.joined(separator: "\n")
        }
    }
  3. Switch Objective-C callers to the generated header. In each .m file that used the class, replace #import "FNNoteFormatter.h" with #import "FieldNotes-Swift.h", and remove FNNoteFormatter.h from the bridging header. Because the Objective-C name and selector are unchanged, the call sites themselves don’t change.

  4. Remove the old implementation from the target. Uncheck FNNoteFormatter.m in the File inspector’s Target Membership, or the build fails with duplicate symbols. Keep the file in the repository for a release or two, as a reference while debugging.

  5. Ship, then modernize. Once all callers of the class are Swift, drop @objc, NSObject and the FN name. Then turn the class into a struct, or a function on Note in FieldNotesModel, and run Unit Testing with Swift Testing tests against the old behavior.

  6. Repeat, leaves first. Work up the dependency graph from classes nothing else depends on. The bridging header empties out as you go. When it is empty, delete it and clear SWIFT_OBJC_BRIDGING_HEADER, and the target is pure Swift again.

// FieldNotes/Legacy/FNLegacyImporter.m -- after step 3; the call site is unchanged
#import "FieldNotes-Swift.h"          // was: #import "FNNoteFormatter.h"

- (NSString *)markdownForImportedNote:(FNImportedNote *)note {
    FNNoteFormatter *formatter = [[FNNoteFormatter alloc] init];
    return [formatter markdownForNote:note];
}

A few habits make the process predictable. Command-click a Swift class in Objective-C to see exactly what the generated header exposes. Option-click an imported Objective-C symbol to see its Swift signature. Migrate tests alongside the code they cover. The Objective-C side of these build settings (modules, -fobjc-arc, header search paths) is covered on the Objective-C reference’s Build and Tooling page. UIKit and AppKit code, which is often what the Objective-C half of an old app contains, is covered on UIKit and AppKit Essentials, and Mixing Them with SwiftUI.