
Ipad Patterns
- 3 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Reference for iPadOS-specific patterns: Stage Manager, multi-window, drag and drop, keyboard shortcuts, pointer interactions, Apple Pencil, and adaptive layouts.
About
A guide to iPadOS-specific development patterns including multitasking, multi-window, drag and drop, keyboard shortcuts, pointer, and Apple Pencil support. A developer uses it when building iPad-optimized features rather than a scaled-up iPhone app.
- Covers Stage Manager, Split View, and multi-window
- Drag and drop, pointer, and Apple Pencil interactions
Ipad Patterns by the numbers
- 3 all-time installs (skills.sh)
- Ranked #883 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill ipad-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Reference for iPadOS-specific patterns: Stage Manager, multi-window, drag and drop, keyboard shortcuts, pointer interactions, Apple Pencil, and adaptive layouts.
Files
iPad Patterns
Comprehensive guide for iPadOS-specific development patterns. Covers multitasking (Stage Manager, Split View, Slide Over), multi-window support, drag and drop, keyboard shortcuts, pointer interactions, Apple Pencil, and external display support. These patterns differentiate an iPad-optimized app from a scaled-up iPhone app.
When This Skill Activates
- User is building or reviewing iPad-specific features
- User asks about Stage Manager, multi-window, or UIScene lifecycle
- User needs drag and drop (NSItemProvider, Transferable, UIDragInteraction)
- User wants keyboard shortcuts or discoverability overlay
- User asks about pointer/trackpad interactions or hover effects
- User is implementing Apple Pencil or PencilKit support
- User needs adaptive layouts for Split View, Slide Over, or size classes
- User asks about external display support
- User wants to make an iPhone app work well on iPad
Decision Tree
What iPad feature are you building?
|
+-- Multi-window / Stage Manager / UIScene lifecycle
| +-- multitasking.md
| +-- Scene configuration, requestSceneSessionActivation
| +-- Window management, scene delegates
|
+-- Split View / Slide Over / Adaptive Layout
| +-- multitasking.md
| +-- Size classes, compact/regular transitions
| +-- NavigationSplitView column widths
|
+-- Drag and Drop
| +-- drag-drop.md
| +-- SwiftUI: .draggable() / .dropDestination()
| +-- UIKit: UIDragInteraction / UIDropInteraction
| +-- Transferable protocol, NSItemProvider
|
+-- Keyboard Shortcuts
| +-- input-methods.md
| +-- SwiftUI: .keyboardShortcut()
| +-- UIKit: UIKeyCommand
| +-- Discoverability overlay (Cmd hold)
|
+-- Pointer / Trackpad Interactions
| +-- input-methods.md
| +-- .hoverEffect(), UIPointerInteraction
| +-- Custom pointer shapes, lift/highlight effects
|
+-- Apple Pencil / PencilKit
| +-- input-methods.md
| +-- PKCanvasView, PKDrawing
| +-- Touch type filtering, Scribble
|
+-- External Display
+-- multitasking.md
+-- WindowGroup for external scenes
+-- UIScreen notifications (legacy)API Availability
| API | Minimum Version | Reference |
|---|---|---|
UIScene / UISceneDelegate | iPadOS 13 | multitasking.md |
UISceneConfiguration | iPadOS 13 | multitasking.md |
UIUserInterfaceSizeClass | iPadOS 8 | multitasking.md |
NavigationSplitView | iPadOS 16 | multitasking.md |
.horizontalSizeClass / .verticalSizeClass | iPadOS 14 (SwiftUI) | multitasking.md |
.hoverEffect() | iPadOS 13 | input-methods.md |
UIPointerInteraction | iPadOS 13.4 | input-methods.md |
.keyboardShortcut() | iPadOS 14 | input-methods.md |
UIKeyCommand | iPadOS 7 | input-methods.md |
PencilKit (PKCanvasView) | iPadOS 13 | input-methods.md |
UIPencilInteraction | iPadOS 12.1 | input-methods.md |
.draggable() / .dropDestination() | iPadOS 16 | drag-drop.md |
Transferable protocol | iPadOS 16 | drag-drop.md |
UIDragInteraction / UIDropInteraction | iPadOS 11 | drag-drop.md |
NSItemProvider | iPadOS 11 | drag-drop.md |
WindowGroup (multi-window) | iPadOS 16 (SwiftUI lifecycle) | multitasking.md |
.handlesExternalEvents | iPadOS 14 | multitasking.md |
| Stage Manager | iPadOS 16 (M1+ iPads) | multitasking.md |
UISceneSession.requestSceneSessionActivation | iPadOS 13 | multitasking.md |
.focusable() / @FocusState | iPadOS 15 | input-methods.md |
FocusedValue / FocusedObject | iPadOS 16 | input-methods.md |
Top 5 Mistakes
| # | Mistake | Fix | Details |
|---|---|---|---|
| 1 | Ignoring size classes, building fixed layouts | Use @Environment(\.horizontalSizeClass) to adapt between compact and regular | multitasking.md |
| 2 | No keyboard shortcuts for common actions | Add .keyboardShortcut() to primary actions (Cmd+N, Cmd+S, Delete) | input-methods.md |
| 3 | Missing drag and drop on list/grid items | Add .draggable() and .dropDestination() for content types users expect to move | drag-drop.md |
| 4 | No hover effects on interactive elements | Add .hoverEffect() to buttons, list rows, and custom controls | input-methods.md |
| 5 | Not supporting multiple windows (single-scene only) | Add WindowGroup support and handle NSUserActivity for state restoration | multitasking.md |
Process
1. Identify iPad Features Needed
Read the user's code or requirements to determine:
- Is this a new iPad app or adapting an iPhone app?
- Which iPad-specific features are relevant (multitasking, drag/drop, keyboard, pencil)?
- Target iPadOS version and hardware (Stage Manager requires M1+)
- Whether the app uses SwiftUI lifecycle or UIKit AppDelegate
2. Load Relevant Reference Files
Based on the need, read from this directory:
multitasking.md-- Stage Manager, multi-window, Split View, Slide Over, size classes, external displayinput-methods.md-- Keyboard shortcuts, pointer interactions, Apple Pencil, focus systemdrag-drop.md-- Drag and drop, Transferable protocol, NSItemProvider
3. Review or Recommend
Apply patterns from the reference files. Check for common issues using the review checklist below.
4. Cross-Reference
- For navigation architecture on iPad, see
ios/navigation-patterns/navigation-split-view.md - For macOS Catalyst concerns, see
macos/coding-best-practices/ - For toolbar patterns, see
swiftui/toolbars/SKILL.md - For animation and transitions, see
design/animation-patterns/
Review Checklist
When reviewing code for iPad optimization, verify:
- [ ] Size class adaptation -- UI adapts to compact/regular width (Split View, Slide Over)
- [ ] Keyboard shortcuts -- primary actions have
.keyboardShortcut()modifiers - [ ] Discoverability -- shortcuts appear when user holds Command key
- [ ] Pointer effects -- interactive elements have
.hoverEffect() - [ ] Drag and drop -- list/grid items support
.draggable()and.dropDestination() - [ ] Multi-window -- app supports multiple windows if content model allows it
- [ ] Scene restoration -- state is preserved when scene disconnects/reconnects
- [ ] Pencil support -- drawing views filter touch types, PencilKit configured correctly
- [ ] Column layout --
NavigationSplitViewcolumn widths appropriate for iPad - [ ] No hardcoded widths -- layouts use
.frame(minWidth:idealWidth:maxWidth:)or geometry-based sizing - [ ] Context menus -- long-press context menus on relevant items (also improve right-click with pointer)
- [ ] Toolbar placement -- actions placed in
.primaryAction,.secondaryAction, or.keyboardas appropriate
References
Drag and Drop
Covers drag and drop using SwiftUI modifiers (.draggable(), .dropDestination()), the Transferable protocol, UIKit drag/drop interactions, and NSItemProvider for inter-app data transfer.
SwiftUI Drag and Drop (iPadOS 16+)
Basic Draggable
struct PhotoGrid: View {
let photos: [Photo]
var body: some View {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 120))]) {
ForEach(photos) { photo in
AsyncImage(url: photo.thumbnailURL)
.draggable(photo) // Photo must conform to Transferable
}
}
}
}Drop Destination
struct DropTargetView: View {
@State private var droppedPhotos: [Photo] = []
var body: some View {
VStack {
Text("Drop photos here")
.frame(maxWidth: .infinity, minHeight: 200)
.background(Color.secondary.opacity(0.1))
.dropDestination(for: Photo.self) { photos, location in
droppedPhotos.append(contentsOf: photos)
return true // Accepted
} isTargeted: { isTargeted in
// Highlight when drag is hovering
}
}
}
}Reordering with Drag and Drop
struct ReorderableList: View {
@State private var items: [Item] = Item.samples
var body: some View {
List {
ForEach(items) { item in
ItemRow(item: item)
.draggable(item)
}
.dropDestination(for: Item.self) { items, offset in
// Insert dropped items at the target offset
self.items.insert(contentsOf: items, at: offset)
}
}
}
}Note: For simple reordering within a List, prefer .onMove(perform:) which provides built-in reorder handles. Use .draggable() and .dropDestination() when you need cross-list or cross-app drag and drop.
Transferable Protocol
The Transferable protocol (iPadOS 16+) defines how types are serialized for drag/drop, copy/paste, and ShareSheet.
Basic Transferable Conformance
struct Photo: Identifiable, Codable, Transferable {
let id: UUID
let title: String
let imageURL: URL
static var transferRepresentation: some TransferRepresentation {
// Primary: encode as Codable JSON
CodableRepresentation(contentType: .photo)
// Fallback: export the image file
FileRepresentation(contentType: .jpeg) { photo in
SentTransferredFile(photo.imageURL)
} importing: { received in
let savedURL = try Self.saveToDocuments(received.file)
return Photo(id: UUID(), title: "Imported", imageURL: savedURL)
}
}
}
// Register custom UTType
import UniformTypeIdentifiers
extension UTType {
static let photo = UTType(exportedAs: "com.myapp.photo")
}Multiple Representations
Order matters -- recipients choose the first representation they can handle:
struct RichTextContent: Transferable {
let html: String
let plainText: String
static var transferRepresentation: some TransferRepresentation {
// Prefer rich text
DataRepresentation(contentType: .html) { content in
Data(content.html.utf8)
} importing: { data in
RichTextContent(
html: String(data: data, encoding: .utf8) ?? "",
plainText: ""
)
}
// Fallback to plain text
DataRepresentation(contentType: .plainText) { content in
Data(content.plainText.utf8)
} importing: { data in
RichTextContent(
html: "",
plainText: String(data: data, encoding: .utf8) ?? ""
)
}
}
}ProxyRepresentation
Delegate to an existing Transferable type:
struct Task: Identifiable, Transferable {
let id: UUID
let title: String
let notes: String
static var transferRepresentation: some TransferRepresentation {
// Drag the title as plain text
ProxyRepresentation(exporting: \.title)
}
}Built-in Transferable Types
These standard types already conform to Transferable:
String-- plain textURL-- file or web URLData-- raw bytesAttributedString-- styled textImage(SwiftUI) -- image dataColor(SwiftUI) -- color data
// Drag a URL
Link(destination: url) {
Text("Visit")
}
.draggable(url)
// Drop text
Text("Drop text here")
.dropDestination(for: String.self) { strings, _ in
receivedText = strings.first ?? ""
return true
}UIKit Drag and Drop (iPadOS 11+)
UIDragInteraction
class DraggableCell: UICollectionViewCell, UIDragInteractionDelegate {
var item: DataItem?
override init(frame: CGRect) {
super.init(frame: frame)
let dragInteraction = UIDragInteraction(delegate: self)
dragInteraction.isEnabled = true
addInteraction(dragInteraction)
}
required init?(coder: NSCoder) { fatalError() }
func dragInteraction(
_ interaction: UIDragInteraction,
itemsForBeginning session: UIDragSession
) -> [UIDragItem] {
guard let item else { return [] }
let provider = NSItemProvider(object: item.title as NSString)
let dragItem = UIDragItem(itemProvider: provider)
dragItem.localObject = item // For same-app drops
return [dragItem]
}
// Optional: provide a custom drag preview
func dragInteraction(
_ interaction: UIDragInteraction,
previewForLifting item: UIDragItem,
session: UIDragSession
) -> UITargetedDragPreview? {
let parameters = UIDragPreviewParameters()
parameters.visiblePath = UIBezierPath(
roundedRect: bounds,
cornerRadius: 12
)
return UITargetedDragPreview(view: self, parameters: parameters)
}
}UIDropInteraction
class DropZoneView: UIView, UIDropInteractionDelegate {
override init(frame: CGRect) {
super.init(frame: frame)
let dropInteraction = UIDropInteraction(delegate: self)
addInteraction(dropInteraction)
}
required init?(coder: NSCoder) { fatalError() }
func dropInteraction(
_ interaction: UIDropInteraction,
canHandle session: UIDropSession
) -> Bool {
// Accept plain text and images
return session.canLoadObjects(ofClass: NSString.self)
|| session.canLoadObjects(ofClass: UIImage.self)
}
func dropInteraction(
_ interaction: UIDropInteraction,
sessionDidUpdate session: UIDropSession
) -> UIDropProposal {
// .copy for inter-app, .move for intra-app reordering
let operation: UIDropOperation = session.localDragSession != nil ? .move : .copy
return UIDropProposal(operation: operation)
}
func dropInteraction(
_ interaction: UIDropInteraction,
performDrop session: UIDropSession
) {
session.loadObjects(ofClass: NSString.self) { items in
for case let string as String in items {
self.handleDroppedText(string)
}
}
session.loadObjects(ofClass: UIImage.self) { items in
for case let image as UIImage in items {
self.handleDroppedImage(image)
}
}
}
}UICollectionView Drag and Drop
UICollectionView has built-in drag and drop support:
class CollectionViewController: UICollectionViewController {
override func viewDidLoad() {
super.viewDidLoad()
collectionView.dragDelegate = self
collectionView.dropDelegate = self
collectionView.dragInteractionEnabled = true // Required on iPad
}
}
extension CollectionViewController: UICollectionViewDragDelegate {
func collectionView(
_ collectionView: UICollectionView,
itemsForBeginning session: UIDragSession,
at indexPath: IndexPath
) -> [UIDragItem] {
let item = dataSource[indexPath.item]
let provider = NSItemProvider(object: item.title as NSString)
let dragItem = UIDragItem(itemProvider: provider)
dragItem.localObject = item
return [dragItem]
}
}
extension CollectionViewController: UICollectionViewDropDelegate {
func collectionView(
_ collectionView: UICollectionView,
performDropWith coordinator: UICollectionViewDropCoordinator
) {
let destinationIndexPath = coordinator.destinationIndexPath
?? IndexPath(item: 0, section: 0)
for item in coordinator.items {
if let sourceIndexPath = item.sourceIndexPath {
// Same collection -- reorder
collectionView.performBatchUpdates {
let movedItem = dataSource.remove(at: sourceIndexPath.item)
dataSource.insert(movedItem, at: destinationIndexPath.item)
collectionView.moveItem(at: sourceIndexPath, to: destinationIndexPath)
}
coordinator.drop(item.dragItem, toItemAt: destinationIndexPath)
} else {
// From another app -- load asynchronously
item.dragItem.itemProvider.loadObject(ofClass: NSString.self) { string, _ in
guard let text = string as? String else { return }
DispatchQueue.main.async {
self.insertNewItem(text, at: destinationIndexPath)
}
}
}
}
}
func collectionView(
_ collectionView: UICollectionView,
dropSessionDidUpdate session: UIDropSession,
withDestinationIndexPath destinationIndexPath: IndexPath?
) -> UICollectionViewDropProposal {
if session.localDragSession != nil {
return UICollectionViewDropProposal(
operation: .move,
intent: .insertAtDestinationIndexPath
)
}
return UICollectionViewDropProposal(
operation: .copy,
intent: .insertAtDestinationIndexPath
)
}
}NSItemProvider
NSItemProvider is the underlying transport for inter-app drag and drop. Use it when you need fine-grained control or UIKit compatibility.
Creating Providers
// From string
let provider = NSItemProvider(object: "Hello" as NSString)
// From URL
let provider = NSItemProvider(object: url as NSURL)
// From image
let provider = NSItemProvider(object: image)
// From custom type with UTType
let provider = NSItemProvider()
provider.registerDataRepresentation(
forTypeIdentifier: UTType.json.identifier,
visibility: .all
) { completion in
let data = try? JSONEncoder().encode(myModel)
completion(data, nil)
return nil // No progress needed
}Loading from Providers
func handleDrop(providers: [NSItemProvider]) {
for provider in providers {
// Check what types are available
if provider.canLoadObject(ofClass: UIImage.self) {
provider.loadObject(ofClass: UIImage.self) { image, error in
guard let image = image as? UIImage else { return }
DispatchQueue.main.async {
self.addImage(image)
}
}
} else if provider.canLoadObject(ofClass: NSString.self) {
provider.loadObject(ofClass: NSString.self) { string, error in
guard let text = string as? String else { return }
DispatchQueue.main.async {
self.addText(text)
}
}
} else if provider.hasItemConformingToTypeIdentifier(UTType.json.identifier) {
provider.loadDataRepresentation(
forTypeIdentifier: UTType.json.identifier
) { data, error in
guard let data, let model = try? JSONDecoder().decode(MyModel.self, from: data)
else { return }
DispatchQueue.main.async {
self.addModel(model)
}
}
}
}
}Async Loading (iPadOS 16+)
func handleDrop(providers: [NSItemProvider]) async {
for provider in providers {
if provider.canLoadObject(ofClass: UIImage.self) {
do {
let image = try await provider.loadObject(ofClass: UIImage.self) as? UIImage
if let image {
addImage(image)
}
} catch {
print("Failed to load image: \(error)")
}
}
}
}Spring Loading
Spring loading allows users to drag over a navigable element (tab, folder), hold briefly, and the app navigates into it so the user can drop into a deeper location.
SwiftUI Spring Loading
struct FolderView: View {
let folder: Folder
@State private var isTargeted = false
var body: some View {
NavigationLink(value: folder) {
Label(folder.name, systemImage: "folder")
}
.dropDestination(for: FileItem.self) { items, _ in
moveItems(items, to: folder)
return true
} isTargeted: { targeted in
isTargeted = targeted
// When targeted during drag, NavigationLink activates after delay (spring loading)
}
}
}UIKit Spring Loading
class FolderCell: UICollectionViewCell, UISpringLoadedInteractionSupporting {
var isSpringLoaded: Bool = true
override init(frame: CGRect) {
super.init(frame: frame)
let springInteraction = UISpringLoadedInteraction { interaction, context in
// Navigate into folder when spring-loaded
self.navigateIntoFolder()
}
addInteraction(springInteraction)
}
required init?(coder: NSCoder) { fatalError() }
}Common Mistakes
Not Conforming to Transferable
// ❌ Wrong -- type cannot be dragged because it has no Transferable conformance
struct Task: Identifiable {
let id: UUID
let title: String
}
Text(task.title)
.draggable(task) // Compiler error: Task does not conform to Transferable
// ✅ Right -- add Transferable conformance
struct Task: Identifiable, Codable, Transferable {
let id: UUID
let title: String
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(contentType: .data)
}
}Blocking Main Thread on Drop
// ❌ Wrong -- loading synchronously blocks UI
func dropInteraction(_ interaction: UIDropInteraction, performDrop session: UIDropSession) {
let data = loadDataSynchronously(from: session) // Blocks main thread
processData(data)
}
// ✅ Right -- use async loading
func dropInteraction(_ interaction: UIDropInteraction, performDrop session: UIDropSession) {
session.loadObjects(ofClass: UIImage.self) { items in
DispatchQueue.main.async {
self.processImages(items.compactMap { $0 as? UIImage })
}
}
}Wrong Drop Operation for Context
// ❌ Wrong -- always using .copy even for same-app reordering
func dropInteraction(
_ interaction: UIDropInteraction,
sessionDidUpdate session: UIDropSession
) -> UIDropProposal {
return UIDropProposal(operation: .copy)
}
// ✅ Right -- .move for same app, .copy for cross-app
func dropInteraction(
_ interaction: UIDropInteraction,
sessionDidUpdate session: UIDropSession
) -> UIDropProposal {
let operation: UIDropOperation = session.localDragSession != nil ? .move : .copy
return UIDropProposal(operation: operation)
}Missing Visual Feedback During Drag
// ❌ Wrong -- no indication that a view accepts drops
.dropDestination(for: String.self) { items, _ in
handle(items)
return true
}
// ✅ Right -- highlight drop target
@State private var isDropTargeted = false
VStack {
content
}
.background(isDropTargeted ? Color.accentColor.opacity(0.15) : Color.clear)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(isDropTargeted ? Color.accentColor : Color.clear, lineWidth: 2)
)
.dropDestination(for: String.self) { items, _ in
handle(items)
return true
} isTargeted: { targeted in
withAnimation(.easeInOut(duration: 0.2)) {
isDropTargeted = targeted
}
}Not Providing Drag Preview
// The default drag preview is a snapshot of the entire view.
// For large cells or complex views, provide a focused preview.
// ✅ Right -- custom drag preview for cleaner appearance
func dragInteraction(
_ interaction: UIDragInteraction,
previewForLifting item: UIDragItem,
session: UIDragSession
) -> UITargetedDragPreview? {
// Use just the thumbnail, not the entire cell
guard let thumbnailView = thumbnailImageView else { return nil }
let parameters = UIDragPreviewParameters()
parameters.backgroundColor = .clear
return UITargetedDragPreview(view: thumbnailView, parameters: parameters)
}Checklist
- [ ] Draggable types conform to
Transferable(SwiftUI) or provideNSItemProvider(UIKit) - [ ] Drop targets show visual feedback via
isTargetedcallback - [ ] Drop operation is
.movefor same-app reordering,.copyfor cross-app - [ ] Drop loading is asynchronous -- never blocks main thread
- [ ] Multiple representations provided (rich + fallback) for cross-app compatibility
- [ ]
CodableRepresentationused for app-specific types,ProxyRepresentationfor simple types - [ ] Drag preview is appropriately sized (not the entire view if it is large)
- [ ] Spring loading enabled on navigable elements (folders, tabs) for drag-through navigation
- [ ] Collection/table view drag delegates set and
dragInteractionEnabled = true - [ ] Custom UTType registered in Info.plist if using app-specific content types
Input Methods: Keyboard, Pointer, Pencil, and Focus
Covers keyboard shortcuts, pointer/trackpad interactions, Apple Pencil support via PencilKit, and the SwiftUI focus system.
Keyboard Shortcuts
SwiftUI Keyboard Shortcuts
struct ContentView: View {
var body: some View {
NavigationSplitView {
SidebarView()
} detail: {
DetailView()
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("New Item", systemImage: "plus") {
createNewItem()
}
.keyboardShortcut("n", modifiers: .command) // Cmd+N
}
}
}
}Common Shortcut Patterns
// Standard shortcuts users expect
Button("Save") { save() }
.keyboardShortcut("s", modifiers: .command) // Cmd+S
Button("Delete") { delete() }
.keyboardShortcut(.delete, modifiers: .command) // Cmd+Delete
Button("Find") { showSearch() }
.keyboardShortcut("f", modifiers: .command) // Cmd+F
Button("Select All") { selectAll() }
.keyboardShortcut("a", modifiers: .command) // Cmd+A
Button("Undo") { undo() }
.keyboardShortcut("z", modifiers: .command) // Cmd+Z
Button("Redo") { redo() }
.keyboardShortcut("z", modifiers: [.command, .shift]) // Cmd+Shift+Z
// Default button (Return key) -- used for primary action in forms/dialogs
Button("Submit") { submit() }
.keyboardShortcut(.defaultAction) // Return/Enter
// Cancel button (Escape key)
Button("Cancel") { cancel() }
.keyboardShortcut(.cancelAction) // EscapeShortcut Discoverability
When the user holds the Command key, iPadOS shows a discoverability overlay listing all available shortcuts. This works automatically for .keyboardShortcut() modifiers. Organize shortcuts with sections using menu structure:
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
CommandGroup(after: .newItem) {
Button("New Document") { newDocument() }
.keyboardShortcut("n", modifiers: .command)
Button("New Folder") { newFolder() }
.keyboardShortcut("n", modifiers: [.command, .shift])
}
CommandMenu("Edit") {
Button("Find") { find() }
.keyboardShortcut("f", modifiers: .command)
Button("Find and Replace") { findReplace() }
.keyboardShortcut("f", modifiers: [.command, .option])
}
}
}UIKit Keyboard Shortcuts (UIKeyCommand)
class DocumentViewController: UIViewController {
override var keyCommands: [UIKeyCommand]? {
return [
UIKeyCommand(
title: "Save",
action: #selector(saveDocument),
input: "s",
modifierFlags: .command,
discoverabilityTitle: "Save Document"
),
UIKeyCommand(
title: "New",
action: #selector(newDocument),
input: "n",
modifierFlags: .command,
discoverabilityTitle: "New Document"
),
UIKeyCommand(
title: "Close",
action: #selector(closeDocument),
input: "w",
modifierFlags: .command,
discoverabilityTitle: "Close Document"
)
]
}
@objc func saveDocument() { /* ... */ }
@objc func newDocument() { /* ... */ }
@objc func closeDocument() { /* ... */ }
}Arrow Key Navigation (UIKit)
class GridViewController: UIViewController {
override var keyCommands: [UIKeyCommand]? {
return [
UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [],
action: #selector(moveUp)),
UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [],
action: #selector(moveDown)),
UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [],
action: #selector(moveLeft)),
UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [],
action: #selector(moveRight)),
]
}
}Pointer Interactions
SwiftUI Hover Effects
// Automatic hover effect (highlight)
Button("Action") { performAction() }
.hoverEffect() // Default: .automatic
// Specific hover effects
Image(systemName: "star")
.hoverEffect(.highlight) // Spotlight-like highlight
.onTapGesture { toggleFavorite() }
Text("Hoverable Text")
.hoverEffect(.lift) // Lifts toward user, adds shadow
// Custom hover region
RoundedRectangle(cornerRadius: 12)
.fill(Color.blue)
.hoverEffect(.highlight)
.contentShape(.hoverEffect, RoundedRectangle(cornerRadius: 12))Hover State Detection (SwiftUI)
struct HoverableCard: View {
@State private var isHovered = false
var body: some View {
VStack {
Text("Card Content")
}
.padding()
.background(isHovered ? Color.blue.opacity(0.1) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 12))
.onHover { hovering in
isHovered = hovering
}
}
}UIKit Pointer Interactions
class CustomButton: UIButton, UIPointerInteractionDelegate {
override init(frame: CGRect) {
super.init(frame: frame)
addInteraction(UIPointerInteraction(delegate: self))
}
required init?(coder: NSCoder) {
super.init(coder: coder)
addInteraction(UIPointerInteraction(delegate: self))
}
func pointerInteraction(
_ interaction: UIPointerInteraction,
styleFor region: UIPointerRegion
) -> UIPointerStyle? {
// Lift effect -- element lifts up with shadow
let targetedPreview = UITargetedPreview(view: self)
return UIPointerStyle(effect: .lift(targetedPreview))
}
}Custom Pointer Shapes
func pointerInteraction(
_ interaction: UIPointerInteraction,
styleFor region: UIPointerRegion
) -> UIPointerStyle? {
// Beam shape for text areas
let shape = UIPointerShape.verticalBeam(length: 20)
return UIPointerStyle(shape: shape)
}
// Available shapes:
// .defaultPointer -- standard arrow
// .verticalBeam -- text cursor (I-beam)
// .horizontalBeam -- horizontal text cursor
// .roundedRect(frame) -- custom rectangle regionStandard UIKit Controls
Standard UIKit controls (UIButton, UIBarButtonItem, UISegmentedControl, UISlider, UISwitch, UITableViewCell, UICollectionViewCell) automatically have pointer effects. Do not add custom UIPointerInteraction to these unless you need a non-standard effect.
Apple Pencil and PencilKit
Basic PencilKit Setup
import PencilKit
struct DrawingView: UIViewRepresentable {
@Binding var drawing: PKDrawing
func makeUIView(context: Context) -> PKCanvasView {
let canvasView = PKCanvasView()
canvasView.drawing = drawing
canvasView.delegate = context.coordinator
canvasView.drawingPolicy = .pencilOnly // Only Pencil draws; finger scrolls
canvasView.tool = PKInkingTool(.pen, color: .black, width: 5)
canvasView.backgroundColor = .systemBackground
// Show the system tool picker
if let windowScene = canvasView.window?.windowScene {
let toolPicker = PKToolPicker.shared(for: windowScene)
toolPicker?.setVisible(true, forFirstResponder: canvasView)
toolPicker?.addObserver(canvasView)
canvasView.becomeFirstResponder()
}
return canvasView
}
func updateUIView(_ canvasView: PKCanvasView, context: Context) {
if canvasView.drawing != drawing {
canvasView.drawing = drawing
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, PKCanvasViewDelegate {
let parent: DrawingView
init(_ parent: DrawingView) {
self.parent = parent
}
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
parent.drawing = canvasView.drawing
}
}
}Drawing Policy
// Drawing policy controls what input triggers drawing
canvasView.drawingPolicy = .pencilOnly // Pencil draws, finger scrolls/gestures
canvasView.drawingPolicy = .anyInput // Both pencil and finger draw
canvasView.drawingPolicy = .default // System default (pencilOnly on iPad with Pencil)Touch Type Filtering (Non-PencilKit Views)
For custom drawing or annotation views that do not use PencilKit, filter touch types:
class CustomDrawingView: UIView {
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
switch touch.type {
case .pencil:
// Handle pencil input for drawing
startDrawing(at: touch.location(in: self))
case .direct, .indirect:
// Handle finger/trackpad for navigation
startPanning(at: touch.location(in: self))
@unknown default:
break
}
}
}Extracting Drawing Data
// Save drawing as data
let drawingData = drawing.dataRepresentation()
// Load drawing from data
if let restored = try? PKDrawing(data: drawingData) {
canvasView.drawing = restored
}
// Generate image from drawing
let image = drawing.image(from: drawing.bounds, scale: UIScreen.main.scale)
// Access individual strokes
for stroke in drawing.strokes {
let ink = stroke.ink // Ink type and color
let path = stroke.path // Array of PKStrokePoint
let bounds = stroke.renderBounds
}UIPencilInteraction (Double Tap)
Handle Apple Pencil double-tap gesture (Pencil 2nd generation):
class DrawingViewController: UIViewController, UIPencilInteractionDelegate {
override func viewDidLoad() {
super.viewDidLoad()
let pencilInteraction = UIPencilInteraction()
pencilInteraction.delegate = self
view.addInteraction(pencilInteraction)
}
func pencilInteractionDidTap(_ interaction: UIPencilInteraction) {
// Respond to the user's preferred action
switch UIPencilInteraction.preferredTapAction {
case .switchEraser:
toggleEraser()
case .showColorPalette:
showColorPicker()
case .switchPrevious:
switchToPreviousTool()
case .showInkAttributes:
showInkSettings()
case .ignore:
break
@unknown default:
break
}
}
}UIIndirectScribbleInteraction
Scribble lets users write with Apple Pencil into text fields. Standard UITextField and UITextView support it automatically. For custom text input views:
class CustomTextView: UIView, UIIndirectScribbleInteractionDelegate {
override init(frame: CGRect) {
super.init(frame: frame)
let scribbleInteraction = UIIndirectScribbleInteraction(delegate: self)
addInteraction(scribbleInteraction)
}
required init?(coder: NSCoder) { fatalError() }
func indirectScribbleInteraction(
_ interaction: UIIndirectScribbleInteraction,
isElementFocused elementIdentifier: String
) -> Bool {
return elementIdentifier == focusedElementID
}
func indirectScribbleInteraction(
_ interaction: UIIndirectScribbleInteraction,
focusElementIfNeeded elementIdentifier: String,
referencePoint point: CGPoint,
completion: @escaping ((UIResponder & UITextInput)?) -> Void
) {
focusElement(elementIdentifier)
completion(textInputResponder)
}
func indirectScribbleInteraction(
_ interaction: UIIndirectScribbleInteraction,
requestElementsIn rect: CGRect,
completion: @escaping ([String]) -> Void
) {
let elements = textElements(in: rect).map(\.identifier)
completion(elements)
}
}Focus System
SwiftUI Focus
struct SearchableList: View {
@FocusState private var isSearchFocused: Bool
@State private var searchText = ""
var body: some View {
VStack {
TextField("Search", text: $searchText)
.focused($isSearchFocused)
List(filteredItems) { item in
ItemRow(item: item)
}
}
.onAppear {
isSearchFocused = true // Auto-focus search field
}
.keyboardShortcut("f", modifiers: .command) // Cmd+F focuses search
}
}Focus with Enum
enum FormField: Hashable {
case title
case description
case tags
}
struct FormView: View {
@FocusState private var focusedField: FormField?
@State private var title = ""
@State private var description = ""
@State private var tags = ""
var body: some View {
Form {
TextField("Title", text: $title)
.focused($focusedField, equals: .title)
TextField("Description", text: $description)
.focused($focusedField, equals: .description)
TextField("Tags", text: $tags)
.focused($focusedField, equals: .tags)
}
.onSubmit {
// Move focus to next field
switch focusedField {
case .title: focusedField = .description
case .description: focusedField = .tags
case .tags: focusedField = nil
case nil: break
}
}
}
}Focusable Views (Non-Text)
struct FocusableCard: View {
@FocusState private var isFocused: Bool
var body: some View {
VStack {
Text("Card")
}
.focusable()
.focused($isFocused)
.focusEffectDisabled() // Disable default focus ring if custom styling
.border(isFocused ? Color.blue : Color.clear, width: 2)
.onKeyPress(.return) {
activateCard()
return .handled
}
}
}FocusedValue for Cross-View Communication
// Define focused value key
struct FocusedDocumentKey: FocusedValueKey {
typealias Value = Document
}
extension FocusedValues {
var focusedDocument: Document? {
get { self[FocusedDocumentKey.self] }
set { self[FocusedDocumentKey.self] = newValue }
}
}
// Set from the focused view
struct DocumentView: View {
let document: Document
var body: some View {
content
.focusedValue(\.focusedDocument, document)
}
}
// Read from commands/menus
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
CommandGroup(after: .pasteboard) {
Button("Export Document") {
// Uses focused document from whichever window is focused
}
.keyboardShortcut("e", modifiers: .command)
}
}
}
}Common Mistakes
No Keyboard Shortcuts for Primary Actions
// ❌ Wrong -- no shortcuts; keyboard users must tap screen
Button("New") { createNew() }
Button("Delete") { deleteSelected() }
// ✅ Right -- discoverable shortcuts for primary actions
Button("New") { createNew() }
.keyboardShortcut("n", modifiers: .command)
Button("Delete") { deleteSelected() }
.keyboardShortcut(.delete, modifiers: .command)Missing Hover Effects
// ❌ Wrong -- no visual feedback with trackpad/mouse
Button("Action") { doSomething() }
// ✅ Right -- pointer gets hover effect
Button("Action") { doSomething() }
.hoverEffect()Note: standard SwiftUI Button already has basic pointer behavior. Add .hoverEffect() to custom interactive elements built with .onTapGesture.
Drawing with Finger When Pencil Is Available
// ❌ Wrong -- both finger and pencil draw, user cannot scroll
canvasView.drawingPolicy = .anyInput
// ✅ Right -- pencil draws, finger scrolls
canvasView.drawingPolicy = .pencilOnlyNot Respecting Pencil Double-Tap Preference
// ❌ Wrong -- hardcoded double-tap behavior
func pencilInteractionDidTap(_ interaction: UIPencilInteraction) {
toggleEraser() // Ignores user preference
}
// ✅ Right -- check user's preferred action
func pencilInteractionDidTap(_ interaction: UIPencilInteraction) {
switch UIPencilInteraction.preferredTapAction {
case .switchEraser: toggleEraser()
case .showColorPalette: showColorPicker()
case .switchPrevious: switchToPreviousTool()
case .showInkAttributes: showInkSettings()
case .ignore: break
@unknown default: break
}
}Checklist
- [ ] Primary actions have
.keyboardShortcut()modifiers - [ ] Shortcuts follow platform conventions (Cmd+S save, Cmd+N new, Cmd+Z undo)
- [ ] Custom interactive elements have
.hoverEffect() - [ ] PencilKit drawing policy is
.pencilOnly(not.anyInput) unless intentional - [ ] Pencil double-tap respects
UIPencilInteraction.preferredTapAction - [ ] Focus management enables Tab/Shift+Tab navigation through form fields
- [ ]
@FocusStateused for programmatic focus control - [ ] Context menus on relevant items (also serve right-click on pointer devices)
- [ ] Arrow key navigation for grid/list views with keyboard
- [ ] Scribble works in custom text input views (UIIndirectScribbleInteraction)
Multitasking, Multi-Window, and Adaptive Layout
Covers Stage Manager, multiple windows via UIScene, Split View and Slide Over adaptation, size classes, NavigationSplitView column configuration, and external display support.
UIScene Lifecycle
iPadOS 13+ uses the UIScene architecture. Each window is backed by a UISceneSession with a UIScene (specifically UIWindowScene).
Scene Configuration (UIKit)
In Info.plist, declare scene configurations:
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
</dict>
</array>
</dict>
</dict>Set UIApplicationSupportsMultipleScenes to true to enable multi-window.
Scene Delegate
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: windowScene)
window?.rootViewController = MainViewController()
window?.makeKeyAndVisible()
// Restore state from user activity
if let activity = connectionOptions.userActivities.first
?? session.stateRestorationActivity {
restoreState(from: activity)
}
}
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
// Return activity representing current state for restoration
let activity = NSUserActivity(activityType: "com.app.document")
activity.userInfo = ["documentID": currentDocumentID]
return activity
}
func sceneDidDisconnect(_ scene: UIScene) {
// Scene released by system -- clean up resources
// Do NOT delete user data here; the scene may reconnect
}
func sceneDidBecomeActive(_ scene: UIScene) {
// Scene moved to foreground and is interactive
}
func sceneWillResignActive(_ scene: UIScene) {
// Scene is about to move out of foreground
}
func sceneDidEnterBackground(_ scene: UIScene) {
// Save state; scene may be disconnected next
}
}Scene Lifecycle States
Not Running --> Foreground Inactive --> Foreground Active
| |
v v
Background <-----------------+
|
v
Suspended (may be disconnected)Key point: each scene transitions independently. One scene can be active while another is in the background.
Requesting New Windows (UIKit)
// Open a new window for a document
func openNewWindow(for document: Document) {
let activity = NSUserActivity(activityType: "com.app.document")
activity.userInfo = ["documentID": document.id.uuidString]
UIApplication.shared.requestSceneSessionActivation(
nil, // nil = create new session
userActivity: activity, // Pass data to the new scene
options: nil,
errorHandler: { error in
print("Failed to open window: \(error)")
}
)
}
// Close/destroy a scene session
func closeWindow(session: UISceneSession) {
UIApplication.shared.requestSceneSessionDestruction(
session,
options: nil,
errorHandler: nil
)
}Handling the Activity in Scene Delegate
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
if let activity = connectionOptions.userActivities.first {
if let documentID = activity.userInfo?["documentID"] as? String {
showDocument(withID: documentID)
}
}
}Multi-Window in SwiftUI
Basic Multi-Window Support
@main
struct MyApp: App {
var body: some Scene {
// Primary window group -- supports multiple instances
WindowGroup {
ContentView()
}
// Additional window type for a specific purpose
WindowGroup("Document", id: "document", for: UUID.self) { $documentID in
if let documentID {
DocumentView(documentID: documentID)
}
}
}
}Opening Windows Programmatically
struct ContentView: View {
@Environment(\.openWindow) private var openWindow
var body: some View {
Button("Open Document") {
openWindow(id: "document", value: document.id)
}
}
}Handling External Events
WindowGroup {
ContentView()
}
.handlesExternalEvents(matching: ["main"])
WindowGroup("Document", id: "document", for: URL.self) { $url in
if let url {
DocumentView(url: url)
}
}
.handlesExternalEvents(matching: ["document"])Stage Manager
Stage Manager (iPadOS 16+, M1 iPads) allows freely resizable windows. Apps must handle:
1. Arbitrary window sizes -- do not assume fixed dimensions 2. Multiple windows visible simultaneously -- each is its own scene 3. Window chrome -- system provides title bar, resize handles
Handling Resizable Windows
// ❌ Wrong -- assuming full screen width
struct ContentView: View {
var body: some View {
HStack {
sidebar
.frame(width: 300) // Fixed sidebar breaks at small widths
detail
}
}
}
// ✅ Right -- use NavigationSplitView which handles resizing
struct ContentView: View {
var body: some View {
NavigationSplitView {
SidebarView()
} detail: {
DetailView()
}
}
}Restricting Window Size (UIKit)
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
// Set minimum and maximum window size
let restrictions = UISceneSizeRestrictions(minimumSize: CGSize(width: 400, height: 300))
restrictions.maximumSize = CGSize(width: 1200, height: 900)
if #available(iOS 16.0, *) {
windowScene.sizeRestrictions?.minimumSize = CGSize(width: 400, height: 300)
windowScene.sizeRestrictions?.maximumSize = CGSize(width: 1200, height: 900)
}
}Restricting Window Size (SwiftUI)
WindowGroup {
ContentView()
}
.defaultSize(width: 800, height: 600)Split View and Slide Over
When the user places the app in Split View (50/50 or 70/30) or Slide Over (narrow overlay), the app receives a compact or regular horizontal size class.
Size Class Detection (SwiftUI)
struct AdaptiveView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
var body: some View {
if horizontalSizeClass == .compact {
// Single-column layout (Slide Over, Split View narrow, iPhone)
NavigationStack {
ItemListView()
}
} else {
// Multi-column layout (full screen, Split View wide)
NavigationSplitView {
ItemListView()
} detail: {
DetailView()
}
}
}
}Size Class Detection (UIKit)
class AdaptiveViewController: UIViewController {
override func traitCollectionDidChange(
_ previousTraitCollection: UITraitCollection?
) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.horizontalSizeClass != previousTraitCollection?.horizontalSizeClass {
updateLayout()
}
}
// iOS 17+: Modern trait change registration
func setupTraitObservation() {
registerForTraitChanges([UITraitHorizontalSizeClass.self]) { (self: Self, _) in
self.updateLayout()
}
}
private func updateLayout() {
if traitCollection.horizontalSizeClass == .compact {
showSingleColumnLayout()
} else {
showMultiColumnLayout()
}
}
}NavigationSplitView Column Configuration
struct ContentView: View {
@State private var columnVisibility: NavigationSplitViewVisibility = .all
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
SidebarView()
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 350)
} content: {
ContentListView()
.navigationSplitViewColumnWidth(min: 250, ideal: 350, max: 500)
} detail: {
DetailView()
.navigationSplitViewColumnWidth(min: 300)
}
.navigationSplitViewStyle(.balanced) // or .prominentDetail
}
}Adapting Toolbar for Size Class
struct ContentView: View {
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
content
.toolbar {
if sizeClass == .regular {
ToolbarItem(placement: .primaryAction) {
Button("New", systemImage: "plus") { }
}
ToolbarItem(placement: .secondaryAction) {
Button("Filter", systemImage: "line.3.horizontal.decrease.circle") { }
}
} else {
ToolbarItem(placement: .bottomBar) {
HStack {
Button("New", systemImage: "plus") { }
Spacer()
Button("Filter", systemImage: "line.3.horizontal.decrease.circle") { }
}
}
}
}
}
}External Display Support
Modern Approach (SwiftUI, iPadOS 16+)
Use a dedicated WindowGroup for external displays:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
// This WindowGroup can be placed on an external display
WindowGroup("Presentation", id: "presentation") {
PresentationView()
}
}
}Legacy Approach (UIKit)
class ExternalDisplayManager {
private var externalWindow: UIWindow?
func startObservingScreens() {
NotificationCenter.default.addObserver(
self,
selector: #selector(screenDidConnect),
name: UIScreen.didConnectNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(screenDidDisconnect),
name: UIScreen.didDisconnectNotification,
object: nil
)
}
@objc private func screenDidConnect(_ notification: Notification) {
guard let screen = notification.object as? UIScreen else { return }
let window = UIWindow(frame: screen.bounds)
window.screen = screen
window.rootViewController = ExternalDisplayViewController()
window.makeKeyAndVisible()
externalWindow = window
}
@objc private func screenDidDisconnect(_ notification: Notification) {
externalWindow?.isHidden = true
externalWindow = nil
}
}Note: UIScreen.didConnectNotification and UIScreen.didDisconnectNotification are the legacy pattern. For SwiftUI apps, prefer multiple WindowGroup scenes.
Common Mistakes
Not Setting UIApplicationSupportsMultipleScenes
// ❌ Wrong -- Info.plist has UIApplicationSupportsMultipleScenes = false
// App only ever has one window, Expose shows no multi-window option
// ✅ Right -- set to true in Info.plist
// <key>UIApplicationSupportsMultipleScenes</key>
// <true/>Storing State in AppDelegate Instead of Per-Scene
// ❌ Wrong -- global state shared across all windows
class AppDelegate: UIResponder, UIApplicationDelegate {
var currentDocument: Document? // Which window does this belong to?
}
// ✅ Right -- state lives in scene delegate or per-scene model
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var documentState: DocumentState? // Each scene has its own state
}Ignoring Scene Disconnection
// ❌ Wrong -- never cleaning up when scene disconnects
// Resources leak as system disconnects background scenes
// ✅ Right -- release heavy resources in sceneDidDisconnect
func sceneDidDisconnect(_ scene: UIScene) {
// Release image caches, media players, etc.
// Keep user data — scene may reconnect
releaseHeavyResources()
}Fixed Layouts That Break in Split View
// ❌ Wrong -- assumes full screen width
.frame(width: UIScreen.main.bounds.width)
// ✅ Right -- use GeometryReader or flexible layouts
GeometryReader { geometry in
content
.frame(width: geometry.size.width)
}
// ✅ Even better -- use flexible SwiftUI layout without explicit widths
HStack {
sidebar
.frame(minWidth: 200, maxWidth: 300)
detail
.frame(maxWidth: .infinity)
}Checklist
- [ ]
UIApplicationSupportsMultipleScenesistruein Info.plist - [ ] State is per-scene, not global in AppDelegate
- [ ]
stateRestorationActivity(for:)returns meaningful NSUserActivity - [ ] Scene handles
userActivitiesinwillConnectTofor restoring state - [ ]
sceneDidDisconnectreleases heavy resources without deleting user data - [ ] Layout adapts to
horizontalSizeClass(compact vs regular) - [ ] No use of
UIScreen.main.boundsfor sizing (use GeometryReader or flexible layout) - [ ] NavigationSplitView uses
min/ideal/maxcolumn widths - [ ] Toolbar items adapt placement for compact vs regular size class
- [ ] External display content is handled via WindowGroup or screen notifications