Die Fläche reichte über die ganze Breite bis an die Bildschirmoberkante, damit sie mit der schwarzen Notch zu einem Körper verschmilzt. Bei einer Kachel ging das auf; bei sechs deckt sie die komplette Menüleiste zu, samt aller Statuselemente. Die Silhouette ist jetzt 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. Das allein hätte nur so ausgesehen, als wäre es gelöst. Das Fenster liegt weiterhin über der Leiste, dort nur durchsichtig — und ein durchsichtiges Fenster schluckt Klicks trotzdem. Die Trefferprüfung reicht sie jetzt durch: links und rechts der Notch gehört der oberste Streifen der Menüleiste. Aus demselben Grund fragt der Koordinator nicht mehr den Fensterrahmen, sondern die sichtbare Fläche. Wer den Rahmen befragt, hält einen Klick auf ein fremdes Statuselement für einen Klick ins Panel — und einen Zeiger über der Menüleiste für einen im Panel. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
11 KiB
Swift
241 lines
11 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 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<CGFloat, CGFloat> {
|
|
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<Content: View>: 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<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.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)
|
|
}
|
|
}
|