Eine Designschicht statt hundertfach gesetzter Zahlen
Token für Schrift, Symbolgröße, Abstand, Radius und Bewegung, dazu sieben Bauteile, die vorher in jeder Kachel neu gebaut wurden — Karte, Abschnittstitel, Hinweis, Pillenknopf, Regler, Ablegefläche, Haarlinie. Symbole bekommen eine **eigene** Staffel und nicht die der Schrift: ein 9-Punkt-Symbol neben 9-Punkt-Text wirkt kleiner, nicht gleich groß. Beide in eine Staffel zu zwingen hieße, an jeder Stelle gegen sie zu arbeiten. Das Flächenrezept — Tint, Bänderung, Kante — stand zweimal da, einmal für das Panel und einmal für Fenster. Es ist jetzt `OnyxStone`, generisch über die Form, damit auch die animierende Notch-Silhouette es benutzen kann. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
49a3ff0548
commit
ecb39fbe5f
@@ -0,0 +1,353 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Bauteile, aus denen Onyx besteht.
|
||||
///
|
||||
/// **Warum es sie gibt.** Nachgemessen war jedes dieser Stücke zwei- bis
|
||||
/// fünfmal einzeln nachgebaut — die Karte im Mischpult zweimal wortgleich, der
|
||||
/// Lautstärkeregler zweimal mit denselben achtzig Zeilen, der Leerzustand
|
||||
/// fünfmal, der Pillenknopf dreimal. Solange das so ist, ist eine
|
||||
/// Designänderung keine Änderung an einer Stelle, sondern eine Suche.
|
||||
///
|
||||
/// **Alle Beschriftungen kommen fertig übersetzt herein**, als `String`, nicht
|
||||
/// als `LocalizedStringKey`. Jedes Modul hat seinen eigenen Sprachkatalog, und
|
||||
/// ein Bauteil in `OnyxDesign` kann `bundle: .module` des Aufrufers nicht
|
||||
/// auflösen. Der Aufrufer übersetzt, das Bauteil zeichnet.
|
||||
|
||||
// MARK: - Karte
|
||||
|
||||
/// Eine Karte **innerhalb** einer Kachel.
|
||||
///
|
||||
/// Fasst zusammen, was zusammengehört, und trennt es von dem, was daneben
|
||||
/// steht. Nicht zu verwechseln mit `OnyxTile`: die Kachel ist das Fach im
|
||||
/// Panel, die Karte ist eine Gruppe darin.
|
||||
public struct OnyxCard<Content: View>: View {
|
||||
private let content: Content
|
||||
|
||||
public init(@ViewBuilder content: () -> Content) {
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Onyx.Radius.card, style: .continuous)
|
||||
|
||||
content
|
||||
.background(Onyx.Color.card, in: shape)
|
||||
.overlay {
|
||||
shape.strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Abschnittstitel
|
||||
|
||||
/// Die Überschrift über einer Gruppe.
|
||||
///
|
||||
/// Versal und klein — sie soll die Gruppe benennen, nicht mit ihrem Inhalt um
|
||||
/// Aufmerksamkeit streiten.
|
||||
public struct OnyxSectionTitle: View {
|
||||
private let text: String
|
||||
|
||||
public init(_ text: String) { self.text = text }
|
||||
|
||||
public var body: some View {
|
||||
Text(text)
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trennlinie
|
||||
|
||||
/// Die Haarlinie zwischen zwei Zeilen.
|
||||
///
|
||||
/// `Divider()` allein zeichnet in der Systemfarbe und ist auf schwarzem Stein
|
||||
/// entweder zu hell oder gar nicht da.
|
||||
public struct OnyxHairline: View {
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
Divider().overlay(Onyx.Color.hairline)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Leerzustand
|
||||
|
||||
/// Wenn eine Kachel nichts zu zeigen hat.
|
||||
///
|
||||
/// Ein Leerzustand ist kein Fehler und soll auch nicht so aussehen: Symbol
|
||||
/// gedämpft, Text eine Stufe heller, und wenn es etwas zu tun gibt, genau ein
|
||||
/// Knopf.
|
||||
public struct OnyxNotice<Action: View>: View {
|
||||
|
||||
/// Mittig in der Kachel oder linksbündig unter dem, was darüber steht.
|
||||
public enum Placement {
|
||||
/// Für Kacheln, die sonst leer wären — Wetter, Kalender.
|
||||
case centered
|
||||
/// Für Kacheln, die den Hinweis in eine bestehende Spalte einreihen —
|
||||
/// Lüfter.
|
||||
case leading
|
||||
}
|
||||
|
||||
private let symbol: String
|
||||
private let text: String
|
||||
private let placement: Placement
|
||||
private let action: Action
|
||||
|
||||
public init(symbol: String,
|
||||
text: String,
|
||||
placement: Placement = .centered,
|
||||
@ViewBuilder action: () -> Action) {
|
||||
self.symbol = symbol
|
||||
self.text = text
|
||||
self.placement = placement
|
||||
self.action = action()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: placement == .centered ? .center : .leading, spacing: Onyx.Space.sm) {
|
||||
Image(systemName: symbol)
|
||||
.font(Onyx.Font.headline)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
Text(text)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
.multilineTextAlignment(placement == .centered ? .center : .leading)
|
||||
// Ohne das reißt ein zweizeiliger Hinweis in der schmalen
|
||||
// Kachel mitten im Wort ab, statt umzubrechen.
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
action
|
||||
}
|
||||
.frame(maxWidth: .infinity,
|
||||
maxHeight: placement == .centered ? .infinity : nil,
|
||||
alignment: placement == .centered ? .center : .leading)
|
||||
}
|
||||
}
|
||||
|
||||
public extension OnyxNotice where Action == EmptyView {
|
||||
init(symbol: String, text: String, placement: Placement = .centered) {
|
||||
self.init(symbol: symbol, text: text, placement: placement) { EmptyView() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pillenknopf
|
||||
|
||||
/// Der kleine Knopf in einer Kachel.
|
||||
///
|
||||
/// Zwei Zustände, und der Unterschied ist Absicht: gefüllt heißt „das gilt
|
||||
/// gerade", umrandet heißt „das kannst du wählen". Ein blauer Text sähe aus wie
|
||||
/// ein Verweis und nicht wie ein Zustand.
|
||||
public struct OnyxPillButton: View {
|
||||
|
||||
/// Zwei Dichten, weil zwei Orte gemeint sind: der Knopf unter einem
|
||||
/// Hinweis hat Platz, der Knopf in einer Lüfterzeile hat ihn nicht.
|
||||
public enum Size {
|
||||
case regular
|
||||
case compact
|
||||
|
||||
var font: Font { self == .regular ? Onyx.Font.caption : Onyx.Font.metricSmall }
|
||||
var horizontal: CGFloat { self == .regular ? Onyx.Space.md : Onyx.Space.sm }
|
||||
var vertical: CGFloat { self == .regular ? Onyx.Space.xs : Onyx.Space.xxs }
|
||||
}
|
||||
|
||||
private let title: String
|
||||
private let isFilled: Bool
|
||||
private let size: Size
|
||||
private let action: () -> Void
|
||||
|
||||
public init(_ title: String,
|
||||
isFilled: Bool = true,
|
||||
size: Size = .regular,
|
||||
action: @escaping () -> Void) {
|
||||
self.title = title
|
||||
self.isFilled = isFilled
|
||||
self.size = size
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(size.font)
|
||||
// Umrandet heißt bedienbar, nicht abgeschaltet — in der
|
||||
// Lüfterkachel ist gerade die **gefüllte** Pille die
|
||||
// ausgegraute. `textTertiary` sah hier aus wie „geht nicht";
|
||||
// das Ausgrauen macht ohnehin `.disabled()`.
|
||||
.foregroundStyle(isFilled ? Onyx.Color.onAccent : Onyx.Color.textSecondary)
|
||||
.padding(.horizontal, size.horizontal)
|
||||
.padding(.vertical, size.vertical)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(isFilled ? Onyx.Color.accent : Color.clear)
|
||||
.overlay {
|
||||
Capsule().strokeBorder(isFilled ? .clear : Onyx.Color.hairline,
|
||||
lineWidth: Onyx.Metric.hairlineWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Regler
|
||||
|
||||
/// Der Regler mit mitlaufendem Pegel.
|
||||
///
|
||||
/// **Einer, nicht zwei.** Es gab ihn zweimal — für die Programme im Mischpult
|
||||
/// und für die Systemlautstärke — mit denselben Knopfgrößen, derselben Geste
|
||||
/// und derselben Pegelrechnung. Der Systemregler war exakt dieser hier mit
|
||||
/// `maximum: 1`; der 100-%-Strich unterdrückt sich dort von selbst.
|
||||
///
|
||||
/// Der Pegel läuft **im** Regler mit, nicht daneben: eine zweite Leiste je
|
||||
/// Zeile wäre doppelt so viel Grafik für dieselbe Auskunft.
|
||||
public struct OnyxSlider: View {
|
||||
|
||||
private let value: Double
|
||||
private let maximum: Double
|
||||
private let level: Double
|
||||
private let isActive: Bool
|
||||
private let onChange: (Double) -> Void
|
||||
|
||||
@State private var isDragging = false
|
||||
|
||||
/// - Parameters:
|
||||
/// - maximum: Der rechte Anschlag. 1 im Normalfall, mehr bei Overdrive.
|
||||
/// - level: Der Ausschlag, der im Regler mitläuft. 0 blendet ihn aus.
|
||||
/// - isActive: Ausgegraut, wenn das Programm stumm ist oder nicht regelbar.
|
||||
public init(value: Double,
|
||||
maximum: Double = 1,
|
||||
level: Double = 0,
|
||||
isActive: Bool = true,
|
||||
onChange: @escaping (Double) -> Void) {
|
||||
self.value = value
|
||||
self.maximum = maximum
|
||||
self.level = level
|
||||
self.isActive = isActive
|
||||
self.onChange = onChange
|
||||
}
|
||||
|
||||
private static let trackHeight: CGFloat = 6
|
||||
private static let barHeight: CGFloat = 16
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let knob: CGFloat = isDragging ? 13 : 11
|
||||
// Der Knopf soll an den Enden nicht überstehen.
|
||||
let travel = max(geometry.size.width - knob, 1)
|
||||
let fraction = min(max(value / maximum, 0), 1)
|
||||
let fillWidth = travel * fraction + knob / 2
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Onyx.Color.surface)
|
||||
.frame(height: Self.trackHeight)
|
||||
|
||||
// Markierung bei 100 %: darüber wird verstärkt, und man soll
|
||||
// sehen, wann man diese Grenze überschreitet. Ohne Overdrive
|
||||
// liegt sie am rechten Anschlag und sagt dort nichts mehr.
|
||||
if maximum > 1 {
|
||||
Rectangle()
|
||||
.fill(Onyx.Color.hairline)
|
||||
.frame(width: 1, height: Self.trackHeight + 4)
|
||||
.offset(x: knob / 2 + travel / maximum)
|
||||
}
|
||||
|
||||
Capsule()
|
||||
.fill(value > 1 ? Onyx.Color.warning : Onyx.Color.accent)
|
||||
.frame(width: fillWidth, height: Self.trackHeight)
|
||||
.opacity(isActive ? 1 : 0.3)
|
||||
|
||||
if isActive, level > 0.001 {
|
||||
Capsule()
|
||||
.fill(.white.opacity(0.4))
|
||||
.frame(width: fillWidth * Self.meterFraction(level),
|
||||
height: Self.trackHeight)
|
||||
.blendMode(.plusLighter)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
Circle()
|
||||
.fill(.white)
|
||||
.frame(width: knob, height: knob)
|
||||
.shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5)
|
||||
.offset(x: travel * fraction)
|
||||
.opacity(isActive ? 1 : 0.55)
|
||||
}
|
||||
.frame(height: Self.barHeight)
|
||||
.contentShape(.rect)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { drag in
|
||||
isDragging = true
|
||||
let position = min(max((drag.location.x - knob / 2) / travel, 0), 1)
|
||||
onChange(position * maximum)
|
||||
}
|
||||
.onEnded { _ in isDragging = false })
|
||||
}
|
||||
.frame(height: Self.barHeight)
|
||||
.animation(Onyx.Motion.value, value: isDragging)
|
||||
}
|
||||
|
||||
/// Wie weit der Pegel im Regler ausschlägt.
|
||||
///
|
||||
/// Ausschläge sind linear in der Amplitude und damit optisch nutzlos: halb
|
||||
/// so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
|
||||
/// Bereich so weit, dass man überhaupt etwas sieht.
|
||||
public static func meterFraction(_ level: Double) -> Double {
|
||||
min(1, sqrt(min(max(level, 0), 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ablegefläche
|
||||
|
||||
/// Wohin man Dateien ziehen kann.
|
||||
///
|
||||
/// Zwei Fassungen, weil zwei Dinge gemeint sind: eine Fläche, die **nur zum
|
||||
/// Ablegen da ist**, zeigt ihre gestrichelte Kante immer — sonst weiß niemand,
|
||||
/// dass sie eine ist. Eine Kachel, die **nebenbei auch** Dateien annimmt, zeigt
|
||||
/// sie erst, wenn wirklich etwas darüber schwebt.
|
||||
public enum OnyxDropZoneStyle {
|
||||
/// Immer sichtbar, beim Anvisieren in der Akzentfarbe.
|
||||
case always
|
||||
/// Nur beim Anvisieren, dann mit leichter Füllung.
|
||||
case whenTargeted
|
||||
}
|
||||
|
||||
private struct OnyxDropZone: ViewModifier {
|
||||
let isTargeted: Bool
|
||||
let style: OnyxDropZoneStyle
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Onyx.Radius.card, style: .continuous)
|
||||
|
||||
return content
|
||||
.overlay {
|
||||
switch style {
|
||||
case .always:
|
||||
shape.strokeBorder(
|
||||
isTargeted ? Onyx.Color.accent : Onyx.Color.hairline,
|
||||
style: StrokeStyle(lineWidth: Onyx.Metric.hairlineWidth, dash: [4, 3]))
|
||||
.allowsHitTesting(false)
|
||||
case .whenTargeted:
|
||||
if isTargeted {
|
||||
shape.strokeBorder(Onyx.Color.accent,
|
||||
style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
|
||||
.background(Onyx.Color.accent.opacity(0.08), in: shape)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(Onyx.Motion.value, value: isTargeted)
|
||||
}
|
||||
}
|
||||
|
||||
public extension View {
|
||||
/// Zeichnet die gestrichelte Kante einer Ablegefläche.
|
||||
///
|
||||
/// Nimmt selbst keine Dateien an — das macht `onFileDrop`. Getrennt, weil
|
||||
/// die Kante auch dort gebraucht wird, wo das Ablegen die ganze Kachel
|
||||
/// betrifft und nicht nur den umrandeten Teil.
|
||||
func onyxDropZone(isTargeted: Bool, style: OnyxDropZoneStyle) -> some View {
|
||||
modifier(OnyxDropZone(isTargeted: isTargeted, style: style))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user