Files
onyx/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift
Guido Schmit 0297b3ad0d Schließen entruckeln: Material fest, Maske animiert, Zeiger abgefragt
Drei Ursachen, davon zwei echte Fehler.

Die NSVisualEffectView steckte in einem Rahmen, dessen Größe animiert wurde.
Ein Weichzeichner muss dann in jedem Einzelbild neu in anderer Größe gerechnet
werden — am teuersten genau beim Schließen, wo die Fläche am schnellsten
schrumpft. Das Material behält jetzt feste Größe, animiert wird nur die Maske
davor. Tint, Bänderung und Kante sind reine SwiftUI-Formen und dürfen weiter in
der Größe animieren. Auch die Schattendeckkraft ist jetzt konstant statt
animiert; sie aufzublenden kostete pro Bild eine neue Weichzeichnung.

Onyx bekam Mausbewegungen über dem eigenen Panel praktisch nicht mit. Der
globale Monitor feuert nur für Ereignisse an fremde Programme, der lokale nur
wenn Onyx aktiv ist — was es als .accessory-App nie ist. Ausgerechnet beim
Verlassen des Panels kam das Ereignis also verspätet oder gar nicht. Solange
ein Panel offen ist, wird die Zeigerposition jetzt mit 60 Hz abgefragt; der
Timer läuft ausschließlich in dieser Zeit.

Öffnen und Schließen benutzten dieselbe Kurve. Schließen ist jetzt kürzer und
ohne Nachschwingen: beim Öffnen sieht man zu, beim Schließen ist man schon
woanders.

Nebenbefund beim Prüfen: es liefen zwei Onyx-Instanzen aus verschiedenen Builds
gleichzeitig, jede mit eigener Zustandsmaschine auf derselben Notch. Das dürfte
einen guten Teil der Hakeligkeit erklärt haben.
2026-08-10 17:52:42 +02:00

165 lines
7.3 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
}
/// 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<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 = 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<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 }
return super.hitTest(point)
}
}