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 /// Wie weit der Inhalt von oben wegbleiben muss. /// /// Die Fläche reicht bis an die Bildschirmoberkante, damit sie mit der /// schwarzen Notch-Hardware zu einem Körper verschmilzt. Die Notch ist aber /// undurchsichtig: alles, was in diesen obersten Punkten steht, ist schlicht /// nicht zu sehen. Der Inhalt beginnt deshalb darunter. public var contentTopInset: CGFloat public init(collapsedSize: CGSize, expandedSize: CGSize, contentTopInset: CGFloat) { self.collapsedSize = collapsedSize self.expandedSize = expandedSize self.contentTopInset = contentTopInset } /// Wie lange das Einfahren braucht, bis das Fenster gefahrlos verschwinden darf. /// Muss zu `Onyx.Motion.collapse` passen: zu früh entfernt reißt das Panel /// sichtbar weg, zu spät bleibt ein unsichtbares Fenster im Weg stehen. public static let collapseDuration: TimeInterval = 0.30 } /// 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 } private var shapeAnimation: Animation { presentation.isExpanded ? Onyx.Motion.expand : Onyx.Motion.collapse } public var body: some View { let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) ZStack(alignment: .top) { // Das Weichzeichner-Material behält **feste** Größe; animiert wird // nur die Maske davor. // // Eine NSVisualEffectView in einem animierten Rahmen muss ihren // Weichzeichner in jedem Einzelbild neu in anderer Größe berechnen — // das ruckelt sichtbar, und zwar genau beim Schließen, wo die Fläche // am schnellsten schrumpft. Eine Maske kostet dagegen nichts. OnyxVisualEffect() .frame(width: presentation.expandedSize.width, height: presentation.expandedSize.height) .mask(alignment: .top) { shape.frame(width: size.width, height: size.height) } // Tint, Bänderung und Kante sind reine SwiftUI-Formen. Die dürfen // in der Größe animieren, das kostet nichts. shape .fill(Onyx.Color.surface.opacity(Onyx.Color.surfaceOpacity)) .overlay { shape.fill( LinearGradient( stops: [.init(color: .white.opacity(0.02), location: 0), .init(color: .clear, location: 0.4)], startPoint: .top, endPoint: .bottom)) } .overlay { shape.strokeBorder(Onyx.Color.hairline, lineWidth: 1) } .frame(width: size.width, height: size.height) // Konstante Deckkraft: einen Schatten aufzublenden kostet in // jedem Einzelbild eine neue Weichzeichnung. Er schrumpft // ohnehin mit der Form mit. .shadow(color: .black.opacity(0.45), radius: 22, y: 10) content .frame(width: presentation.expandedSize.width, height: presentation.expandedSize.height - presentation.contentTopInset, alignment: .top) // Freiraum für die Notch. Padding statt Offset, damit der // Inhalt auch beim Messen die richtige Höhe hat. .padding(.top, presentation.contentTopInset) .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() // Eigene Kurve: beim Öffnen verzögert, damit die Fläche vorweg // läuft; beim Schließen sofort weg, damit beim Einfahren nichts // gestaucht wird. .animation(presentation.isExpanded ? .easeOut(duration: 0.20).delay(0.08) : .easeIn(duration: 0.08), value: presentation.isExpanded) } .frame(width: presentation.expandedSize.width, height: presentation.expandedSize.height, alignment: .top) .animation(shapeAnimation, 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) } }