Guida completa per sviluppare widget dinamici stile Dynamic Island per il notch dei MacBook Pro usando AppKit, Combine e Swift 5.7.
Building Custom Notch Overlays for macOS Apps with Notchy: A Deep Dive into Dynamic Island-Style UI
Il notch dei MacBook Pro è spazio sprecato. Apple lo usa per la fotocamera, ma quei pixel circostanti restano vuoti mentre scorri tra finestre cercando lo stato della tua build o i controlli di Spotify. Con Notchy, trasformiamo quella zona morta in un centro di comando contestuale — esattamente come Dynamic Island su iPhone, ma con la potenza di AppKit.
In questo articolo costruiremo un sistema completo di overlay per il notch: widget modulari che mostrano dati in tempo reale, si animano fluidamente e degradano con eleganza su hardware senza notch.
Prerequisiti
Prima di iniziare, assicurati di avere:
- macOS 12.0+ (Monterey o successivo per le API del notch)
- Xcode 14+ con Swift 5.7
- Familiarità con AppKit e il sistema di coordinate di macOS
- Conoscenza base di Combine per il reactive binding
- Un Mac con notch (per testing completo) oppure accesso a un display esterno
1
2
3
4
5
6
7
| # Verifica la versione di Xcode
xcodebuild -version
# Output atteso: Xcode 14.0+ Build version 14A309+
# Crea il progetto
mkdir Notchy && cd Notchy
swift package init --type executable --name Notchy
|
💡 Se non hai un MacBook con notch, puoi usare il Simulator con device profile “MacBook Pro 14-inch” per testare la geometria dello schermo.
Architettura e Concetti Chiave
Notchy si basa su tre pilastri: Screen Geometry Engine per rilevare e tracciare il notch, Widget Container per gestire i moduli UI, e Degradation Handler per gli edge case hardware.
flowchart TD
subgraph ScreenGeometry["Screen Geometry Engine"]
A[NSScreen.main] --> B{hasNotch?}
B -->|Sì| C[Calcola safeAreaInsets]
B -->|No| D[Fallback: Menu Bar Mode]
C --> E[NotchFrame Struct]
end
subgraph WidgetContainer["Widget Container"]
E --> F[NSWindow borderless]
F --> G[Widget Registry]
G --> H[BuildStatusWidget]
G --> I[MediaControlWidget]
G --> J[SystemMetricsWidget]
end
subgraph DegradationHandler["Degradation Handler"]
K[Screen Configuration Change] --> L{Tipo Display?}
L -->|Builtin + Notch| M[Full Overlay Mode]
L -->|External| N[Compact Menu Extra]
L -->|Screen Recording| O[Hidden Mode]
end
H & I & J --> P[Compositor]
P --> Q[Animated Render]
M & N & O --> Q
Il flusso è lineare: interroghiamo NSScreen per ottenere la geometria del notch, creiamo una finestra borderless posizionata precisamente sopra di esso, e la popoliamo con widget registrati dinamicamente. Quando cambia la configurazione (nuovo display, recording attivo), il DegradationHandler decide la modalità di rendering.
📝 La chiave è NSScreen.safeAreaInsets introdotta in macOS 12 — è l’unica API ufficiale che espone le dimensioni del notch.
Implementazione Passo-Passo
Rilevamento del Notch e Calcolo della Geometria
Il primo passo è costruire un engine che rilevi il notch e calcoli il frame esatto dove posizionare l’overlay. macOS non espone direttamente “il notch”, ma possiamo dedurlo dai safeAreaInsets.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
| // NotchGeometryEngine.swift
import AppKit
import Combine
/// Struttura che rappresenta la geometria del notch
struct NotchGeometry: Equatable {
let frame: NSRect // Frame assoluto del notch in coordinate schermo
let displayID: CGDirectDisplayID
let isBuiltIn: Bool
/// Area utilizzabile a sinistra del notch
var leftUsableArea: NSRect {
guard let screen = NSScreen.screens.first(where: {
($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID) == displayID
}) else {
return .zero
}
return NSRect(x: screen.frame.origin.x,
y: frame.origin.y,
width: frame.origin.x - screen.frame.origin.x,
height: frame.height)
}
/// Area utilizzabile a destra del notch
var rightUsableArea: NSRect {
guard let screen = NSScreen.screens.first(where: {
($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID) == displayID
}) else {
return .zero
}
return NSRect(x: frame.maxX,
y: frame.origin.y,
width: screen.frame.maxX - frame.maxX,
height: frame.height)
}
}
final class NotchGeometryEngine: ObservableObject {
@Published private(set) var currentGeometry: NotchGeometry?
@Published private(set) var hasNotch: Bool = false
private var screenObserver: Any?
init() {
screenObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.recalculateGeometry()
}
recalculateGeometry()
}
/// Ricalcola la geometria del notch per lo schermo principale
func recalculateGeometry() {
guard let screen = NSScreen.main else {
currentGeometry = nil
hasNotch = false
return
}
let insets = screen.safeAreaInsets
guard insets.top > 0 else {
currentGeometry = nil
hasNotch = false
return
}
hasNotch = true
// Calcola il frame del notch
// Il notch è centrato orizzontalmente, largo circa 180pt su 14"
let notchWidth: CGFloat = estimateNotchWidth(for: screen)
let notchHeight: CGFloat = insets.top
let screenFrame = screen.frame
let notchFrame = NSRect(
x: screenFrame.midX - (notchWidth / 2),
y: screenFrame.maxY - notchHeight,
width: notchWidth,
height: notchHeight
)
let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID ?? 0
let isBuiltIn = CGDisplayIsBuiltin(displayID) != 0
currentGeometry = NotchGeometry(
frame: notchFrame,
displayID: displayID,
isBuiltIn: isBuiltIn
)
}
/// Stima la larghezza del notch in base alla risoluzione dello schermo
private func estimateNotchWidth(for screen: NSScreen) -> CGFloat {
let screenWidth = screen.frame.width
// MacBook Pro 14" (3024x1964 nativo, ~1512pt) -> notch ~180pt
// MacBook Pro 16" (3456x2234 nativo, ~1728pt) -> notch ~204pt
if screenWidth >= 1700 {
return 204
} else {
return 180
}
}
deinit {
if let observer = screenObserver {
NotificationCenter.default.removeObserver(observer)
}
}
}
|
⚠️ I valori di larghezza del notch sono empirici. Per una maggiore precisione in produzione, considera di creare una lookup table basata su CGDisplayModelNumber o sulle dimensioni esatte dello schermo.
Creazione della Finestra Overlay Borderless
Con la geometria calcolata, creiamo una NSWindow speciale che si sovrappone esattamente al notch. Questa finestra deve essere invisibile al sistema (non appare in Mission Control) ma sempre visibile sopra le altre app.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
| // NotchOverlayWindow.swift
import AppKit
import Combine
/// Finestra borderless che si posiziona sopra il notch
final class NotchOverlayWindow: NSWindow {
private let geometryEngine: NotchGeometryEngine
private var geometryCancellable: AnyCancellable?
init(geometryEngine: NotchGeometryEngine) {
self.geometryEngine = geometryEngine
super.init(
contentRect: .zero,
styleMask: [.borderless],
backing: .buffered,
defer: false
)
configureWindow()
bindToGeometry()
}
private func configureWindow() {
self.level = .statusBar + 1
self.collectionBehavior = [
.canJoinAllSpaces,
.stationary,
.ignoresCycle
]
self.isOpaque = false
self.backgroundColor = .clear
self.hasShadow = false
self.ignoresMouseEvents = false
}
private func bindToGeometry() {
geometryCancellable = geometryEngine.$currentGeometry
.receive(on: DispatchQueue.main)
.sink { [weak self] geometry in
self?.updateFrame(for: geometry)
}
}
private func updateFrame(for geometry: NotchGeometry?) {
guard let geometry = geometry else {
self.orderOut(nil)
return
}
// Espandi il frame oltre il notch per widget laterali
let expandedFrame = NSRect(
x: geometry.frame.origin.x - 100,
y: geometry.frame.origin.y,
width: geometry.frame.width + 200,
height: geometry.frame.height + 10
)
if self.isVisible {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.3
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
self.animator().setFrame(expandedFrame, display: true)
}
} else {
self.setFrame(expandedFrame, display: true)
self.orderFrontRegardless()
}
}
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
}
// MARK: - Panel subclass per comportamento NSPanel-like
final class NotchOverlayPanel: NSPanel {
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
/// Permette click-through nelle aree trasparenti
override func isMousePoint(_ point: NSPoint, in rect: NSRect) -> Bool {
guard let contentView = self.contentView else { return false }
let localPoint = contentView.convert(point, from: nil)
return contentView.hitTest(localPoint) != nil
}
}
|
💡 Usa NSPanel invece di NSWindow se vuoi che l’overlay non attivi l’app quando cliccato. La subclass NotchOverlayPanel permette click-through sulle aree trasparenti.
Ora costruiamo l’architettura per widget intercambiabili. Ogni widget implementa un protocollo comune e viene registrato in un registry centrale che gestisce lifecycle e layout.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
| // WidgetProtocol.swift
import AppKit
import Combine
/// Posizione del widget rispetto al notch
enum WidgetPosition {
case left
case right
case overlay // Sopra/dentro il notch (espande al hover)
}
/// Priorità per risoluzione conflitti di spazio
enum WidgetPriority: Int, Comparable {
case low = 0
case normal = 50
case high = 100
case critical = 200
static func < (lhs: WidgetPriority, rhs: WidgetPriority) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
/// Protocollo base per tutti i widget Notchy
protocol NotchWidget: AnyObject {
var identifier: String { get }
var preferredPosition: WidgetPosition { get }
var priority: WidgetPriority { get }
var requiredWidth: CGFloat { get }
var view: NSView { get }
var updatePublisher: AnyPublisher<Void, Never> { get }
func activate()
func deactivate()
func handleHover(_ isHovering: Bool)
}
// MARK: - Widget Registry
final class WidgetRegistry: ObservableObject {
@Published private(set) var activeWidgets: [NotchWidget] = []
private var registeredWidgets: [String: NotchWidget] = [:]
private var cancellables = Set<AnyCancellable>()
func register(_ widget: NotchWidget) {
guard registeredWidgets[widget.identifier] == nil else {
print("⚠️ Widget \(widget.identifier) già registrato")
return
}
registeredWidgets[widget.identifier] = widget
widget.updatePublisher
.sink { [weak self] in
self?.objectWillChange.send()
}
.store(in: &cancellables)
rebuildActiveList()
}
func unregister(identifier: String) {
guard let widget = registeredWidgets.removeValue(forKey: identifier) else {
return
}
widget.deactivate()
rebuildActiveList()
}
func setActive(_ active: Bool, for identifier: String) {
guard let widget = registeredWidgets[identifier] else { return }
if active {
widget.activate()
if !activeWidgets.contains(where: { $0.identifier == identifier }) {
activeWidgets.append(widget)
activeWidgets.sort { $0.priority > $1.priority }
}
} else {
widget.deactivate()
activeWidgets.removeAll { $0.identifier == identifier }
}
}
func widgets(for position: WidgetPosition) -> [NotchWidget] {
activeWidgets.filter { $0.preferredPosition == position }
}
private func rebuildActiveList() {
activeWidgets = registeredWidgets.values
.sorted { $0.priority > $1.priority }
}
}
// MARK: - Esempio: Build Status Widget
final class BuildStatusWidget: NotchWidget {
let identifier = "com.notchy.buildstatus"
let preferredPosition: WidgetPosition = .left
let priority: WidgetPriority = .high
var requiredWidth: CGFloat { isExpanded ? 200 : 80 }
private let _view: BuildStatusView
var view: NSView { _view }
private let updateSubject = PassthroughSubject<Void, Never>()
var updatePublisher: AnyPublisher<Void, Never> {
updateSubject.eraseToAnyPublisher()
}
@Published private(set) var buildState: BuildState = .idle
private var isExpanded = false
enum BuildState {
case idle
case building(progress: Double)
case success
case failed(error: String)
var color: NSColor {
switch self {
case .idle: return .systemGray
case .building: return .systemBlue
case .success: return .systemGreen
case .failed: return .systemRed
}
}
}
init() {
_view = BuildStatusView()
_view.widget = self
}
func activate() {
startMonitoring()
}
func deactivate() {
stopMonitoring()
}
func handleHover(_ isHovering: Bool) {
isExpanded = isHovering
updateSubject.send()
}
private func startMonitoring() {
// In produzione: usa XCBBuildService o webhook CI
print("📊 Build monitoring attivato")
}
private func stopMonitoring() {
print("📊 Build monitoring disattivato")
}
func updateState(_ newState: BuildState) {
buildState = newState
updateSubject.send()
}
}
// MARK: - Vista per BuildStatusWidget
final class BuildStatusView: NSView {
weak var widget: BuildStatusWidget?
private let indicator = NSView()
private let label = NSTextField(labelWithString: "")
override init(frame: NSRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupUI()
}
private func setupUI() {
wantsLayer = true
layer?.cornerRadius = 8
layer?.backgroundColor = NSColor.black.withAlphaComponent(0.7).cgColor
indicator.wantsLayer = true
indicator.layer?.cornerRadius = 4
indicator.translatesAutoresizingMaskIntoConstraints = false
addSubview(indicator)
label.font = .systemFont(ofSize: 10, weight: .medium)
label.textColor = .white
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
NSLayoutConstraint.activate([
indicator.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
indicator.centerYAnchor.constraint(equalTo: centerYAnchor),
indicator.widthAnchor.constraint(equalToConstant: 8),
indicator.heightAnchor.constraint(equalToConstant: 8),
label.leadingAnchor.constraint(equalTo: indicator.trailingAnchor, constant: 6),
label.centerYAnchor.constraint(equalTo: centerYAnchor),
label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8)
])
}
func update(for state: BuildStatusWidget.BuildState) {
indicator.layer?.backgroundColor = state.color.cgColor
switch state {
case .idle:
label.stringValue = "Idle"
case .building(let progress):
label.stringValue = "Building \(Int(progress * 100))%"
case .success:
label.stringValue = "✓ Success"
case .failed(let error):
label.stringValue = "✗ \(error)"
}
}
}
|
📝 Il WidgetRegistry usa il pattern Observer con Combine. Puoi connettere updatePublisher ai tuoi data stream esistenti per aggiornamenti in tempo reale.
Configurazione per Produzione
Per distribuire Notchy in produzione, serve una configurazione robusta che gestisca correttamente il sandboxing macOS, le entitlements e il code signing.
Entitlements e Info.plist
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| <!-- Notchy.entitlements -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Accesso alla window list per posizionamento sopra altre app -->
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>com.apple.windowserver.active</string>
</array>
<!-- Necessario per overlay a livello di sistema -->
<key>com.apple.security.app-sandbox</key>
<false/>
<!-- Accessibility per interazioni avanzate -->
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>
|
⚠️ Disabilitare il sandbox (app-sandbox = false) impedisce la distribuzione su Mac App Store. Per lo Store, usa un’app helper non-sandboxed con XPC.
Configuration Manager
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
| // NotchyConfiguration.swift
import Foundation
import os.log
/// Configurazione centralizzata per deployment in produzione
public struct NotchyConfiguration: Codable {
// MARK: - Impostazioni UI
public var animationDuration: TimeInterval = 0.3
public var maxExpandedHeight: CGFloat = 200
public var cornerRadius: CGFloat = 22
public var blurIntensity: CGFloat = 0.85
// MARK: - Comportamento
public var autoCollapseDelay: TimeInterval = 5.0
public var enableHaptics: Bool = true
public var respectDoNotDisturb: Bool = true
// MARK: - Performance
public var maxConcurrentAnimations: Int = 3
public var enableMetalRendering: Bool = true
public var frameRateLimit: Int = 120
// MARK: - Debug (disabilitare in produzione)
public var debugOverlayEnabled: Bool = false
public var verboseLogging: Bool = false
public static var shared: NotchyConfiguration = {
loadFromDisk() ?? NotchyConfiguration()
}()
private static let configURL: URL = {
let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first!
let notchyDir = appSupport.appendingPathComponent("Notchy", isDirectory: true)
try? FileManager.default.createDirectory(
at: notchyDir,
withIntermediateDirectories: true
)
return notchyDir.appendingPathComponent("config.json")
}()
private static func loadFromDisk() -> NotchyConfiguration? {
guard let data = try? Data(contentsOf: configURL) else { return nil }
do {
return try JSONDecoder().decode(NotchyConfiguration.self, from: data)
} catch {
os_log(.error, "Errore parsing configurazione: %{public}@", error.localizedDescription)
return nil
}
}
public func save() throws {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data
|