Fünf Knöpfe in einer Reihe wurden auf dem Telefon zu Säulen
In einem `HStack` teilen sich die Knöpfe die Breite. Abspielen, Von vorn, Herunterladen und zwei Sprachknöpfe passten auf einem Telefon nicht nebeneinander — heraus kamen Säulen aus einzelnen Buchstaben, senkrecht untereinander. Lesbar war nichts mehr; angetippt hätte man trotzdem das Richtige. `Flusszeile` bricht um, statt zu quetschen: was nicht mehr hinpasst, kommt in die nächste Zeile. Zwei Zeilen sind kein Mangel, sie sind, was dort hinpasst — und ein Knopf, der auf schmalen Geräten verschwindet, wäre eine zweite Oberfläche, die gepflegt werden will. Kein `ViewThatFits`: das wählt zwischen fertigen Entwürfen, und die müsste man für jede Zahl von Knöpfen von Hand hinschreiben. Hier wird gerechnet. Dazu bleibt die Beschriftung eines Sprachknopfs einzeilig. Umbrochen wird die Reihe, nicht das Wort darin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f85891aba4
commit
94458ef41c
@@ -9,10 +9,6 @@
|
||||
"comment" : "A number in a list.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"%@ GB": {
|
||||
"comment": "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"%@ → %@" : {
|
||||
"comment" : "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@@ -25,22 +21,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@ GB" : {
|
||||
"comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"%@: %@" : {
|
||||
"comment" : "A label that shows the audio or subtitle language. The argument is the language name.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@: %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"4K" : {
|
||||
"comment" : "A label for a 4K video.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Full HD": {
|
||||
"comment": "A label for a media resolution.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"HD": {
|
||||
"comment": "A label for a media resolution.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"SD": {
|
||||
"comment": "A label for a 720p movie.",
|
||||
"isCommentAutoGenerated": true
|
||||
},
|
||||
"account.hint" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2021,6 +2021,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Full HD" : {
|
||||
"comment" : "A label for a media resolution.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"HD" : {
|
||||
"comment" : "A label for a media resolution.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"hero.details" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3506,23 +3514,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"liveTV.recordSeries": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Serie aufnehmen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Record series"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"liveTV.recording.inProgress" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -3591,6 +3582,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"liveTV.recordSeries" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Serie aufnehmen"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Record series"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"liveTV.runningNow" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -4365,23 +4373,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.subtitleTracks": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Untertitel"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Subtitles"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.subtitlesOff" : {
|
||||
"comment" : "Eintrag, der die Untertitel abschaltet",
|
||||
"extractionState" : "stale",
|
||||
@@ -4400,6 +4391,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.subtitleTracks" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Untertitel"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Subtitles"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"player.tracks" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -4484,23 +4492,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.autoShort": {
|
||||
"extractionState": "stale",
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Auto"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.automatic" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -4518,6 +4509,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.autoShort" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Auto"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"quality.current" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -5063,6 +5071,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SD" : {
|
||||
"comment" : "A label for a 720p movie.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"search.forgetRecent" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -77,6 +77,12 @@ struct AudioLanguagePicker: View {
|
||||
Image(systemName: kind == .audio ? "waveform" : "captions.bubble")
|
||||
Text("\(Text(title)): \(currentLabel)")
|
||||
.font(.flaroCardTitle)
|
||||
// **Eine Zeile, keine Säule.** Umbrochen wird die
|
||||
// Reihe, nicht der einzelne Knopf — sonst steht
|
||||
// „Untertitel" wieder Buchstabe für Buchstabe
|
||||
// untereinander.
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.flaroCaption)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Eine Reihe, die umbricht statt zu quetschen.
|
||||
///
|
||||
/// **Der Anlass.** Im Kopf einer Detailseite stehen Abspielen, Von vorn,
|
||||
/// Herunterladen und zwei Sprachknöpfe nebeneinander. In einem `HStack`
|
||||
/// teilen sie sich die Breite — auf einem Telefon wurde daraus eine Reihe
|
||||
/// von Säulen aus einzelnen Buchstaben, untereinander gesetzt. Lesbar war
|
||||
/// nichts mehr, und angetippt hätte man trotzdem das Richtige.
|
||||
///
|
||||
/// **Umbrechen ist die Antwort, nicht Weglassen.** Ein Knopf, der auf einem
|
||||
/// schmalen Gerät verschwindet, ist eine zweite Oberfläche, die gepflegt
|
||||
/// werden will. Zwei Zeilen sind kein Mangel; sie sind, was dort hinpasst.
|
||||
///
|
||||
/// **Warum eigener `Layout` und kein `ViewThatFits`.** Das wählt zwischen
|
||||
/// fertigen Entwürfen — man müsste jede Aufteilung von Hand hinschreiben,
|
||||
/// und bei fünf Knöpfen sind das fünf. Hier wird gerechnet.
|
||||
public struct Flusszeile: Layout {
|
||||
private let abstand: CGFloat
|
||||
private let zeilenabstand: CGFloat
|
||||
|
||||
public init(abstand: CGFloat = FlaroSpacing.regular, zeilenabstand: CGFloat? = nil) {
|
||||
self.abstand = abstand
|
||||
self.zeilenabstand = zeilenabstand ?? abstand
|
||||
}
|
||||
|
||||
public func sizeThatFits(
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout Void
|
||||
) -> CGSize {
|
||||
let breite = proposal.width ?? .infinity
|
||||
let zeilen = umbrechen(subviews, in: breite)
|
||||
|
||||
let hoehe = zeilen.reduce(CGFloat.zero) { $0 + $1.hoehe }
|
||||
+ CGFloat(max(0, zeilen.count - 1)) * zeilenabstand
|
||||
|
||||
// **Die breiteste Zeile, nicht die angebotene Breite.** Sonst nimmt
|
||||
// die Reihe das ganze Fenster ein, auch wenn zwei Knöpfe darin
|
||||
// stehen — und alles daneben rutscht weg.
|
||||
let genutzt = zeilen.map(\.breite).max() ?? 0
|
||||
return CGSize(width: min(genutzt, breite), height: hoehe)
|
||||
}
|
||||
|
||||
public func placeSubviews(
|
||||
in bounds: CGRect,
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout Void
|
||||
) {
|
||||
var y = bounds.minY
|
||||
|
||||
for zeile in umbrechen(subviews, in: bounds.width) {
|
||||
var x = bounds.minX
|
||||
|
||||
for index in zeile.teile {
|
||||
let groesse = subviews[index].sizeThatFits(.unspecified)
|
||||
subviews[index].place(
|
||||
at: CGPoint(x: x, y: y + (zeile.hoehe - groesse.height) / 2),
|
||||
proposal: ProposedViewSize(groesse)
|
||||
)
|
||||
x += groesse.width + abstand
|
||||
}
|
||||
|
||||
y += zeile.hoehe + zeilenabstand
|
||||
}
|
||||
}
|
||||
|
||||
private struct Zeile {
|
||||
var teile: [Int] = []
|
||||
var breite: CGFloat = 0
|
||||
var hoehe: CGFloat = 0
|
||||
}
|
||||
|
||||
private func umbrechen(_ subviews: Subviews, in breite: CGFloat) -> [Zeile] {
|
||||
var zeilen: [Zeile] = []
|
||||
var jetzt = Zeile()
|
||||
|
||||
for index in subviews.indices {
|
||||
let groesse = subviews[index].sizeThatFits(.unspecified)
|
||||
let dazu = jetzt.teile.isEmpty ? groesse.width : jetzt.breite + abstand + groesse.width
|
||||
|
||||
// **Das erste Element bleibt immer stehen.** Ein Knopf, der
|
||||
// allein schon breiter ist als die Zeile, käme sonst in eine
|
||||
// leere Zeile und von dort in die nächste, endlos.
|
||||
if !jetzt.teile.isEmpty, dazu > breite {
|
||||
zeilen.append(jetzt)
|
||||
jetzt = Zeile()
|
||||
jetzt.teile = [index]
|
||||
jetzt.breite = groesse.width
|
||||
jetzt.hoehe = groesse.height
|
||||
continue
|
||||
}
|
||||
|
||||
jetzt.teile.append(index)
|
||||
jetzt.breite = dazu
|
||||
jetzt.hoehe = max(jetzt.hoehe, groesse.height)
|
||||
}
|
||||
|
||||
if !jetzt.teile.isEmpty { zeilen.append(jetzt) }
|
||||
return zeilen
|
||||
}
|
||||
}
|
||||
@@ -291,7 +291,10 @@ public struct HeroBanner<Actions: View>: View {
|
||||
.frame(maxWidth: 640 * scale, alignment: .leading)
|
||||
}
|
||||
|
||||
HStack(spacing: FlaroSpacing.regular) {
|
||||
// **Umbrechen statt quetschen.** In einem `HStack` teilen sich
|
||||
// fünf Knöpfe die Breite; auf einem Telefon wurde daraus eine
|
||||
// Reihe von Säulen aus einzelnen Buchstaben.
|
||||
Flusszeile {
|
||||
actions
|
||||
}
|
||||
.padding(.top, FlaroSpacing.tight)
|
||||
|
||||
Reference in New Issue
Block a user