Die Notch wird zur Insel, wo es keine Hardware gibt
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ecb39fbe5f
commit
c05ccb8ae1
@@ -28,16 +28,64 @@ public final class NotchPresentation {
|
||||
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.
|
||||
/// 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.
|
||||
public static let collapseDuration: TimeInterval = 0.30
|
||||
/// 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.
|
||||
@@ -48,17 +96,31 @@ public final class NotchPresentation {
|
||||
/// 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.
|
||||
/// 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
|
||||
var notchWidth: 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, notchWidth) }
|
||||
set { cornerRadius = newValue.first; notchWidth = newValue.second }
|
||||
get { .init(cornerRadius, topWidth) }
|
||||
set { cornerRadius = newValue.first; topWidth = newValue.second }
|
||||
}
|
||||
|
||||
func inset(by amount: CGFloat) -> NotchShape {
|
||||
@@ -69,30 +131,83 @@ struct NotchShape: Shape, InsettableShape {
|
||||
|
||||
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 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 {
|
||||
path.addRoundedRect(in: rect,
|
||||
cornerSize: CGSize(width: radius, height: radius),
|
||||
style: .continuous)
|
||||
// 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 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)
|
||||
let bandMinX = rect.midX - band / 2
|
||||
let bandMaxX = rect.midX + band / 2
|
||||
let top = min(topRadius, band / 2, max(topBandHeight, 0))
|
||||
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -104,34 +219,73 @@ struct NotchShape: Shape, InsettableShape {
|
||||
/// 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 {
|
||||
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
|
||||
|
||||
public init(presentation: NotchPresentation, @ViewBuilder 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.collapsedSize
|
||||
presentation.isExpanded ? presentation.expandedSize : presentation.collapsedDisplaySize
|
||||
}
|
||||
|
||||
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
|
||||
// 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
|
||||
}
|
||||
|
||||
private var shapeAnimation: Animation {
|
||||
presentation.isExpanded ? Onyx.Motion.expand : Onyx.Motion.collapse
|
||||
/// 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,
|
||||
notchWidth: presentation.notchWidth)
|
||||
topWidth: presentation.topStripWidth,
|
||||
isDetached: presentation.isDetached)
|
||||
|
||||
ZStack(alignment: .top) {
|
||||
// Das Weichzeichner-Material behält **feste** Größe; animiert wird
|
||||
@@ -141,31 +295,68 @@ public struct NotchContainer<Content: View>: View {
|
||||
// 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)
|
||||
}
|
||||
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.
|
||||
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) }
|
||||
//
|
||||
// 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
|
||||
@@ -191,7 +382,9 @@ public struct NotchContainer<Content: View>: View {
|
||||
.frame(width: presentation.expandedSize.width,
|
||||
height: presentation.expandedSize.height,
|
||||
alignment: .top)
|
||||
.animation(shapeAnimation, value: presentation.isExpanded)
|
||||
// Der Anlass ist die gezeichnete Größe selbst — nicht der Umweg über
|
||||
// `isExpanded`, der die Live Activity nicht mitbekommt.
|
||||
.animation(sizeAnimation, value: size)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +409,8 @@ public final class NotchHostingView<Content: View>: NSHostingView<Content> {
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
public override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
let size = presentation.isExpanded ? presentation.expandedSize : presentation.collapsedSize
|
||||
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,
|
||||
@@ -225,16 +419,34 @@ public final class NotchHostingView<Content: View>: NSHostingView<Content> {
|
||||
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.
|
||||
// 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 band = CGRect(x: (bounds.width - presentation.notchWidth) / 2, y: 0,
|
||||
width: presentation.notchWidth,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user