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 } /// Die Fläche, die das Panel oben einnehmen darf, ohne die Menüleiste /// zuzudecken — also die Breite der Notch. /// /// Darunter darf es beliebig breit werden; dort ist keine Menüleiste mehr. public var notchWidth: CGFloat { collapsedSize.width } /// 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 } /// Die Silhouette des Panels: oben nur so breit wie die Notch, darunter voll. /// /// **Warum nicht einfach ein Rechteck.** Das Panel reicht bis an die /// Bildschirmoberkante, damit seine Fläche mit der schwarzen Notch zu einem /// Körper verschmilzt. Sobald es breiter wird als die Notch, deckt es links /// und rechts davon aber die Menüleiste zu — bei sechs Kacheln die ganze, /// samt aller Statuselemente. /// /// Also ein T: oben ein Steg in Notch-Breite, ab Unterkante der Menüleiste die /// volle Fläche. Die Notch bleibt verschmolzen, die Menüleiste bleibt frei. struct NotchShape: Shape, InsettableShape { var cornerRadius: CGFloat var topBandHeight: CGFloat var notchWidth: CGFloat var inset: CGFloat = 0 var animatableData: AnimatablePair { get { .init(cornerRadius, notchWidth) } set { cornerRadius = newValue.first; notchWidth = newValue.second } } func inset(by amount: CGFloat) -> NotchShape { var copy = self copy.inset += amount return copy } func path(in rect: CGRect) -> Path { let rect = rect.insetBy(dx: inset, dy: inset) // Ist das Panel nicht breiter als die Notch, ist der Steg die ganze // Form — dann bleibt es die schlichte abgerundete Fläche. let band = min(max(notchWidth - 2 * inset, 0), rect.width) let radius = min(cornerRadius, rect.width / 2, rect.height / 2) var path = Path() guard topBandHeight > 0, band < rect.width - 0.5 else { path.addRoundedRect(in: rect, cornerSize: CGSize(width: radius, height: radius), style: .continuous) return path } let bandRect = CGRect(x: rect.midX - band / 2, y: rect.minY, width: band, height: topBandHeight + radius) let body = CGRect(x: rect.minX, y: rect.minY + topBandHeight, width: rect.width, height: rect.height - topBandHeight) // Der Steg bekommt keine Rundung nach oben: dort sitzt die Notch, und // eine Rundung an der Bildschirmkante wäre ohnehin nicht zu sehen. path.addRect(bandRect) path.addRoundedRect(in: body, cornerSize: CGSize(width: radius, height: radius), style: .continuous) return path } } /// 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 = NotchShape(cornerRadius: cornerRadius, topBandHeight: presentation.contentTopInset, notchWidth: presentation.notchWidth) 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.stroke(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 } // Links und rechts neben der Notch gehört der oberste Streifen der // Menüleiste. Dort ist das Panel durchsichtig — und muss auch // durchlässig sein, sonst schluckt es Klicks auf fremde // Statuselemente, ohne dass man sieht, warum. if presentation.isExpanded, point.y < presentation.contentTopInset { let band = CGRect(x: (bounds.width - presentation.notchWidth) / 2, y: 0, width: presentation.notchWidth, height: presentation.contentTopInset) guard band.contains(point) else { return nil } } return super.hitTest(point) } }