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 Breite der Notch bzw. ihres virtuellen Ersatzes. public var notchWidth: CGFloat { collapsedSize.width } /// Was eine Live Activity **unter** der Notch aufspannt. `nil` = keine. /// /// Wird vom Presenter gesetzt. public var ambientContentSize: CGSize? /// Ob die Fläche frei schwebt statt mit der Bildschirmkante zu verschmelzen. /// /// Vom Controller aus der Geometrie gesetzt. Eine echte Notch verschmilzt /// mit dem Gehäuse und ist oben eckig; ohne Hardware gibt es nichts zu /// verschmelzen, und eine oben eckige Fläche mit Luft darüber sähe aus wie /// ein abgeschnittenes Rechteck. public var isDetached = false /// Die Arme links und rechts der Notch, auf Menüleistenhöhe. /// /// Nur ausgefahren: eingeklappt hängt die Live Activity **unter** der /// Notch, nicht neben ihr. Neben ihr müssten beide Arme gleich breit sein — /// die Silhouette zeichnet den Steg mittig um die Hardware —, und das hieße /// bei einem Symbol links und einem Balken rechts über hundert Punkte /// Schwarz für das Symbol. public var band: NotchBand { isExpanded ? NotchBandLayout.resolve(panelWidth: expandedSize.width, notchWidth: notchWidth) : .none } /// Die Größe der **eingeklappten** Fläche. /// /// Ohne Activity die Notch allein. Mit einer wächst sie nach unten — und /// mindestens auf Notchbreite, sonst hinge unter dem Steg ein schmalerer /// Zipfel. public var collapsedDisplaySize: CGSize { guard let ambientContentSize else { return collapsedSize } return CGSize(width: max(ambientContentSize.width, collapsedSize.width), height: collapsedSize.height + ambientContentSize.height) } /// Wie breit der oberste Streifen gerade gezeichnet wird. /// /// **Der Steg wächst, es kommen nicht Arme dazu.** Das ist derselbe Wert, /// den die Silhouette animiert: zusammengezogen die Notch, ausgefahren die /// Notch samt Armen. So wächst das Band aus der Hardware heraus, statt /// daneben aufzublenden — und die Animation bleibt die eine, die es schon /// gab. public var topStripWidth: CGFloat { band.totalWidth(notchWidth: notchWidth) } /// 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. Seit /// die Kurve am Bewegungsprofil hängt, hängt die Dauer mit — eine feste Zahl /// wäre bei „calm" um ein Drittel zu kurz gewesen. public static var collapseDuration: TimeInterval { OnyxTheme.shared.motion.collapseDuration } } /// 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, ab Unterkante der Menüleiste die volle Fläche. /// Die Notch bleibt verschmolzen, die Menüleiste daneben bleibt frei. /// /// Der Steg ist **nicht** zwingend so breit wie die Notch: er darf bis zur /// Panelbreite wachsen und trägt dann links und rechts der Hardware das Band. /// Welche Breite gilt, entscheidet `NotchPresentation.topStripWidth`. struct NotchShape: Shape, InsettableShape { var cornerRadius: CGFloat var topBandHeight: CGFloat /// Die Breite des oberen Stegs — Notch, oder Notch samt Band. var topWidth: CGFloat /// Frei schwebend: dann ist auch die Oberkante gerundet. /// /// Kein eigener Radius, sondern derselbe wie unten — sonst hätte dieselbe /// Form zwei Rundungen, und das liest sich als Fehler. Weil er von /// `cornerRadius` abgeleitet ist, wächst er beim Ausfahren mit, ohne /// eigens animiert werden zu müssen. var isDetached: Bool = false var inset: CGFloat = 0 private var topRadius: CGFloat { isDetached ? cornerRadius : 0 } var animatableData: AnimatablePair { get { .init(cornerRadius, topWidth) } set { cornerRadius = newValue.first; topWidth = 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) let band = min(max(topWidth - 2 * inset, 0), rect.width) let radius = min(cornerRadius, rect.width / 2, rect.height / 2) let bandBottom = rect.minY + max(topBandHeight, 0) // **Ein durchgehender Umriss, nicht zwei überlappende Rechtecke.** // // Gefüllt sahen zwei Teilpfade richtig aus — die Vereinigung stimmt. // Die Kontur aber zeichnet jeden Teilpfad vollständig nach, auch dort, // wo er *innen* liegt: quer durch die Fläche lief die Oberkante des // Körpers und die eckige Unterkante des Stegs. Dazu sprang die Form ab // einer Breite in einen anderen Zweig. Zusammen war das das Flackern. var path = Path() // Die Schulter: der Übergang vom Steg in den Körper, nach innen // gewölbt. Sie ist es, die aus einem T eine Insel macht — ohne sie // sitzt der Steg wie aufgeklebt auf einer Platte. let shoulder = min(radius, max((rect.width - band) / 2, 0), max(topBandHeight, 0)) guard topBandHeight > 0, band < rect.width - 0.5 else { // Nur der Steg. Verschmolzen ist er oben eckig — dort ist die // Bildschirmkante, und eine Rundung wäre ein Spalt zum Gehäuse. // Schwebend ist er rundum gerundet, sonst sähe er aus wie ein // abgeschnittenes Rechteck. let top = min(topRadius, rect.width / 2, rect.height / 2) path.move(to: CGPoint(x: rect.minX, y: rect.minY + top)) path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY - radius)) path.addQuadCurve(to: CGPoint(x: rect.minX + radius, y: rect.maxY), control: CGPoint(x: rect.minX, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX - radius, y: rect.maxY)) path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.maxY - radius), control: CGPoint(x: rect.maxX, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + top)) path.addQuadCurve(to: CGPoint(x: rect.maxX - top, y: rect.minY), control: CGPoint(x: rect.maxX, y: rect.minY)) path.addLine(to: CGPoint(x: rect.minX + top, y: rect.minY)) path.addQuadCurve(to: CGPoint(x: rect.minX, y: rect.minY + top), control: CGPoint(x: rect.minX, y: rect.minY)) path.closeSubpath() return path } let bandMinX = rect.midX - band / 2 let bandMaxX = rect.midX + band / 2 let top = min(topRadius, band / 2, max(topBandHeight, 0)) path.move(to: CGPoint(x: bandMinX, y: rect.minY + top)) // Linke Stegkante hinunter, dann nach außen in den Körper. path.addLine(to: CGPoint(x: bandMinX, y: bandBottom - shoulder)) path.addQuadCurve(to: CGPoint(x: bandMinX - shoulder, y: bandBottom), control: CGPoint(x: bandMinX, y: bandBottom)) // Oberkante des Körpers nach links, obere linke Ecke. path.addLine(to: CGPoint(x: rect.minX + radius, y: bandBottom)) path.addQuadCurve(to: CGPoint(x: rect.minX, y: bandBottom + radius), control: CGPoint(x: rect.minX, y: bandBottom)) // Linke Flanke, untere Ecken, rechte Flanke. path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY - radius)) path.addQuadCurve(to: CGPoint(x: rect.minX + radius, y: rect.maxY), control: CGPoint(x: rect.minX, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX - radius, y: rect.maxY)) path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.maxY - radius), control: CGPoint(x: rect.maxX, y: rect.maxY)) path.addLine(to: CGPoint(x: rect.maxX, y: bandBottom + radius)) path.addQuadCurve(to: CGPoint(x: rect.maxX - radius, y: bandBottom), control: CGPoint(x: rect.maxX, y: bandBottom)) // Zurück zur rechten Stegkante, wieder über die Schulter. path.addLine(to: CGPoint(x: bandMaxX + shoulder, y: bandBottom)) path.addQuadCurve(to: CGPoint(x: bandMaxX, y: bandBottom - shoulder), control: CGPoint(x: bandMaxX, y: bandBottom)) // Rechte Stegkante hinauf und über die Oberkante zurück. Verschmolzen // ist `top` null, dann sind es schlicht zwei Geraden. path.addLine(to: CGPoint(x: bandMaxX, y: rect.minY + top)) path.addQuadCurve(to: CGPoint(x: bandMaxX - top, y: rect.minY), control: CGPoint(x: bandMaxX, y: rect.minY)) path.addLine(to: CGPoint(x: bandMinX + top, y: rect.minY)) path.addQuadCurve(to: CGPoint(x: bandMinX, y: rect.minY + top), control: CGPoint(x: bandMinX, y: rect.minY)) path.closeSubpath() 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 leading: Leading private let trailing: Trailing private let ambient: Ambient private let content: Content /// - Parameters: /// - leading: Was links neben der Notch steht, wenn ausgefahren. /// - trailing: Was rechts daneben steht. /// - ambient: Die Live Activity, eingeklappt unter der Notch. /// - content: Die Kacheln, unterhalb der Menüleiste. public init(presentation: NotchPresentation, @ViewBuilder leading: () -> Leading, @ViewBuilder trailing: () -> Trailing, @ViewBuilder ambient: () -> Ambient, @ViewBuilder content: () -> Content) { self.presentation = presentation self.leading = leading() self.trailing = trailing() self.ambient = ambient() self.content = content() } private var size: CGSize { presentation.isExpanded ? presentation.expandedSize : presentation.collapsedDisplaySize } private var cornerRadius: CGFloat { // Ausgefahren die Rundung des Panels. Ein fester Radius ließe die // kleine Form aufgeblasen aussehen. if presentation.isExpanded { return Onyx.Radius.panel } // Eingeklappt und verschmolzen: die Rundung der Notch-Unterkante. guard presentation.isDetached else { return Onyx.Radius.notch } // Eingeklappt und schwebend: die **halbe Höhe**, also eine Pille. // // Eine Insel mit dem Radius einer Notch-Unterkante ist keine Insel, // sondern eine abgerundete Kachel — die Rundung muss aus der Form // selbst kommen und nicht aus einer geerbten Zahl. Bei wachsender // Live Activity bleibt es bei der halben *Insel*höhe, sonst würde die // Fläche mit dem Inhalt immer runder. return presentation.collapsedSize.height / 2 } /// Die Kurve für **jede** Größenänderung der Fläche. /// /// Eine, nicht zwei. Zwei gestapelte `.animation(_:value:)` sehen aus wie /// zwei Zuständigkeiten, sind aber keine: der innere Modifikator /// beansprucht den Teilbaum, und der äußere kommt nicht mehr an die Form /// heran. Weil eine Live Activity `isExpanded` unberührt lässt, hatte der /// innere nichts zu animieren — und die Fläche sprang auf, während die /// zweite Kurve daneben wirkungslos stand. private var sizeAnimation: Animation { // Ausfahren und Wachsen sind dieselbe Richtung; nur das Einfahren // ist kürzer und schwingt nicht nach. presentation.isExpanded || presentation.ambientContentSize != nil ? Onyx.Motion.expand : Onyx.Motion.collapse } public var body: some View { let shape = NotchShape(cornerRadius: cornerRadius, topBandHeight: presentation.contentTopInset, topWidth: presentation.topStripWidth, isDetached: presentation.isDetached) 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. if OnyxTheme.shared.material.showsVibrancy { 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. // // Dasselbe Rezept wie in ``OnyxSurface`` — und zwar buchstäblich // dasselbe, nicht eine zweite Fassung davon. OnyxStone(shape) .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) // Die Live Activity, unter der Notch. ambient .frame(width: presentation.collapsedDisplaySize.width, height: presentation.collapsedDisplaySize.height - presentation.contentTopInset) .padding(.top, presentation.contentTopInset) // Beim Einfahren schneidet die schrumpfende Fläche den Inhalt // weg — das ist die Bewegung. Das Ausblenden kommt nur dazu, // damit die letzte Zeile nicht als Strich stehen bleibt. .opacity(presentation.isExpanded || presentation.ambientContentSize == nil ? 0 : 1) .allowsHitTesting(false) .clipped() // Das Band: links und rechts der Notch, auf Menüleistenhöhe. // // Die Notchbreite in der Mitte bleibt leer und **muss** es: dort // sitzt undurchsichtige Hardware, alles darunter ist schlicht nicht // zu sehen. HStack(spacing: 0) { leading .frame(width: presentation.band.leading, alignment: .leading) Color.clear .frame(width: presentation.notchWidth) trailing .frame(width: presentation.band.trailing, alignment: .trailing) } .frame(height: presentation.contentTopInset) .clipped() // Eingeklappt ist das Band die Live Activity und muss stehen; // ausgefahren blendet es mit dem übrigen Inhalt ein. .opacity(presentation.band.isEmpty ? 0 : 1) .allowsHitTesting(false) // Dieselbe Kurve wie beim Kachelinhalt: die Fläche läuft vorweg, // der Inhalt kommt nach. Ein Band, das mit der Fläche mitwächst, // verrät die Animation als Skalierung. .animation(presentation.isExpanded ? .easeOut(duration: 0.20).delay(0.08) : .easeIn(duration: 0.08), value: presentation.isExpanded) 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) // Der Anlass ist die gezeichnete Größe selbst — nicht der Umweg über // `isExpanded`, der die Live Activity nicht mitbekommt. .animation(sizeAnimation, value: size) } } /// 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.collapsedDisplaySize // 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 } // Außerhalb des Stegs 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. // // **Die Prüfung folgt dem, was gezeichnet wird**, nicht der Notchbreite: // sonst wäre das Band zwar zu sehen, aber tot für Klicks — und ein // Knopf, der aussieht wie einer und keiner ist, ist schlimmer als // keiner. if presentation.isExpanded, point.y < presentation.contentTopInset { let strip = presentation.topStripWidth let band = CGRect(x: (bounds.width - strip) / 2, y: 0, width: strip, height: presentation.contentTopInset) guard band.contains(point) else { return nil } } return super.hitTest(point) } } public extension NotchContainer where Leading == EmptyView, Trailing == EmptyView, Ambient == EmptyView { /// Ohne Band und ohne Live Activity — die Fassung, die es vorher gab. init(presentation: NotchPresentation, @ViewBuilder content: () -> Content) { self.init(presentation: presentation, leading: { EmptyView() }, trailing: { EmptyView() }, ambient: { EmptyView() }, content: content) } }