Volle Höhe, eine Kante statt eines Streifens
Der Aufmacher nimmt jetzt achtundachtzig Prozent der sichtbaren Höhe, statt sich seine Höhe aus 16:9 auszurechnen. Ein Aufmacher, der ein Drittel hoch ist, sieht aus wie ein breites Vorschaubild; die zwölf Prozent Rest sind Absicht, dort lugt die erste Reihe herein und sagt, dass es weitergeht. Auf schmalen Geräten bleibt es beim Hochformat. `FlixyArtwork` kann dafür den Rahmen ausfüllen, den es bekommt. Das Seitenverhältnis bleibt trotzdem wichtig — danach werden die Bilder beim Server angefordert. Und `glassEffect` ist raus. Er zeichnet an seiner Kante einen Lichtsaum, und über einem hellen Aufmacher wurde daraus ein heller Balken von mehreren Punkten Breite neben der Leiste — kein Rand, sondern ein Streifen. Von einer Kante war gleichzeitig nichts zu erkennen. Von Hand sind es drei Lagen: ein trübes Material, ein Schleier gegen die durchziehende Startseite, und ein Haarstrich, der oben links aufhellt und unten rechts nachlässt. Dazu ein Schatten — was schwebt, wirft einen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d0f732402c
commit
873b8a4405
@@ -85,6 +85,16 @@ struct HeroCarousel: View {
|
|||||||
.offset(x: (1 - cover) * (isMovingForward ? Self.slide : -Self.slide))
|
.offset(x: (1 - cover) * (isMovingForward ? Self.slide : -Self.slide))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Die Höhe: fast das ganze Fenster.
|
||||||
|
//
|
||||||
|
// Ein Aufmacher, der nur ein Drittel hoch ist, sieht aus wie ein
|
||||||
|
// breites Vorschaubild. Die zwölf Prozent, die übrig bleiben,
|
||||||
|
// sind Absicht — dort lugt die erste Reihe herein und sagt, dass
|
||||||
|
// es weitergeht.
|
||||||
|
//
|
||||||
|
// Auf einem schmalen Gerät nicht: dort ist der Aufmacher hochkant
|
||||||
|
// und rechnet sich seine Höhe aus der Breite aus.
|
||||||
|
.modifier(HeroHeight(fraction: sizeClass == .compact ? nil : 0.88))
|
||||||
// **Kein** Seitenverhältnis hier: der Aufmacher bringt sein
|
// **Kein** Seitenverhältnis hier: der Aufmacher bringt sein
|
||||||
// eigenes mit, und eines darüber legte ihn auf die Breite des
|
// eigenes mit, und eines darüber legte ihn auf die Breite des
|
||||||
// Inhaltsbereichs fest. Weil er nach links unter die Seitenleiste
|
// Inhaltsbereichs fest. Weil er nach links unter die Seitenleiste
|
||||||
@@ -184,3 +194,21 @@ struct HeroCarousel: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gibt dem Aufmacher einen Anteil an der sichtbaren Höhe — oder gar nichts.
|
||||||
|
///
|
||||||
|
/// Eigener Baustein, weil `containerRelativeFrame` sich nicht abschalten lässt:
|
||||||
|
/// es wirkt, sobald es dasteht. Ein `if` in der Ansicht brächte den beiden
|
||||||
|
/// Zweigen außerdem verschiedene Identitäten ein, und der Aufmacher würde beim
|
||||||
|
/// Drehen neu geladen statt mitzuwandern.
|
||||||
|
private struct HeroHeight: ViewModifier {
|
||||||
|
let fraction: CGFloat?
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if let fraction {
|
||||||
|
content.containerRelativeFrame(.vertical) { height, _ in height * fraction }
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ struct HeroSection: View {
|
|||||||
logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) },
|
logoURL: session.backend.flatMap { HeroArtwork.logoURL(for: item, backend: $0) },
|
||||||
// Auf dem iPhone hochkant: 16:9 ließ dem Text so wenig Platz, dass
|
// Auf dem iPhone hochkant: 16:9 ließ dem Text so wenig Platz, dass
|
||||||
// Schriftzug und Beschreibung übereinanderlagen.
|
// Schriftzug und Beschreibung übereinanderlagen.
|
||||||
aspectRatio: HeroArtwork.aspectRatio(compact: sizeClass == .compact)
|
aspectRatio: HeroArtwork.aspectRatio(compact: sizeClass == .compact),
|
||||||
|
// Auf einem großen Bildschirm nimmt der Aufmacher die Höhe, die
|
||||||
|
// das Karussell ihm gibt — sonst wäre er so hoch, wie 16:9 es
|
||||||
|
// vorschreibt, und darüber und darunter bliebe der halbe
|
||||||
|
// Bildschirm leer.
|
||||||
|
fillsAvailableSpace: sizeClass != .compact
|
||||||
) {
|
) {
|
||||||
Button {
|
Button {
|
||||||
playback.play(item)
|
playback.play(item)
|
||||||
|
|||||||
@@ -54,6 +54,40 @@ struct FlixySidebar: View {
|
|||||||
/// kommt eine Mediathek dazu, soll nicht plötzlich die falsche zuklappen.
|
/// kommt eine Mediathek dazu, soll nicht plötzlich die falsche zuklappen.
|
||||||
@State private var collapsed: Set<String> = []
|
@State private var collapsed: Set<String> = []
|
||||||
|
|
||||||
|
/// Die Fläche, auf der die Leiste liegt.
|
||||||
|
///
|
||||||
|
/// Von Hand gebaut und nicht mit `glassEffect`. Der zeichnet an seiner
|
||||||
|
/// Kante einen Lichtsaum, und über einem hellen Aufmacher wurde daraus ein
|
||||||
|
/// heller Balken von mehreren Punkten Breite, der neben der Leiste stand —
|
||||||
|
/// kein Rand, sondern ein Streifen. Gleichzeitig war von einer Kante nichts
|
||||||
|
/// zu erkennen.
|
||||||
|
///
|
||||||
|
/// Drei Lagen: ein trübes Material, damit die Leiste eine eigene Fläche
|
||||||
|
/// ist; ein Schleier, damit die durchziehende Startseite nicht mitgelesen
|
||||||
|
/// wird; und ein Haarstrich, der oben links aufhellt und unten rechts
|
||||||
|
/// nachlässt — so fängt Glas das Licht. Dazu ein Schatten, denn was
|
||||||
|
/// schwebt, wirft einen.
|
||||||
|
@ViewBuilder
|
||||||
|
static func surface(in shape: some InsettableShape) -> some View {
|
||||||
|
shape
|
||||||
|
.fill(.ultraThinMaterial)
|
||||||
|
.overlay { shape.fill(Color.flixyCanvas.opacity(0.55)) }
|
||||||
|
.overlay {
|
||||||
|
shape.strokeBorder(
|
||||||
|
LinearGradient(
|
||||||
|
stops: [
|
||||||
|
.init(color: .white.opacity(0.55), location: 0),
|
||||||
|
.init(color: .white.opacity(0.10), location: 0.5),
|
||||||
|
.init(color: .white.opacity(0.30), location: 1),
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
),
|
||||||
|
lineWidth: 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static let width: CGFloat = 235
|
static let width: CGFloat = 235
|
||||||
/// Der Abstand zum Fensterrand. Er ist der eigentliche Punkt der Sache.
|
/// Der Abstand zum Fensterrand. Er ist der eigentliche Punkt der Sache.
|
||||||
static let margin: CGFloat = 12
|
static let margin: CGFloat = 12
|
||||||
@@ -81,21 +115,9 @@ struct FlixySidebar: View {
|
|||||||
.padding(.vertical, FlixySpacing.snug)
|
.padding(.vertical, FlixySpacing.snug)
|
||||||
}
|
}
|
||||||
.frame(width: Self.width)
|
.frame(width: Self.width)
|
||||||
// Ein Schleier unter dem Glas.
|
.background { Self.surface(in: .rect(cornerRadius: FlixyRadius.panel, style: .continuous)) }
|
||||||
//
|
.clipShape(.rect(cornerRadius: FlixyRadius.panel, style: .continuous))
|
||||||
// Reines Glas zeigt alles, was dahinter vorbeizieht — und beim Scrollen
|
.shadow(color: .black.opacity(0.45), radius: 22, y: 8)
|
||||||
// zieht die ganze Startseite vorbei. Der Aufmacher, die Plakatreihen,
|
|
||||||
// die Überschriften: alles wanderte durch die Leiste hindurch, und
|
|
||||||
// neben den ruhigen Beschriftungen wurde daraus ein unruhiger Brei.
|
|
||||||
//
|
|
||||||
// Der Schleier nimmt der Bewegung die Schärfe, ohne die Fläche
|
|
||||||
// zuzumauern: man sieht noch, dass dort etwas ist, aber man liest es
|
|
||||||
// nicht mehr mit.
|
|
||||||
.background(
|
|
||||||
Color.flixyCanvas.opacity(0.72),
|
|
||||||
in: .rect(cornerRadius: FlixyRadius.panel, style: .continuous)
|
|
||||||
)
|
|
||||||
.glassEffect(.regular, in: .rect(cornerRadius: FlixyRadius.panel))
|
|
||||||
.padding(Self.margin)
|
.padding(Self.margin)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,8 +245,9 @@ struct FlixyTopBar: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, FlixySpacing.snug)
|
.padding(.horizontal, FlixySpacing.snug)
|
||||||
.padding(.vertical, FlixySpacing.tight)
|
.padding(.vertical, FlixySpacing.tight)
|
||||||
.background(Color.flixyCanvas.opacity(0.72), in: .capsule)
|
.background { FlixySidebar.surface(in: .capsule) }
|
||||||
.glassEffect(.regular, in: .capsule)
|
.clipShape(.capsule)
|
||||||
|
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
|
||||||
.padding(.top, FlixySpacing.snug)
|
.padding(.top, FlixySpacing.snug)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ import SwiftUI
|
|||||||
public struct FlixyArtwork: View {
|
public struct FlixyArtwork: View {
|
||||||
private let url: URL?
|
private let url: URL?
|
||||||
private let aspectRatio: CGFloat
|
private let aspectRatio: CGFloat
|
||||||
|
/// Ob das Bild den Rahmen einnimmt, den es bekommt, statt sich seine Höhe
|
||||||
|
/// aus dem Seitenverhältnis auszurechnen.
|
||||||
|
///
|
||||||
|
/// Für den Aufmacher: der soll so hoch sein wie das Fenster und nicht so
|
||||||
|
/// hoch, wie 16:9 es vorgibt. Das Seitenverhältnis bleibt trotzdem
|
||||||
|
/// wichtig — danach werden die Bilder beim Server angefordert.
|
||||||
|
private let fillsFrame: Bool
|
||||||
private let fallbackSymbol: String
|
private let fallbackSymbol: String
|
||||||
private let contentMode: ContentMode
|
private let contentMode: ContentMode
|
||||||
|
|
||||||
@@ -22,11 +29,13 @@ public struct FlixyArtwork: View {
|
|||||||
public init(
|
public init(
|
||||||
url: URL?,
|
url: URL?,
|
||||||
aspectRatio: CGFloat,
|
aspectRatio: CGFloat,
|
||||||
|
fillsFrame: Bool = false,
|
||||||
fallbackSymbol: String = "film",
|
fallbackSymbol: String = "film",
|
||||||
contentMode: ContentMode = .fill
|
contentMode: ContentMode = .fill
|
||||||
) {
|
) {
|
||||||
self.url = url
|
self.url = url
|
||||||
self.aspectRatio = aspectRatio
|
self.aspectRatio = aspectRatio
|
||||||
|
self.fillsFrame = fillsFrame
|
||||||
self.fallbackSymbol = fallbackSymbol
|
self.fallbackSymbol = fallbackSymbol
|
||||||
self.contentMode = contentMode
|
self.contentMode = contentMode
|
||||||
}
|
}
|
||||||
@@ -35,9 +44,7 @@ public struct FlixyArtwork: View {
|
|||||||
@State private var hasFailed = false
|
@State private var hasFailed = false
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
Rectangle()
|
placeholder
|
||||||
.fill(Color.flixyPlaceholder)
|
|
||||||
.aspectRatio(aspectRatio, contentMode: .fit)
|
|
||||||
.overlay {
|
.overlay {
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
@@ -45,6 +52,15 @@ public struct FlixyArtwork: View {
|
|||||||
.task(id: url) { await load() }
|
.task(id: url) { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var placeholder: some View {
|
||||||
|
if fillsFrame {
|
||||||
|
Rectangle().fill(Color.flixyPlaceholder)
|
||||||
|
} else {
|
||||||
|
Rectangle().fill(Color.flixyPlaceholder).aspectRatio(aspectRatio, contentMode: .fit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Das Bild, das jetzt gezeigt werden kann.
|
/// Das Bild, das jetzt gezeigt werden kann.
|
||||||
///
|
///
|
||||||
/// Der Blick in den Speicher steht **hier**, nicht in `load()`: eine
|
/// Der Blick in den Speicher steht **hier**, nicht in `load()`: eine
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
/// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht.
|
/// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht.
|
||||||
private let logoURL: URL?
|
private let logoURL: URL?
|
||||||
private let aspectRatio: CGFloat
|
private let aspectRatio: CGFloat
|
||||||
|
/// Ob der Aufmacher den Platz einnimmt, den er bekommt.
|
||||||
|
private let fillsAvailableSpace: Bool
|
||||||
private let actions: Actions
|
private let actions: Actions
|
||||||
|
|
||||||
@State private var logo: PlatformImage?
|
@State private var logo: PlatformImage?
|
||||||
@@ -38,6 +40,7 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
// kräftig beschnitten, und vom Aufmacher war nur ein Ausschnitt zu
|
// kräftig beschnitten, und vom Aufmacher war nur ein Ausschnitt zu
|
||||||
// sehen.
|
// sehen.
|
||||||
aspectRatio: CGFloat = FlixyAspectRatio.hero,
|
aspectRatio: CGFloat = FlixyAspectRatio.hero,
|
||||||
|
fillsAvailableSpace: Bool = false,
|
||||||
@ViewBuilder actions: () -> Actions
|
@ViewBuilder actions: () -> Actions
|
||||||
) {
|
) {
|
||||||
self.title = title
|
self.title = title
|
||||||
@@ -46,6 +49,7 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
self.artworkURL = artworkURL
|
self.artworkURL = artworkURL
|
||||||
self.logoURL = logoURL
|
self.logoURL = logoURL
|
||||||
self.aspectRatio = aspectRatio
|
self.aspectRatio = aspectRatio
|
||||||
|
self.fillsAvailableSpace = fillsAvailableSpace
|
||||||
self.actions = actions()
|
self.actions = actions()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +125,7 @@ public struct HeroBanner<Actions: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio)
|
FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio, fillsFrame: fillsAvailableSpace)
|
||||||
// Bis unter die Leiste und an den Fensterrand.
|
// Bis unter die Leiste und an den Fensterrand.
|
||||||
//
|
//
|
||||||
// Hier stand `backgroundExtensionEffect()`. Der spiegelt den
|
// Hier stand `backgroundExtensionEffect()`. Der spiegelt den
|
||||||
|
|||||||
Reference in New Issue
Block a user