diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 8b505e8..41a255f 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -25,8 +25,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) - let coordinator = NotchCoordinator(policy: .builtInOnly) { displayID in - NSHostingView(rootView: NotchPanelContent(displayID: displayID)) + let coordinator = NotchCoordinator(policy: .builtInOnly) { displayID, presentation in + NotchHostingView( + rootView: NotchContainer(presentation: presentation) { + NotchPanelContent(displayID: displayID) + }, + presentation: presentation) } coordinator.start() self.coordinator = coordinator diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift index 208558a..817363c 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift @@ -24,11 +24,13 @@ public final class NotchCoordinator { private var suppressionTimer: Timer? /// Baut den Inhalt für einen Bildschirm. Ab Phase 2 liefert das die Widget-Ebene. - private let makeContent: (CGDirectDisplayID) -> NSView + /// Die `NotchPresentation` kommt mit, weil der Inhalt in den Animationsrahmen + /// gehört — sonst müsste jede Aufrufstelle den Animationszustand selbst führen. + private let makeContent: (CGDirectDisplayID, NotchPresentation) -> NSView public init(policy: DisplayPolicy = .builtInOnly, virtualNotchSize: CGSize = CGSize(width: 220, height: 32), - makeContent: @escaping (CGDirectDisplayID) -> NSView) { + makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) { self.policy = policy self.virtualNotchSize = virtualNotchSize self.makeContent = makeContent @@ -80,10 +82,11 @@ public final class NotchCoordinator { // ein Auflösungswechsel darf ein offenes Panel nicht wegreißen. existing.update(metrics: metrics, virtualSize: virtualNotchSize) } else { - controllers[metrics.displayID] = NotchScreenController( + let id = metrics.displayID + controllers[id] = NotchScreenController( metrics: metrics, virtualSize: virtualNotchSize, - content: makeContent(metrics.displayID)) + makeContent: { presentation in makeContent(id, presentation) }) } } refreshSuppression() diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchPanel.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchPanel.swift index 5062d7b..023e41a 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchPanel.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchPanel.swift @@ -30,7 +30,11 @@ public final class NotchPanel: NSPanel { backgroundColor = .clear isOpaque = false - hasShadow = true + // Der Fensterschatten folgt dem Fensterrahmen, nicht der gezeichneten + // Form. Da das Fenster immer die volle Panelgröße hat, läge der Schatten + // während der ganzen Animation um eine unsichtbare Fläche. Den Schatten + // zeichnet deshalb SwiftUI, wo er der wachsenden Form folgt. + hasShadow = false isMovable = false isMovableByWindowBackground = false animationBehavior = .none diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift new file mode 100644 index 0000000..de1c704 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift @@ -0,0 +1,117 @@ +import SwiftUI +import AppKit +import OnyxDesign + +/// Der Animationszustand eines Panels. Der Controller setzt `isExpanded`, +/// die View animiert daraufhin. +@MainActor +@Observable +public final class NotchPresentation { + /// Ausgefahren oder auf Notch-Größe zusammengezogen. + public var isExpanded = false + /// Die Größe, aus der heraus gewachsen wird — die echte Notch bzw. ihr + /// virtueller Ersatz. + public var collapsedSize: CGSize + public var expandedSize: CGSize + + public init(collapsedSize: CGSize, expandedSize: CGSize) { + self.collapsedSize = collapsedSize + self.expandedSize = expandedSize + } + + /// Wie lange das Einfahren braucht, bis das Fenster gefahrlos verschwinden darf. + /// Der Spring schwingt nach; zu früh entfernt reißt das Panel sichtbar weg. + public static let collapseDuration: TimeInterval = 0.42 +} + +/// Der Rahmen, der aus der Notch herauswächst. +/// +/// Die Fläche wird **nicht** eingeblendet. Sie beginnt exakt in der Notch-Silhouette +/// und wächst von dort nach unten und zur Seite — deckungsgleich mit der schwarzen +/// Hardware darüber, sodass beides als ein Körper wirkt. Eingeblendet wird nur der +/// Inhalt, und zwar erst, wenn die Fläche fast ihre Endgröße hat: Text, der sich +/// mitdehnt, verrät die Animation als Skalierung und sieht billig aus. +public struct NotchContainer: View { + + private let presentation: NotchPresentation + private let content: Content + + public init(presentation: NotchPresentation, @ViewBuilder content: () -> Content) { + self.presentation = presentation + self.content = content() + } + + private var size: CGSize { + presentation.isExpanded ? presentation.expandedSize : presentation.collapsedSize + } + + private var cornerRadius: CGFloat { + // Zusammengezogen die Rundung der Notch-Unterkante, ausgefahren die des + // Panels. Ein fester Radius lässt die kleine Form aufgeblasen aussehen. + presentation.isExpanded ? Onyx.Metric.panelCornerRadius : 10 + } + + public var body: some View { + ZStack(alignment: .top) { + OnyxSurface(cornerRadius: cornerRadius) + .frame(width: size.width, height: size.height) + .shadow(color: .black.opacity(presentation.isExpanded ? 0.45 : 0), + radius: 22, y: 10) + .overlay(alignment: .top) { + content + .frame(width: presentation.expandedSize.width, + height: presentation.expandedSize.height) + .opacity(presentation.isExpanded ? 1 : 0) + // Ein Hauch von oben herangleiten — genug, um Richtung zu + // geben, zu wenig, um als Bewegung aufzufallen. + .offset(y: presentation.isExpanded ? 0 : -8) + .allowsHitTesting(presentation.isExpanded) + .clipped() + } + .animation(Onyx.Motion.expand, value: presentation.isExpanded) + } + .frame(width: presentation.expandedSize.width, + height: presentation.expandedSize.height, + alignment: .top) + // Der Inhalt hat eine eigene Kurve: beim Öffnen verzögert, damit die + // Fläche vorweg läuft; beim Schließen sofort, damit nichts beim + // Einfahren gestaucht wird. + .animation(presentation.isExpanded + ? .easeOut(duration: 0.22).delay(0.09) + : .easeIn(duration: 0.10), + value: presentation.isExpanded) + } +} + +/// Hosting-View, die nur dort Klicks annimmt, wo auch etwas zu sehen ist. +/// +/// Das Fenster hat immer die volle Panelgröße, damit die Animation Platz zum +/// Wachsen hat. Ohne diese Einschränkung würde es während der 340 ms des +/// Ausfahrens — und noch einmal beim Einfahren — Klicks in seiner unsichtbaren +/// Fläche verschlucken. +public final class NotchHostingView: NSHostingView { + + private let presentation: NotchPresentation + + public init(rootView: Content, presentation: NotchPresentation) { + self.presentation = presentation + super.init(rootView: rootView) + } + + @available(*, unavailable) + required init(rootView: Content) { fatalError() } + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + public override func hitTest(_ point: NSPoint) -> NSView? { + let size = presentation.isExpanded ? presentation.expandedSize : presentation.collapsedSize + // Bildschirmkoordinaten wachsen nach oben, View-Koordinaten nach unten: + // die sichtbare Fläche hängt oben mittig im Fenster. + let visible = CGRect(x: (bounds.width - size.width) / 2, + y: 0, + width: size.width, + height: size.height) + guard visible.contains(point) else { return nil } + return super.hitTest(point) + } +} diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift index 909fd29..9646029 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift @@ -3,28 +3,42 @@ import AppKit /// Führt für genau einen Bildschirm Geometrie, Zustandsmaschine und Fenster zusammen. /// /// Die Entscheidungen trifft `NotchStateMachine`; diese Klasse übersetzt sie nur -/// in Timer und Fensteraufrufe. Deshalb steht hier auch kein `if phase == …`. +/// in Timer, Fensteraufrufe und Animationszustände. Deshalb steht hier auch kein +/// `if phase == …`. @MainActor public final class NotchScreenController { public private(set) var geometry: NotchGeometry public let displayID: CGDirectDisplayID + public let presentation: NotchPresentation private var machine = NotchStateMachine() private let panel: NotchPanel private var armTimer: DispatchWorkItem? private var closeTimer: DispatchWorkItem? + /// Entfernt das Fenster erst, wenn das Einfahren zu Ende ist. + private var teardownTimer: DispatchWorkItem? /// Größe des ausgefahrenen Panels. Ab Phase 2 liefert das die Layout-Engine. - public var expandedSize = CGSize(width: 640, height: 320) { - didSet { if machine.isVisible { layoutPanel() } } + public var expandedSize: CGSize { + didSet { + guard expandedSize != oldValue else { return } + presentation.expandedSize = expandedSize + layoutPanel() + } } - public init(metrics: ScreenMetrics, virtualSize: CGSize, content: NSView) { + public init(metrics: ScreenMetrics, + virtualSize: CGSize, + expandedSize: CGSize = CGSize(width: 640, height: 320), + makeContent: (NotchPresentation) -> NSView) { self.displayID = metrics.displayID self.geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize) + self.expandedSize = expandedSize + self.presentation = NotchPresentation(collapsedSize: geometry.rect.size, + expandedSize: expandedSize) self.panel = NotchPanel(contentRect: .zero) - panel.contentView = content + panel.contentView = makeContent(presentation) layoutPanel() } @@ -35,8 +49,12 @@ public final class NotchScreenController { /// Zeigerposition in globalen Bildschirmkoordinaten. public func pointerMoved(to point: CGPoint) { + // Nur die tatsächlich sichtbare Fläche zählt. Das Fenster ist immer so + // groß wie das ausgefahrene Panel, damit die Animation Platz hat — + // würde man `panel.frame` nehmen, hielte ein Zeiger in der noch leeren + // Fläche das Panel offen. let inside = geometry.triggerRect.contains(point) - || (machine.isVisible && panel.frame.contains(point)) + || (presentation.isExpanded && panel.frame.contains(point)) apply(machine.handle(inside ? .pointerEntered : .pointerExited)) } @@ -47,12 +65,14 @@ public final class NotchScreenController { /// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung. public func update(metrics: ScreenMetrics, virtualSize: CGSize) { geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize) + presentation.collapsedSize = geometry.rect.size layoutPanel() } public func tearDown() { armTimer?.cancel() closeTimer?.cancel() + teardownTimer?.cancel() panel.orderOut(nil) } @@ -84,15 +104,41 @@ public final class NotchScreenController { closeTimer = nil case .show: - layoutPanel() - panel.orderFrontRegardless() + show() case .hide: - panel.orderOut(nil) + hide() } } } + private func show() { + // Ein noch laufendes Einfahren abbrechen: wer sofort zurückkehrt, soll + // das Panel wieder aufgehen sehen und nicht dabei zusehen, wie es + // mittendrin verschwindet. + teardownTimer?.cancel() + teardownTimer = nil + + layoutPanel() + panel.orderFrontRegardless() + + // Erst im nächsten Durchlauf ausfahren. Im selben Durchlauf wie + // `orderFrontRegardless` sieht SwiftUI keinen Zustandswechsel, sondern + // nur den Endzustand — das Panel wäre schlagartig da. + DispatchQueue.main.async { [weak self] in + self?.presentation.isExpanded = true + } + } + + private func hide() { + presentation.isExpanded = false + teardownTimer?.cancel() + teardownTimer = schedule(after: NotchPresentation.collapseDuration) { [weak self] in + guard let self, !presentation.isExpanded else { return } + panel.orderOut(nil) + } + } + private func schedule(after delay: TimeInterval, _ body: @escaping @MainActor () -> Void) -> DispatchWorkItem { let item = DispatchWorkItem { MainActor.assumeIsolated { body() } } @@ -100,7 +146,10 @@ public final class NotchScreenController { return item } + /// Das Fenster hat immer die Größe des ausgefahrenen Panels — die Animation + /// findet im Inhalt statt, nicht am Fenster. Fensterrahmen zu animieren + /// überlässt die Interpolation dem Fensterserver und ruckelt sichtbar. private func layoutPanel() { - panel.setFrame(geometry.panelRect(for: expandedSize), display: machine.isVisible) + panel.setFrame(geometry.panelRect(for: expandedSize), display: presentation.isExpanded) } }