Files
Flaro/Packages/FlixyKit/Sources/FlixyDesign/Components/HeroBanner.swift
T
ScarriffleandClaude Opus 5 f8ada44a8c Mac: näher am iPad — Schrift, Seitenleiste, Formulare
Drei Gründe, warum der Mac anders aussah, und keiner davon war Geschmack.

**Die Schrift.** macOS rendert dieselben Textstile deutlich kleiner als iOS:
`largeTitle` misst dort 26 Punkt statt 34, Fließtext 13 statt 17. Eine
Oberfläche, die auf dem iPad stimmt, wirkt damit zusammengeschrumpft. Auf dem
Mac stehen jetzt feste Größen — die von iOS. Das kostet dort Dynamic Type;
vertretbar, weil macOS keine systemweite Textgröße kennt und man ein Fenster
stattdessen größer ziehen kann.

**Der Aufmacher wächst mit.** Das war der zweite, größere Teil: das Bild ist so
breit wie das Fenster, die Schrift darauf war es nicht. Bei 1280 Punkt Breite
wirkt ein 34-Punkt-Titel verloren, bei 834 auf dem iPad genau richtig. Titel,
Schriftzug, Angaben und Abstände richten sich jetzt nach der Breite des
Aufmachers — mit dem iPad als Bezug und einem Deckel, damit ein Fenster über
die ganze Wand nicht in Plakatschrift endet. Die Beschreibung bekommt
außerdem eine Zeilenbreite: ein Satz über 1280 Punkt ist unlesbar, das Auge
verliert die Zeile.

**Die Seitenleiste.** Sie richtet sich nach ihrem Inhalt, und bei Wörtern wie
„Start" fällt sie auf eine Breite zusammen, in der schon der Kontoname darunter
nicht mehr Platz hatte — daher das „S…". Die Beschriftungen haben jetzt eine
Mindestbreite.

**Die Formulare.** Auf dem Mac nahm SwiftUI den Spalten-Stil: Beschriftungen
rechts ausgerichtet, keine abgesetzten Abschnitte. Jetzt derselbe gruppierte
Stil wie auf iOS.

Dazu: Scrollbalken sind weg. Der graue Streifen unter dem Aufmacher kam daher,
dass in den Systemeinstellungen „Scrollbalken immer einblenden" steht —
dagegen reicht `.hidden` nicht, `.never` schon. Und das Fenster öffnet mit
1280 × 820 statt in Notizzettelgröße, ohne graue Titelleiste.

263 Tests grün, iOS baut unverändert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:59:22 +02:00

161 lines
6.8 KiB
Swift

import SwiftUI
/// Das Aufmacher-Bild oben auf der Startseite.
///
/// Die Aktionsknöpfe kommen von außen, weil ein Film „Abspielen" und
/// „Herunterladen" braucht, eine angefangene Folge dagegen „Weiterschauen".
/// Der Baustein legt nur Bild, Verlauf und Textsatz fest.
public struct HeroBanner<Actions: View>: View {
private let title: String
private let metadata: String?
private let summary: String?
private let artworkURL: URL?
/// Der freigestellte Schriftzug des Films. Steht er zur Verfügung, tritt
/// er an die Stelle des gesetzten Titels — „Harry Potter" in seiner
/// eigenen Schrift ist wiedererkennbar, eine Systemschrift nicht.
private let logoURL: URL?
private let aspectRatio: CGFloat
private let actions: Actions
public init(
title: String,
metadata: String? = nil,
summary: String? = nil,
artworkURL: URL?,
logoURL: URL? = nil,
// 16:9, weil Plex genau das als `art` liefert. Vorher stand hier
// 3:4 — mit `scaledToFill` wurde das Bild dadurch links und rechts
// kräftig beschnitten, und vom Aufmacher war nur ein Ausschnitt zu
// sehen.
aspectRatio: CGFloat = FlixyAspectRatio.hero,
@ViewBuilder actions: () -> Actions
) {
self.title = title
self.metadata = metadata
self.summary = summary
self.artworkURL = artworkURL
self.logoURL = logoURL
self.aspectRatio = aspectRatio
self.actions = actions()
}
/// Wie stark Schrift und Schriftzug mitwachsen.
///
/// Der Aufmacher ist so breit wie das Fenster. Bei fester Schriftgröße
/// wirkt derselbe Titel auf einem iPad groß und in einem breiten
/// Mac-Fenster verloren — das Bild wächst, die Schrift nicht. Bezugsgröße
/// ist die Breite eines iPads im Hochformat; alles darüber wächst mit,
/// aber gedeckelt, damit ein Fenster über die ganze Wand nicht in
/// Plakatschrift endet.
private func scale(for width: CGFloat) -> CGFloat {
min(max(width / 834, 1), 1.7)
}
/// Schriftzug, wenn vorhanden — sonst gesetzter Titel.
///
/// Der Schriftzug wird ohne Bild-Transcoder geladen; er ist ein
/// freigestelltes PNG, und der Transcoder liefert JPEG. Aus dem
/// freigestellten Logo würde sonst ein schwarzer Kasten.
@ViewBuilder
private func titleView(scale: CGFloat) -> some View {
if let logoURL {
AsyncImage(url: logoURL) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFit()
// Nach oben begrenzt: manche Schriftzüge sind fast
// quadratisch, andere sehr breit. Ohne Deckel schöbe
// ein hohes Logo die Knöpfe aus dem Bild.
.frame(maxWidth: 260 * scale, maxHeight: 76 * scale, alignment: .leading)
default:
// Solange geladen wird — und wenn es scheitert — bleibt es
// beim gesetzten Titel. Ein leerer Platz wäre schlimmer
// als eine Systemschrift.
plainTitle(scale: scale)
}
}
.accessibilityLabel(Text(title))
} else {
plainTitle(scale: scale)
}
}
private func plainTitle(scale: CGFloat) -> some View {
Text(title)
.font(.system(size: 34 * scale, weight: .bold))
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(2)
}
public var body: some View {
FlixyArtwork(url: artworkURL, aspectRatio: aspectRatio)
// Das Bild läuft über seinen Bereich hinaus — unter die
// Seitenleiste und bis an den Fensterrand. Der Effekt spiegelt die
// Ränder und zeichnet sie weich weiter, statt das Bild zu
// verzerren; der Bildinhalt selbst bleibt, wo er ist, und mit ihm
// Titel, Beschreibung und Knöpfe.
//
// Ohne das endete der Aufmacher an der Kante der Seitenleiste, und
// beim Weiterwandern schob sich das nächste Bild an einer harten
// Linie ins Bild.
.backgroundExtensionEffect()
.overlay(alignment: .bottom) {
// Der Verlauf endet in der Hintergrundfarbe der Seite, nicht in
// Schwarz: sonst entsteht genau dort, wo der Aufmacher in die
// erste Reihe übergeht, eine sichtbare Kante.
LinearGradient(
stops: [
.init(color: .clear, location: 0),
.init(color: Color.flixyCanvas.opacity(0.75), location: 0.45),
.init(color: Color.flixyCanvas, location: 1),
],
startPoint: .top,
endPoint: .bottom
)
.frame(maxHeight: .infinity, alignment: .bottom)
.allowsHitTesting(false)
}
.overlay(alignment: .bottomLeading) {
GeometryReader { geometry in
let scale = scale(for: geometry.size.width)
VStack(alignment: .leading, spacing: FlixySpacing.snug * scale) {
titleView(scale: scale)
if let metadata {
Text(metadata)
.font(.system(size: 12 * scale))
.foregroundStyle(Color.flixyTextSecondary)
}
if let summary {
Text(summary)
.font(.system(size: 12 * scale))
.foregroundStyle(Color.flixyTextSecondary)
.lineLimit(2)
// Ein Satz über die ganze Breite eines großen
// Fensters ist unlesbar; das Auge verliert die
// Zeile. Achtzig Zeichen sind die Grenze, ab
// der Satzspiegel unangenehm werden.
.frame(maxWidth: 640 * scale, alignment: .leading)
}
HStack(spacing: FlixySpacing.regular) {
actions
}
.padding(.top, FlixySpacing.tight)
}
.padding(.horizontal, FlixySpacing.screenMargin * scale)
.padding(.bottom, FlixySpacing.loose * scale)
.frame(
width: geometry.size.width,
height: geometry.size.height,
alignment: .bottomLeading
)
}
}
}
}