Learn to create Dynamic Island-style notch overlays for macOS apps using Notchy framework. Master AppKit screen geometry, animated widgets, and notch detection.
Building Custom Notch Overlays for macOS Apps with Notchy: A Deep Dive into Dynamic Island-Style UI
Apple’s MacBook Pro notch created an interesting design challenge: a hardware element that interrupts the traditional rectangular display. While many developers treat it as dead space, the Dynamic Island on iPhone proved that this “problem area” can become a feature. Notchy brings this concept to macOS, letting you build notch-aware overlays that display build status, system metrics, or media controls—without fighting the hardware.
This tutorial walks you through building a production-ready notch overlay system using AppKit’s screen geometry APIs. You’ll implement notch detection, create animated widgets, and handle the edge cases that trip up most implementations.
Prerequisites
Before diving in, ensure you have:
- macOS 12.0+ (Monterey or later for notch API support)
- Xcode 14+ with Swift 5.7
- A MacBook Pro with notch (14" or 16" models from 2021+), though we’ll handle notch-less Macs
- Familiarity with AppKit basics (NSWindow, NSView, NSScreen)
- Understanding of Swift concurrency (async/await)
💡 If you’re testing on a notch-less Mac, the code includes simulation mode for development.
Install the Notchy framework via Swift Package Manager:
1
2
3
4
| // Package.swift
dependencies: [
.package(url: "https://github.com/your-org/notchy.git", from: "1.0.0")
]
|
Architecture and Key Concepts
Notchy uses a layered architecture that separates screen geometry detection from widget rendering. This separation allows widgets to remain agnostic about display configuration while the positioning layer handles hardware-specific calculations.
flowchart TD
subgraph Detection["Screen Detection Layer"]
A[NSScreen Observer] --> B{Has Notch?}
B -->|Yes| C[NotchGeometry Calculator]
B -->|No| D[Fallback Menu Bar Position]
C --> E[Safe Area Insets]
D --> E
end
subgraph Positioning["Window Positioning Layer"]
E --> F[NotchWindowController]
F --> G[Anchor Point Calculator]
G --> H[Animation Coordinator]
end
subgraph Widgets["Widget Layer"]
H --> I[Widget Container]
I --> J[Build Status Widget]
I --> K[System Metrics Widget]
I --> L[Media Controls Widget]
end
subgraph EdgeCases["Edge Case Handlers"]
M[External Display Monitor] --> F
N[Screen Recording Detector] --> I
O[Display Configuration Change] --> A
end
The three core concepts you need to understand:
- NotchGeometry: Represents the physical notch dimensions and position relative to screen coordinates
- NotchWindow: A borderless, floating window that anchors to the notch area
- NotchWidget: A protocol-conforming view that renders content and handles its own update cycle
📝 AppKit’s NSScreen.safeAreaInsets (macOS 12+) provides notch dimensions, but we need additional calculations for pixel-perfect positioning.
Step-by-Step Implementation
Detecting Notch Geometry Across Display Configurations
The foundation of any notch overlay is accurate geometry detection. We need to handle three scenarios: built-in display with notch, built-in display without notch, and external displays.
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
| // NotchGeometry.swift
import AppKit
public struct NotchGeometry: Equatable, Sendable {
public let frame: NSRect
public let screenFrame: NSRect
public let safeAreaInsets: NSEdgeInsets
public let isSimulated: Bool
/// The usable width on each side of the notch
public var leftSideWidth: CGFloat {
frame.minX - screenFrame.minX
}
public var rightSideWidth: CGFloat {
screenFrame.maxX - frame.maxX
}
/// Center point for anchoring overlays
public var anchorPoint: NSPoint {
NSPoint(
x: frame.midX,
y: frame.maxY - safeAreaInsets.top
)
}
}
public final class NotchDetector: @unchecked Sendable {
public static let shared = NotchDetector()
private var displayConfigurationObserver: Any?
private let notificationCenter = NotificationCenter.default
// Callback for geometry changes
public var onGeometryChange: ((NotchGeometry?) -> Void)?
private init() {
setupDisplayObserver()
}
/// Detects notch geometry for a given screen
public func detectNotch(for screen: NSScreen) -> NotchGeometry? {
let safeAreaInsets = screen.safeAreaInsets
// A notch exists if the top safe area inset is significant
// MacBook Pro notch creates approximately 38pt inset
guard safeAreaInsets.top > 0 else {
return nil
}
let screenFrame = screen.frame
// Calculate notch frame from safe area insets
// The notch width is derived from the difference between
// screen width and the sum of left/right safe areas
let notchWidth = calculateNotchWidth(screen: screen)
let notchHeight = safeAreaInsets.top
let notchFrame = NSRect(
x: screenFrame.midX - (notchWidth / 2),
y: screenFrame.maxY - notchHeight,
width: notchWidth,
height: notchHeight
)
return NotchGeometry(
frame: notchFrame,
screenFrame: screenFrame,
safeAreaInsets: safeAreaInsets,
isSimulated: false
)
}
/// Finds the primary display with a notch
public func primaryNotchScreen() -> NSScreen? {
// The built-in display is typically the one with a notch
NSScreen.screens.first { screen in
screen.safeAreaInsets.top > 0 && isBuiltInDisplay(screen)
}
}
private func calculateNotchWidth(screen: NSScreen) -> CGFloat {
// Apple's notch dimensions are consistent across models
// 14" and 16" MacBook Pro: approximately 180pt wide
let auxiliaryTopLeftArea = screen.auxiliaryTopLeftArea
let auxiliaryTopRightArea = screen.auxiliaryTopRightArea
if let leftArea = auxiliaryTopLeftArea,
let rightArea = auxiliaryTopRightArea {
// Notch width = screen width - left area - right area
return screen.frame.width - leftArea.width - rightArea.width
}
// Fallback for older APIs
return 180.0
}
private func isBuiltInDisplay(_ screen: NSScreen) -> Bool {
// CGDisplayIsBuiltin requires the display ID
guard let screenNumber = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else {
return false
}
return CGDisplayIsBuiltin(screenNumber) != 0
}
private func setupDisplayObserver() {
displayConfigurationObserver = notificationCenter.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.handleDisplayConfigurationChange()
}
}
private func handleDisplayConfigurationChange() {
let geometry = primaryNotchScreen().flatMap { detectNotch(for: $0) }
onGeometryChange?(geometry)
}
deinit {
if let observer = displayConfigurationObserver {
notificationCenter.removeObserver(observer)
}
}
}
|
⚠️ auxiliaryTopLeftArea and auxiliaryTopRightArea are available in macOS 12.0+. Always check for their availability when supporting older systems.
Creating the Notch-Anchored Window Controller
With geometry detection in place, we need a window controller that positions a transparent, borderless window precisely at the notch location. This window serves as the container for all widgets.
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
| // NotchWindowController.swift
import AppKit
import Combine
public final class NotchWindowController: NSWindowController {
private var geometry: NotchGeometry?
private var cancellables = Set<AnyCancellable>()
private let animationDuration: TimeInterval = 0.3
// Widget container view
private lazy var containerView: NotchContainerView = {
let view = NotchContainerView()
view.wantsLayer = true
view.layer?.cornerRadius = 12
view.layer?.masksToBounds = true
return view
}()
public init() {
// Create a borderless, transparent window
let window = NotchWindow(
contentRect: NSRect(x: 0, y: 0, width: 300, height: 44),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
super.init(window: window)
configureWindow()
setupGeometryObserver()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func configureWindow() {
guard let window = window as? NotchWindow else { return }
// Window configuration for overlay behavior
window.isOpaque = false
window.backgroundColor = .clear
window.level = .statusBar + 1 // Above menu bar, below alerts
window.collectionBehavior = [
.canJoinAllSpaces, // Visible on all spaces
.stationary, // Doesn't move with space switches
.ignoresCycle // Command-Tab ignores it
]
window.hasShadow = true
window.contentView = containerView
// Ensure the window doesn't become key and steal focus
window.hidesOnDeactivate = false
}
private func setupGeometryObserver() {
NotchDetector.shared.onGeometryChange = { [weak self] geometry in
self?.updatePosition(with: geometry)
}
// Initial positioning
if let screen = NotchDetector.shared.primaryNotchScreen() {
let initialGeometry = NotchDetector.shared.detectNotch(for: screen)
updatePosition(with: initialGeometry)
}
}
/// Updates window position based on new geometry
public func updatePosition(with geometry: NotchGeometry?) {
self.geometry = geometry
guard let window = window,
let geometry = geometry else {
// No notch available - hide or use fallback position
handleNoNotchScenario()
return
}
// Calculate window frame anchored below the notch
let windowSize = calculateWindowSize(for: geometry)
let windowOrigin = calculateWindowOrigin(for: geometry, size: windowSize)
let targetFrame = NSRect(origin: windowOrigin, size: windowSize)
NSAnimationContext.runAnimationGroup { context in
context.duration = animationDuration
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrame(targetFrame, display: true)
}
}
private func calculateWindowSize(for geometry: NotchGeometry) -> NSSize {
// Window should be slightly wider than the notch for visual balance
let baseWidth = geometry.frame.width + 40
let baseHeight: CGFloat = 44
// Adjust based on widget content
let contentSize = containerView.intrinsicContentSize
return NSSize(
width: max(baseWidth, contentSize.width),
height: max(baseHeight, contentSize.height)
)
}
private func calculateWindowOrigin(for geometry: NotchGeometry, size: NSSize) -> NSPoint {
// Center horizontally relative to the notch
let x = geometry.anchorPoint.x - (size.width / 2)
// Position just below the notch (accounting for the notch height)
// Small offset (2pt) prevents visual collision with notch edge
let y = geometry.screenFrame.maxY - geometry.safeAreaInsets.top - size.height + 2
return NSPoint(x: x, y: y)
}
private func handleNoNotchScenario() {
// Fallback: position in center-top of menu bar area
guard let window = window,
let screen = NSScreen.main else { return }
let menuBarHeight: CGFloat = NSStatusBar.system.thickness
let windowSize = NSSize(width: 300, height: 44)
let x = screen.frame.midX - (windowSize.width / 2)
let y = screen.frame.maxY - menuBarHeight - windowSize.height - 4
window.setFrame(NSRect(origin: NSPoint(x: x, y: y), size: windowSize), display: true)
}
/// Animates the overlay expansion (Dynamic Island style)
public func expandOverlay(to size: NSSize, animated: Bool = true) {
guard let window = window, let geometry = geometry else { return }
let origin = calculateWindowOrigin(for: geometry, size: size)
let targetFrame = NSRect(origin: origin, size: size)
if animated {
NSAnimationContext.runAnimationGroup { context in
context.duration = animationDuration
context.timingFunction = CAMediaTimingFunction(
controlPoints: 0.34, 1.56, 0.64, 1 // Spring-like curve
)
window.animator().setFrame(targetFrame, display: true)
}
} else {
window.setFrame(targetFrame, display: true)
}
}
/// Collapses overlay back to compact size
public func collapseOverlay(animated: Bool = true) {
guard let geometry = geometry else { return }
let compactSize = NSSize(width: geometry.frame.width + 40, height: 44)
expandOverlay(to: compactSize, animated: animated)
}
}
// MARK: - NotchWindow Subclass
private final class NotchWindow: NSPanel {
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
override func accessibilityRole() -> NSAccessibility.Role? {
.popover
}
}
|
💡 Using NSPanel instead of NSWindow gives you better control over activation behavior. The nonactivatingPanel style mask prevents the overlay from stealing focus from other apps.
The widget system needs to be modular enough that developers can create custom widgets while maintaining consistent update patterns and lifecycle management. We’ll use a protocol-based approach with async update streams.
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
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
| // NotchWidget.swift
import AppKit
import Combine
/// Defines the contract for notch-compatible widgets
public protocol NotchWidget: NSView {
/// Unique identifier for this widget type
static var widgetIdentifier: String { get }
/// Preferred size when displayed in compact mode
var compactSize: NSSize { get }
/// Preferred size when expanded
var expandedSize: NSSize { get }
/// Current expansion state
var isExpanded: Bool { get set }
/// Called when the widget becomes visible
func activate()
/// Called when the widget is hidden or removed
func deactivate()
/// Publisher for widget state changes that require container updates
var stateDidChange: AnyPublisher<WidgetStateChange, Never> { get }
}
public enum WidgetStateChange {
case sizeChanged(compact: NSSize, expanded: NSSize)
case requestExpansion
case requestCollapse
case contentUpdated
}
// MARK: - Widget Container
public final class NotchContainerView: NSView {
private var activeWidgets: [any NotchWidget] = []
private var cancellables = Set<AnyCancellable>()
private let stackView: NSStackView
public var onExpansionRequested: ((Bool) -> Void)?
public override init(frame frameRect: NSRect) {
stackView = NSStackView()
stackView.orientation = .horizontal
stackView.spacing = 8
stackView.alignment = .centerY
stackView.distribution = .fillProportionally
super.init(frame: frameRect)
setupStackView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupStackView() {
stackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(stackView)
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
stackView.topAnchor.constraint(equalTo: topAnchor, constant: 6),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -6)
])
}
/// Adds a widget to the container
public func addWidget(_ widget: some NotchWidget) {
activeWidgets.append(widget)
stackView.addArrangedSubview(widget)
// Subscribe to widget state changes
widget.stateDidChange
.receive(on: DispatchQueue.main)
.sink { [weak self] change in
self?.handleWidgetStateChange(change, from: widget)
}
.store(in: &cancellables)
widget.activate()
invalidateIntrinsicContentSize()
}
/// Removes a widget from the container
public func removeWidget(_ widget: some NotchWidget) {
widget.deactivate()
stackView.removeArrangedSubview(widget)
widget.removeFromSuperview()
activeWidgets.removeAll { $0 === widget }
invalidateIntrinsicContentSize()
}
private func handleWidgetStateChange(_ change: WidgetStateChange, from widget: some NotchWidget) {
switch change {
case .requestExpansion:
onExpansionRequested?(true)
case .requestCollapse:
onExpansionRequested?(false)
case .sizeChanged, .contentUpdated:
invalidateIntrinsicContentSize()
}
}
public override var intrinsicContentSize: NSSize {
// Calculate total size based on active widgets
let isExpanded = activeWidgets.contains { $0.isExpanded }
let widgetSizes = activeWidgets.map { widget in
isExpanded ? widget.expandedSize : widget.compactSize
}
let totalWidth = widgetSizes.reduce(0) { $0 + $1.width }
+ CGFloat(max(0, widgetSizes.count - 1)) * stackView.spacing
+ 24 // Horizontal padding
let maxHeight = widgetSizes.map(\.height).max() ?? 32
return NSSize(width: totalWidth, height: maxHeight + 12)
}
}
// MARK: - Build Status Widget Implementation
public final class BuildStatusWidget: NSView, NotchWidget {
public static let widgetIdentifier = "com.notchy.widget.buildStatus"
public var compactSize: NSSize { NSSize(width: 120, height: 32) }
public var expandedSize: NSSize { NSSize(width: 280, height: 80) }
public var isExpanded: Bool = false {
didSet {
updateLayout()
}
}
private let stateSubject = PassthroughSubject<WidgetStateChange, Never>()
public var stateDidChange: AnyPublisher<WidgetStateChange, Never> {
stateSubject.eraseToAnyPublisher()
}
// UI Components
private let statusIndicator = NSView()
private let statusLabel = NSTextField(labelWithString: "")
private let detailLabel = NSTextField(labelWithString: "")
private let progressIndicator = NSProgressIndicator()
// State
private var currentStatus: BuildStatus = .idle
public enum BuildStatus: Equatable {
case idle
case building(progress: Double, target: String)
case success(duration: TimeInterval)
case failed(error: String)
}
public override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
wantsLayer = true
layer?.cornerRadius = 8
layer?.backgroundColor = NSColor.black.withAlphaComponent(0.8).cgColor
// Status indicator (colored dot)
statusIndicator.wantsLayer = true
statusIndicator.layer?.cornerRadius = 5
statusIndicator.translatesAutoresizingMaskIntoConstraints = false
// Labels
statusLabel.font = .systemFont(ofSize: 12, weight: .medium)
statusLabel.textColor = .white
statusLabel.translatesAutoresizingMaskIntoConstraints = false
detailLabel.font = .systemFont(ofSize: 10)
detailLabel.textColor = .secondaryLabelColor
detailLabel.translatesAutoresizingMaskIntoConstraints = false
detailLabel.isHidden = true
// Progress indicator
progressIndicator.style = .bar
progressIndicator.isIndeterminate = false
progressIndicator.translatesAutoresizingMaskIntoConstraints = false
progressIndicator.isHidden = true
addSubview(statusIndicator)
addSubview(statusLabel)
addSubview(detailLabel)
addSubview(progressIndicator)
setupConstraints()
updateStatus(.idle)
}
private func setupConstraints() {
NSLayoutConstraint.activate([
statusIndicator.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
statusIndicator.centerYAnchor.constraint(equalTo: centerYAnchor),
statusIndicator.widthAnchor.constraint(equalToConstant: 10),
statusIndicator.heightAnchor.constraint(equalToConstant: 10),
statusLabel.leadingAnchor.constraint(equalTo: statusIndicator.trailingAnchor, constant: 6),
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8),
statusLabel.centerYAnchor.constraint(equalTo: centerYAnchor)
])
}
private func updateLayout() {
detailLabel.isHidden = !isExpanded
progressIndicator.isHidden = !isExpanded || !currentStatus.isBuilding
stateSubject.send(.contentUpdated)
}
// MARK: - Public API
func updateStatus(_ status: BuildStatus) {
currentStatus = status
statusIndicator.layer?.backgroundColor = status.tintColor.cgColor
statusLabel.stringValue = status.title
detailLabel.stringValue = status.detail ?? ""
if status.isBuilding {
progressIndicator.doubleValue = status.progress * 100
}
updateLayout()
}
func setExpanded(_ expanded: Bool, animated: Bool = true) {
guard expanded != isExpanded else { return }
isExpanded = expanded
NSAnimationContext.runAnimationGroup { context in
context.duration = animated ? 0.22 : 0
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
updateLayout()
layoutSubtreeIfNeeded()
}
}
/// Called by the registry when the widget is removed from the notch stack.
func teardown() {
cancellables.removeAll()
stateSubject.send(completion: .finished)
removeFromSuperview()
}
}
|
The widget exposes a stateSubject publisher; the window controller subscribes to .contentUpdated events and recomputes the notch window’s height so the overlay grows and shrinks with its content:
1
2
3
4
5
6
7
| widget.statePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] event in
guard case .contentUpdated = event else { return }
self?.resizeWindowToFitContent(animated: true)
}
.store(in: &cancellables)
|
Production Configuration
- Run as an accessory app. Set
LSUIElement to YES in Info.plist so the overlay has no Dock icon and no menu bar — it’s chrome, not an app window. - Window level and collection behavior. Keep the notch window at
.statusBar level (or .mainMenu + 1) with collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] so it stays put across Spaces and doesn’t appear in Mission Control or `⌘``. - Ignore mouse events by default.
window.ignoresMouseEvents = true until a widget actually needs interaction, then toggle it per hit-test region — otherwise you steal clicks from whatever is behind the notch. - Multi-display. Recompute geometry on
NSApplication.didChangeScreenParametersNotification; the built-in notch only exists on the internal display, and external monitors need a synthesized anchor rect. - Persist config to
~/Library/Application Support/<App>/config.json, reload on change, and ship sane defaults so a corrupt file can’t brick the overlay. - Sign and harden. For distribution outside the App Store, enable the Hardened Runtime and notarize; a background UI process that isn’t notarized will be blocked by Gatekeeper.
Common Mistakes and Troubleshooting
The overlay sits a few pixels off the notch. You used NSScreen.frame instead of safeAreaInsets / auxiliaryTopLeftArea & auxiliaryTopRightArea. The notch width is the gap between those two auxiliary areas, not a hard-coded constant.
Nothing appears on Macs without a notch. That’s expected for the physical notch, but if you want the overlay everywhere, synthesize an anchor rect centered on screen.frame.midX with your design width and fall back to that path.
The window disappears when switching Spaces. Missing .canJoinAllSpaces in collectionBehavior, or the window was created before the screen was available.
Clicks pass through when they shouldn’t (or vice versa). ignoresMouseEvents is global to the window. Use a content view that overrides hitTest(_:) to return nil outside interactive regions.
Animations stutter under load. You’re animating Auto Layout constraints on the main thread while a build floods status updates. Coalesce updates (throttle on the Combine publisher, ~16 ms) and animate a single height constraint, not a dozen.
Overlay is clipped by full-screen apps. Full-screen Spaces raise the window level bar. Use .screenSaver level if the overlay must survive full-screen video, and test with a real full-screen app.
- Throttle status input. A CI build or file watcher can emit hundreds of events/second;
.throttle(for: .milliseconds(100), scheduler: RunLoop.main, latest: true) keeps the UI at display cadence. - Animate one constraint. Drive the expand/collapse from a single
heightAnchor constant with NSAnimationContext; layer-backed views make this cheap. - Layer-back everything.
wantsLayer = true on the container so AppKit composites on the GPU instead of redrawing the view tree. - Cap the frame rate. A
frameRateLimit (e.g. 120 on ProMotion, 60 otherwise) avoids spinning a CVDisplayLink faster than the panel. - One window, many widgets. The registry stacks widgets in a single borderless window; don’t create a window per widget.
- Idle to zero. When no widget has content, hide the window (
orderOut) rather than keeping a transparent 0-height window alive.
Conclusion and Next Steps
You now have the three pieces of a notch overlay: geometry detection that survives multi-display and notch-less Macs, a borderless always-on-top window anchored to the notch, and a modular widget registry whose contents resize the window through a Combine publisher. The design keeps AppKit window management separate from widget content, so adding a new widget is a NotchWidget conformance, not a window rewrite.
Next steps:
- Add hover-to-expand and a click-through hit-test layer for interactive widgets.
- Give widgets a priority so the most important one wins the limited notch width.
- Add a preferences window for per-widget enable/disable and position.
- Package as a Swift Package so other apps can embed the overlay.
- Study the real Dynamic Island transitions and approximate the spring curves with
CASpringAnimation.
Additional Resources