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) } }