Die Silhouette bestand aus zwei überlappenden Teilpfaden. Gefüllt stimmte die Vereinigung, aber die Kontur zeichnete jeden Teilpfad vollständig nach — auch dort, wo er innen lag. Dazu kippte die Form ab einer Breite in einen anderen Zweig, und die beiden Zweige waren nicht dieselbe Geometrie. Zusammen war das das Flackern an den Rundungen. Jetzt ein durchgehender Umriss, dessen Zweige ineinander übergehen statt zu springen. Neu ist die Wahl zwischen verschmolzen und schwebend. Eine echte Notch **muss** an der Kante kleben und oben eckig sein, sonst klafft ein Spalt zum Gehäuse; eine Insel **muss** Abstand halten und rundum gerundet sein, sonst sieht sie aus wie ein abgeschnittenes Rechteck. Beides zu mischen geht nicht, deshalb eine Wahl und keine zwei Schalter. Ihre Höhe rät Onyx nicht mehr, sondern misst die Menüleiste: 24 Punkte minus Luft oben und unten. Vorher hing ein festes Maß unten heraus. Dazu „nur der Hauptbildschirm" als dritte Anzeigewahl — gemeint ist der mit der Menüleiste, nicht `NSScreen.main`, das mit dem aktiven Fenster mitwandert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
453 lines
22 KiB
Swift
453 lines
22 KiB
Swift
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<CGFloat, CGFloat> {
|
|
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<Leading: View, Trailing: View,
|
|
Ambient: View, Content: View>: 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<Content: View>: NSHostingView<Content> {
|
|
|
|
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)
|
|
}
|
|
}
|