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:
-
Interoperability with C, Objective-C and C++ in the Swift reference;
-
Swift Interoperability in the Objective-C reference, with its companion Build and Tooling page.
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 backFNImportedNoteobjects. It lives in theFieldNotesapp target underFieldNotes/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 toFieldNotesKitandFieldNotesUI.
Figure: How FieldNotes’s languages see each other
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 |
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 |
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
publicandopendeclarations that Objective-C can represent. If the target also has a bridging header, it containsinternalones too, which is the common case in a mixed app like FieldNotes.privateandfileprivatedeclarations never appear, unless they are marked@objc,@IBActionor@IBOutlet. -
In a framework target, only
publicandopendeclarations 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, astruct, anenumwith associated values, a generic type or a protocol with associated types. Objective-C has no equivalent for any of them. -
Members need
@objcindividually, 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 theFNclass prefix that Objective-C needs, without putting it on the Swift name. -
Integer-backed enums can be exported with
@objc enum Name: Int. They becomeNS_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 |
|---|---|---|
|
Unannotated pointers import as non-optional instead of |
The whole header. |
|
Marks the exceptions, which import as optionals. |
|
|
Renames or nests a type, method or enum for Swift only. Objective-C keeps its name. |
|
|
The method becomes |
|
|
Imports the method with a |
|
|
Controls which initializers Swift offers and which subclasses must override. |
|
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
.cppand.mmfiles 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
FieldNotesapp target setsSWIFT_OBJC_INTEROP_MODE = objcxx, because it importsNoteStatistics. For a published package, enabling the mode is a breaking change that needs a major version.NoteStatisticskeeps 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
.mmimplementation 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.
-
Clean up the Objective-C first. Add nullability (
NS_ASSUME_NONNULL_BEGIN), lightweight generics (NSArray<FNImportedNote *> *) andNS_SWIFT_NAMEto 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. -
Create the Swift replacement in one
.swiftfile. Swift has no header, so interface and implementation go together. Keep the Objective-C name with@objc(FNNoteFormatter), and inherit fromNSObjectso 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") } } -
Switch Objective-C callers to the generated header. In each
.mfile that used the class, replace#import "FNNoteFormatter.h"with#import "FieldNotes-Swift.h", and removeFNNoteFormatter.hfrom the bridging header. Because the Objective-C name and selector are unchanged, the call sites themselves don’t change. -
Remove the old implementation from the target. Uncheck
FNNoteFormatter.min 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. -
Ship, then modernize. Once all callers of the class are Swift, drop
@objc,NSObjectand theFNname. Then turn the class into astruct, or a function onNoteinFieldNotesModel, and run Unit Testing with Swift Testing tests against the old behavior. -
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.
References
-
Apple Developer Documentation — Importing Objective-C into Swift
-
Apple Developer Documentation — Importing Swift into Objective-C
-
Apple Developer Documentation — Designating Nullability in Objective-C APIs
-
Apple Developer Documentation — Renaming Objective-C APIs for Swift
-
Apple Developer Documentation — Improving Objective-C API Declarations for Swift
-
Apple Developer Documentation — Making Objective-C APIs Unavailable in Swift
-
Apple Developer Documentation — Grouping Related Objective-C Constants
-
Apple Developer Documentation — Marking API Availability in Objective-C
-
Apple Developer Documentation — Calling Objective-C APIs Asynchronously
-
Apple Developer Documentation — Migrating Your Objective-C Code to Swift
-
Apple Developer Documentation — Mixing Languages in an Xcode project
-
Apple Developer Documentation — Calling APIs Across Language Boundaries
-
Swift.org — Setting Up Mixed-Language Swift and C++ Projects