Building and improving your app with Mac Catalyst
At a glance
| Item |
Summary |
| Purpose |
Evolves an iPad trip-planning app into a multiwindow Mac Catalyst app with native toolbars, sharing, printing, menus, and restoration. |
| App architecture |
A three-column split-view coordinator owns browser state; protocol-composed values are type-erased for heterogeneous UI; scene delegates adapt state and Mac-only chrome. |
| Main patterns |
Split-view coordinator, protocol composition/type erasure, observable selection model, scene restoration, responder-chain commands, and platform adapters. |
| Project style |
UIKit MVC-style app organized by Model, Item Detail, Printing, and Utility Extensions feature folders. |
Project structure
Trip Planner/
├── AppDelegate.swift
├── SceneDelegate.swift
├── BrowserSplitViewController.swift
├── SidebarViewController.swift
├── SupplementaryViewController.swift
├── Model/
│ ├── DataModel.swift
│ ├── ModelItemsCollection.swift
│ └── Sample Data/SampleData.swift
├── Item Detail/
│ ├── DetailSceneDelegate.swift
│ ├── DetailViewController.swift
│ ├── LocatedItemDetailViewController.swift
│ └── RewardsProgramDetailViewController.swift
├── Printing/ItemPrintPageRenderer.swift
└── Utility Extensions/
Structure observations
- Domain values and selection state live outside the view controllers.
- The main browser and item-detail windows have separate scene delegates and restoration paths.
- Mac Catalyst code is localized behind
targetEnvironment(macCatalyst) rather than forked into a second app.
Overall architecture
flowchart LR
Scene["SceneDelegate"] --> Browser["BrowserSplitViewController"]
Sidebar["SidebarViewController"] --> Selection["ModelItemsCollection"]
Selection -->|"change notification"| Browser
Browser --> Supplementary["SupplementaryViewController"]
Browser --> Detail["DetailViewController"]
Detail --> Located["LocatedItemDetailViewController"]
Detail --> Rewards["RewardsProgramDetailViewController"]
Browser --> Print["ItemPrintPageRenderer"]
Scene -->|"encode / restore IDs"| Activity["NSUserActivity"]
DetailScene["DetailSceneDelegate"] --> Detail
Catalyst["Catalyst toolbar adapters"] --> Scene
Reference code
Trip Planner/BrowserSplitViewController.swift:31 — the split controller installs its column shells, observes one selection collection, and derives visible columns from that state.
setViewController(emptyNavigationController(), for: .secondary)
setViewController(emptyNavigationController(), for: .supplementary)
supplementarySelectionChangeObserver = NotificationCenter.default.addObserver(
forName: .modelItemsCollectionDidChange,
object: selectedItemsCollection,
queue: nil
) { [weak self] _ in
self?.updateDueToSelectedItemsChange()
}
updateDueToSelectedItemsChange()
updateDueToDetailItemChange()
Interpretation
BrowserSplitViewController is the high-level feature coordinator, not AppDelegate. It translates selection/detail state into columns, titles, printing targets, and a browser-state notification consumed by the scene’s restoration adapter.
Ownership and state
classDiagram
SceneDelegate *-- NSUserActivity : current restoration activity
SceneDelegate *-- MainSceneToolbarModel : Catalyst only
BrowserSplitViewController *-- ModelItemsCollection : stable selection owner
BrowserSplitViewController o-- Model : sample data
BrowserSplitViewController *-- NotificationToken : selection observer
SupplementaryViewController o-- ModelItemsCollection : injected reference
DetailViewController *-- AnyModelItem : immutable input
DetailViewController *-- UIViewController : chosen child
AnyModelItem *-- ModelItem : private wrapped value
DetailSceneDelegate *-- DetailToolbarModel : Catalyst only
Ownership evidence
Trip Planner/Model/DataModel.swift:108 — AnyModelItem retains one private protocol value and exposes a uniform identity/name plus optional capabilities.
class AnyModelItem: NSObject, ModelItem {
typealias ItemID = ItemIdentifierProviding.ItemID
private let wrapped: ModelItem
var itemID: ItemID { wrapped.itemID }
var name: String { wrapped.name }
var imageName: String? { (wrapped as? ImageProviding)?.imageName }
var location: CLLocation? { (wrapped as? LocationProviding)?.location }
var iconName: String? { (wrapped as? IconNameProviding)?.iconName }
init(_ modelItem: ModelItem) {
wrapped = modelItem
}
}
| Owner |
Object or state |
Relationship |
Mutation authority |
BrowserSplitViewController |
selectedItemsCollection and detailItem |
Owns browser navigation state |
Selection collection operations and coordinator setter |
ModelItemsCollection |
Ordered modelItems |
Owns mutable selection and posts changes |
add, remove, or restoration assignment |
SceneDelegate |
Current NSUserActivity and Catalyst toolbar model |
Owns per-main-window integration state |
Browser-state observer and scene callbacks |
DetailSceneDelegate |
Item activity and detail-window toolbar |
Owns per-detail-window state |
Scene configuration/restoration |
DetailViewController |
One type-erased item and one chosen child controller |
Retains immutable input; creates child |
Private child-installation logic |
AnyModelItem |
Wrapped ModelItem plus UI-facing favorite flag |
Retains erased domain value |
Wrapper initialization and local mutation |
Class and protocol design
| Type or protocol |
Responsibility |
Depends on or composes |
Model |
Root access to countries and reward programs |
Concrete sample data |
ModelItem |
Common identity and display name |
ItemIdentifierProviding |
ImageProviding / LocationProviding / IconNameProviding |
Small optional domain capabilities |
Composed only where a model supports them |
LocatedItem |
Alias for ModelItem & ImageProviding & LocationProviding |
Protocol composition |
AnyModelItem |
Type-erase heterogeneous model items for collections and controllers |
Private ModelItem plus conditional capability casts |
BrowserSplitViewController |
Coordinate primary, supplementary, and secondary columns |
Selection model, detail container, printing |
DetailViewController |
Choose a concrete detail child for the erased item |
Located/reward capability views |
| Scene delegates |
Translate scene activities and Mac chrome into app state |
NSUserActivity, Catalyst toolbar delegates |
This is protocol-oriented design with a pragmatic UIKit bridge: domain features are expressed as narrow protocols, while AnyModelItem provides the concrete, homogeneous reference type required by collection identity, Objective-C equality, and controller APIs.
Access control
| Symbol |
Access |
Verified effect |
Why it fits this design |
selectedItemsCollection |
private(set) |
Other app types can read/use the stable collection, but only the split controller can replace it. |
Preserves one selection identity for observers and injected consumers. |
| Selection observer and column-update helpers |
private |
Only the coordinator can run its derived-view synchronization. |
Prevents bypassing the state-to-columns flow. |
AnyModelItem.wrapped |
private let |
Callers use the uniform/capability surface, not the erased value directly. |
Enforces type-erasure boundary. |
DetailViewController.modelItem and child installer |
private |
The item’s interpretation remains inside the container. |
Keeps child-selection rules cohesive. |
SceneDelegate.currentUserActivity |
private |
Only scene restoration code replaces the current activity. |
Avoids competing state snapshots. |
| Toolbar model properties/classes |
private and Catalyst-conditional |
Hidden from iOS code and other source files. |
Treats AppKit toolbar delegates as platform adapters. |
SidebarViewController.dataSource |
fileprivate private(set) |
Same-file helpers can read it; mutation stays on the declaring type. |
Supports same-file snapshot helpers without broad module mutation. |
| Most app types |
implicit internal |
Available within the app target only. |
This sample is an app, not a reusable public framework. |
Reference code
Trip Planner/BrowserSplitViewController.swift:10 — the coordinator exposes its current selection but guards replacement and synchronization internals.
class BrowserSplitViewController: UISplitViewController {
private(set) var selectedItemsCollection = ModelItemsCollection()
private var supplementarySelectionChangeObserver: Any?
var selectedItems: [AnyModelItem] {
selectedItemsCollection.modelItems
}
var detailItem: AnyModelItem? {
didSet { updateDueToDetailItemChange() }
}
}
Logic ownership and placement
| Logic |
Owning type or file |
Placement rationale |
| Column selection, detail navigation, titles, print routing |
BrowserSplitViewController |
One owner sees all three split-view columns and the responder chain. |
| Ordered selection mutations and change notification |
ModelItemsCollection |
Selection is reusable state, independent of one view. |
| Heterogeneous model capabilities and erasure |
DataModel.swift |
Domain shape should not depend on view-controller branching. |
| Concrete detail selection and child containment |
DetailViewController |
The detail container owns presentation context and child lifetime. |
| Main/detail window restoration |
SceneDelegate / DetailSceneDelegate |
NSUserActivity belongs to scene lifecycle boundaries. |
| Printing layout |
ItemPrintPageRenderer |
Printing rules remain out of navigation controllers. |
| Native Mac toolbar construction |
Private toolbar model types |
AppKit integration stays isolated and conditional. |
Design patterns
| Pattern |
Source evidence |
Purpose or tradeoff |
| Split-view coordinator |
Trip Planner/BrowserSplitViewController.swift:10 |
Centralizes relationships among three columns without putting domain data in the app delegate. |
| Observable selection model |
Trip Planner/Model/ModelItemsCollection.swift:11 |
Preserves ordered selection and notifies multiple UI/state consumers. |
| Protocol composition |
Trip Planner/Model/DataModel.swift:17 |
Models declare only capabilities they actually support. |
| Type erasure |
Trip Planner/Model/DataModel.swift:108 |
Makes heterogeneous values usable in homogeneous UIKit collections. |
| Container controller |
Trip Planner/Item Detail/DetailViewController.swift:67 |
Chooses and owns the specialized detail child. |
| Scene restoration adapter |
Trip Planner/SceneDelegate.swift:61 |
Persists stable item IDs rather than view-controller instances. |
| Responder-chain command routing |
Trip Planner/BrowserSplitViewController.swift:126 |
Sends Print to one focused detail or the aggregate browser. |
| Conditional platform adapter |
Trip Planner/SceneDelegate.swift:112 |
Adds native Mac chrome without contaminating shared domain logic. |
Naming conventions
- UI owners use role suffixes:
BrowserSplitViewController, SidebarViewController, DetailSceneDelegate, and ItemPrintPageRenderer.
- Capability protocols use “Providing”; the common entity role uses
ModelItem.
AnyModelItem clearly signals type erasure; ModelItemsCollection signals ordered mutable selection rather than a repository.
- State-transition methods are explicit:
updateDueToSelectedItemsChange, restoreSelections, configure(with:), and updateUserActivity.
Architecture takeaways
- Use a split-view coordinator as the owner of cross-column navigation state.
- Model heterogeneous domain values with small capabilities, then type-erase only at the UIKit collection boundary.
- Restore windows from stable model identifiers, not serialized UI objects.
- Keep Catalyst-only toolbar adapters private and conditionally compiled while sharing the domain/navigation core.
- Use
private(set) to preserve the identity of observable state that collaborators still need to read and mutate through its API.
Source map
| Source file |
Relevant symbols |
Trip Planner/BrowserSplitViewController.swift |
Three-column coordination, printing, browser state |
Trip Planner/Model/DataModel.swift |
Capability protocols, model values, AnyModelItem |
Trip Planner/Model/ModelItemsCollection.swift |
Ordered selection owner and notification |
Trip Planner/SceneDelegate.swift |
Main scene restoration and Catalyst toolbar |
Trip Planner/Item Detail/DetailSceneDelegate.swift |
Item-window restoration and toolbar |
Trip Planner/Item Detail/DetailViewController.swift |
Type-directed child containment |
Trip Planner/Printing/ItemPrintPageRenderer.swift |
Print filtering and page rendering |
Trip Planner/SidebarViewController.swift |
Hierarchical list and selection input |