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>
This commit is contained in:
Scarriffle
2026-09-13 15:59:22 +02:00
co-authored by Claude Opus 5
parent d54954b9c2
commit f8ada44a8c
21 changed files with 174 additions and 56 deletions
@@ -39,13 +39,25 @@ public struct HeroBanner<Actions: View>: View {
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 var titleView: some View {
private func titleView(scale: CGFloat) -> some View {
if let logoURL {
AsyncImage(url: logoURL) { phase in
switch phase {
@@ -56,23 +68,23 @@ public struct HeroBanner<Actions: View>: View {
// 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, maxHeight: 76, alignment: .leading)
.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
plainTitle(scale: scale)
}
}
.accessibilityLabel(Text(title))
} else {
plainTitle
plainTitle(scale: scale)
}
}
private var plainTitle: some View {
private func plainTitle(scale: CGFloat) -> some View {
Text(title)
.font(.flixyHero)
.font(.system(size: 34 * scale, weight: .bold))
.foregroundStyle(Color.flixyTextPrimary)
.lineLimit(2)
}
@@ -106,29 +118,43 @@ public struct HeroBanner<Actions: View>: View {
.allowsHitTesting(false)
}
.overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: FlixySpacing.snug) {
titleView
GeometryReader { geometry in
let scale = scale(for: geometry.size.width)
if let metadata {
Text(metadata)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
}
VStack(alignment: .leading, spacing: FlixySpacing.snug * scale) {
titleView(scale: scale)
if let summary {
Text(summary)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextSecondary)
.lineLimit(2)
}
if let metadata {
Text(metadata)
.font(.system(size: 12 * scale))
.foregroundStyle(Color.flixyTextSecondary)
}
HStack(spacing: FlixySpacing.regular) {
actions
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(.top, FlixySpacing.tight)
.padding(.horizontal, FlixySpacing.screenMargin * scale)
.padding(.bottom, FlixySpacing.loose * scale)
.frame(
width: geometry.size.width,
height: geometry.size.height,
alignment: .bottomLeading
)
}
.padding(.horizontal, FlixySpacing.screenMargin)
.padding(.bottom, FlixySpacing.loose)
}
}
}
@@ -2,10 +2,33 @@ import SwiftUI
/// Die Schriftskala der App.
///
/// Durchgehend die Systemschrift mit `Font.TextStyle` als Grundlage, damit
/// Dynamic Type wirkt. Die Rolle steht im Namen, nicht die Größe — sonst
/// führt jede Anpassung zu einer Suche quer durch alle Views.
/// Auf iOS die Systemstile, damit Dynamic Type wirkt. Die Rolle steht im
/// Namen, nicht die Größe — sonst führt jede Anpassung zu einer Suche quer
/// durch alle Views.
///
/// **Auf dem Mac stehen feste Größen.** Dieselben Stile sind dort deutlich
/// kleiner: `largeTitle` misst 26 Punkt statt 34, `body` 13 statt 17. Eine
/// Oberfläche, die auf dem iPad stimmt, wirkt damit auf dem Mac zusammen-
/// geschrumpft — und genau das war sie. Die Zahlen hier sind die von iOS.
///
/// Das kostet auf dem Mac Dynamic Type. Das ist vertretbar: macOS hat keine
/// systemweite Textgrößen-Einstellung, auf die Apps reagieren müssten, und ein
/// Fenster lässt sich stattdessen größer ziehen.
extension Font {
#if os(macOS)
/// Der Titel im Aufmacher-Bild.
public static let flixyHero = Font.system(size: 34, weight: .bold)
/// Titel auf Detailseiten.
public static let flixyTitle = Font.system(size: 22, weight: .semibold)
/// Überschrift einer Reihe auf der Startseite.
public static let flixyRowHeader = Font.system(size: 17, weight: .semibold)
/// Titel unter einem Poster.
public static let flixyCardTitle = Font.system(size: 15, weight: .medium)
/// Fließtext, Beschreibungen.
public static let flixyBody = Font.system(size: 17)
/// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht.
public static let flixyCaption = Font.system(size: 12)
#else
/// Der Titel im Aufmacher-Bild.
public static let flixyHero = Font.system(.largeTitle, design: .default, weight: .bold)
/// Titel auf Detailseiten.
@@ -18,6 +41,7 @@ extension Font {
public static let flixyBody = Font.system(.body)
/// Jahreszahl, Laufzeit, Auflösung — alles, was neben dem Titel steht.
public static let flixyCaption = Font.system(.caption)
#endif
}
/// Abstände.
@@ -43,6 +43,21 @@ extension View {
#endif
}
/// Ein Formular, das auf beiden Seiten gleich aussieht.
///
/// Auf dem Mac setzt SwiftUI sonst den Spalten-Stil: Beschriftungen rechts
/// ausgerichtet, keine abgesetzten Abschnitte, alles eng. Der gruppierte
/// Stil ist derselbe, den iOS ohnehin nimmt — und der Grund, warum die
/// Einstellungen auf dem Mac wie ein Systemdialog aus einem anderen
/// Jahrzehnt aussahen.
public func flixyForm() -> some View {
#if os(macOS)
formStyle(.grouped)
#else
self
#endif
}
/// Eingabefelder für Adressen und Namen: keine Großschreibung, keine
/// Korrektur — auf dem Mac gibt es beides ohnehin nicht als Tastatur.
public func flixyPlainEntry(isAddress: Bool = false) -> some View {