Bearbeiten, Suche auf dem Handy, und zwei Knöpfe, die man treffen kann

Die Seitenleiste lässt sich wieder bearbeiten: Ziehen zum Umsortieren, Haken
zum Ein- und Ausblenden. Das Anordnen macht `Arrangement` aus MediaKit — dieselbe
Rechnung samt Tests, die schon die Reihen der Startseite ordnet, mit ihren
kniffligen Fällen: eine Mediathek, die neu auftaucht, und eine, die aus der
gespeicherten Reihenfolge verschwindet. Gespeichert wird über eine feste
Kennung **ohne** den Titel, damit eine umbenannte Mediathek ihren Platz behält.

Die Suche ist auf dem iPhone kein eigener Reiter mehr. Die Leiste unten hat
fünf Plätze, und einer davon für etwas, das man in einer Mediathek sucht, ist
einer zu viel — sie sitzt jetzt über der Mediathek, neben Filter und Ansicht.

Der Umschalter zwischen Seite und oben ließ sich auf dem Mac kaum treffen: ein
Knopf aus einem Zeichen mit Rand hat ohne `contentShape` nur das Zeichen als
Ziel. Beide Umschalter haben jetzt eine Fläche.

Die Leiste oben trägt dieselbe Schriftgröße wie die Seitenleiste. In
Beschriftungsgröße war sie auf einem großen Bildschirm winzig.

Und eine Maske: der Aufmacher greift nach links unter die Seitenleiste, scrollt
dabei aber, während die Leiste steht — über und unter ihr blieb je ein Streifen
Bild stehen. Die Maske sitzt im festen Teil und lässt genau zwei Flächen übrig:
alles ab der Inhaltskante, und die Fläche der Leiste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 20:33:33 +02:00
co-authored by Claude Opus 5
parent dba645d168
commit e2779f2519
6 changed files with 4156 additions and 3794 deletions
+145 -34
View File
@@ -12,12 +12,17 @@ enum NavigationPlacement: String {
}
/// Ein Eintrag in der Navigation.
struct NavigationEntry: Identifiable {
struct NavigationEntry: Identifiable, Arrangeable {
let destination: SidebarDestination
let title: Text
let symbol: String
/// Der Titel als reiner Text — für die Sortierlogik und für die
/// Bedienungshilfen. `Text` selbst lässt sich nicht auslesen.
let plainTitle: String
var id: SidebarDestination { destination }
var arrangementID: String { destination.storageKey }
var arrangementTitle: String { plainTitle }
}
/// Eine Gruppe von Einträgen, mit oder ohne Überschrift.
@@ -45,14 +50,18 @@ struct NavigationGroup: Identifiable {
/// fällt, und verschwindet, wo keins ist. Das ist der Unterschied zwischen
/// „umrandet" und „aus Glas".
struct FlixySidebar: View {
/// Alle Einträge, **ungefiltert**. Was davon zu sehen ist, entscheidet
/// `layout` — beim Bearbeiten müssen auch die ausgeblendeten dastehen.
let groups: [NavigationGroup]
@Binding var selection: SidebarDestination
@Binding var placement: NavigationPlacement
let layout: NavigationLayoutSettings
@Environment(\.flixyAccent) private var accent
/// Welche Gruppen eingeklappt sind. Nach Kennung, nicht nach Stellung:
/// kommt eine Mediathek dazu, soll nicht plötzlich die falsche zuklappen.
@State private var collapsed: Set<String> = []
@State private var isEditing = false
/// Die Fläche, auf der die Leiste liegt.
///
@@ -96,24 +105,46 @@ struct FlixySidebar: View {
VStack(alignment: .leading, spacing: 0) {
header
// Ohne Bildlaufansicht.
//
// Sie war der helle Streifen an der rechten Kante: ihr Anzeiger,
// zwölf Punkte breit, in gleichbleibender Helligkeit, genau so
// hoch wie die Liste. `.scrollIndicators(.never)` half nicht — auf
// dem Mac zeichnet das System ihn trotzdem.
//
// Gebraucht wird sie ohnehin nicht: die Einträge sind wenige und
// passen. Sollten es einmal so viele Mediatheken werden, dass sie
// nicht passen, ist das eine eigene Aufgabe — und dann mit
// abgeschaltetem Anzeiger von Hand.
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
ForEach(groups) { group in
section(group)
// Ohne Bildlaufansicht im Ruhezustand: die Einträge sind wenige
// und passen. Beim Bearbeiten braucht es eine Liste — nur die
// bringt das Ziehen zum Umsortieren mit.
if isEditing {
Text("sidebar.editHint")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.padding(.horizontal, FlixySpacing.regular)
.padding(.bottom, FlixySpacing.snug)
List {
ForEach(groups) { group in
Section {
ForEach(editable(group)) { entry in
editRow(entry)
}
.onMove { move(in: group, from: $0, to: $1) }
} header: {
if let title = group.title {
Text(title)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
}
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.environment(\.editMode, .constant(.active))
} else {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
ForEach(groups) { group in
section(visible(group))
}
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.bottom, FlixySpacing.regular)
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.bottom, FlixySpacing.regular)
Spacer(minLength: 0)
@@ -130,23 +161,69 @@ struct FlixySidebar: View {
}
private var header: some View {
HStack {
Spacer()
HStack(spacing: FlixySpacing.snug) {
Button {
withAnimation(.snappy(duration: 0.25)) { placement = .top }
withAnimation(.snappy(duration: 0.25)) { isEditing.toggle() }
} label: {
Image(systemName: "rectangle.topthird.inset.filled")
.font(.flixyRowHeader)
Text(isEditing ? "action.done" : "sidebar.edit")
.font(.flixyCaption)
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.tight)
.contentShape(Capsule())
}
.buttonStyle(.plain)
.foregroundStyle(Color.flixyTextSecondary)
.accessibilityLabel(Text("sidebar.moveToTop"))
.foregroundStyle(isEditing ? accent : Color.flixyTextSecondary)
Spacer(minLength: 0)
if !isEditing {
Button {
withAnimation(.snappy(duration: 0.25)) { placement = .top }
} label: {
Image(systemName: "rectangle.topthird.inset.filled")
.font(.flixyRowHeader)
.padding(FlixySpacing.tight)
// Ohne eigene Klickfläche reicht das Ziel nur so weit
// wie das Zeichen selbst — auf dem Mac trifft man das
// mit dem Zeiger kaum.
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(Color.flixyTextSecondary)
.accessibilityLabel(Text("sidebar.moveToTop"))
}
}
.padding(.horizontal, FlixySpacing.regular)
.padding(.top, FlixySpacing.regular)
.padding(.bottom, FlixySpacing.snug)
}
/// Die Einträge einer Gruppe beim Bearbeiten: alle, in der gespeicherten
/// Reihenfolge, auch die ausgeblendeten.
private func editable(_ group: NavigationGroup) -> [NavigationEntry] {
layout.ordered(group.entries)
}
/// Verschiebt einen Eintrag innerhalb seiner Gruppe.
///
/// Gespeichert wird die Reihenfolge **aller** Gruppen hintereinander.
/// Innerhalb einer Gruppe zu sortieren genügt für die Anzeige, aber eine
/// Liste je Gruppe zu speichern hieße, beim Hinzukommen einer Gruppe
/// wieder eine Sonderregel zu haben.
private func move(in group: NavigationGroup, from source: IndexSet, to destination: Int) {
var entries = editable(group)
entries.move(fromOffsets: source, toOffset: destination)
var order: [String] = []
for other in groups {
let ids = other.id == group.id
? entries.map(\.arrangementID)
: editable(other).map(\.arrangementID)
order.append(contentsOf: ids)
}
layout.setOrder(order)
}
@ViewBuilder
private func section(_ group: NavigationGroup) -> some View {
if let title = group.title {
@@ -182,6 +259,36 @@ struct FlixySidebar: View {
}
}
/// Eine Gruppe, wie sie im Ruhezustand dasteht: ausgeblendete raus,
/// Reihenfolge angewandt.
private func visible(_ group: NavigationGroup) -> NavigationGroup {
NavigationGroup(id: group.id, title: group.title, entries: layout.apply(to: group.entries))
}
private func editRow(_ entry: NavigationEntry) -> some View {
let isHidden = layout.isHidden(entry.arrangementID)
return HStack(spacing: FlixySpacing.snug) {
Button {
layout.setHidden(!isHidden, for: entry.arrangementID)
} label: {
Image(systemName: isHidden ? "circle" : "checkmark.circle.fill")
.foregroundStyle(isHidden ? Color.flixyTextMuted : accent)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(Text(isHidden ? "sidebar.show" : "sidebar.hide"))
Image(systemName: entry.symbol)
.frame(width: 22)
entry.title
.lineLimit(1)
Spacer(minLength: 0)
}
.font(.flixyBody)
.foregroundStyle(isHidden ? Color.flixyTextMuted : Color.flixyTextPrimary)
}
private func row(_ entry: NavigationEntry) -> some View {
let isSelected = selection == entry.destination
@@ -233,9 +340,13 @@ struct FlixyTopBar: View {
withAnimation(.snappy(duration: 0.25)) { placement = .sidebar }
} label: {
Image(systemName: "sidebar.leading")
.font(.flixyCaption)
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.tight)
.font(.flixyBody)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.snug)
// Ohne eigene Klickfläche reicht das Ziel nur so weit wie
// das Zeichen; auf dem Mac war der Umschalter damit
// praktisch nicht zu treffen.
.contentShape(Capsule())
}
.buttonStyle(.plain)
.foregroundStyle(Color.flixyTextSecondary)
@@ -252,7 +363,7 @@ struct FlixyTopBar: View {
}
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.tight)
.padding(.vertical, FlixySpacing.snug)
.background { FlixySidebar.surface(in: .capsule) }
.clipShape(.capsule)
.shadow(color: .black.opacity(0.45), radius: 18, y: 6)
@@ -266,11 +377,11 @@ struct FlixyTopBar: View {
selection = entry.destination
} label: {
entry.title
.font(.flixyCaption)
.font(.flixyBody)
.lineLimit(1)
.foregroundStyle(isSelected ? accent : Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.tight)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
.background {
if isSelected { Capsule().fill(Color.flixyElevated) }
}
@@ -293,10 +404,10 @@ struct FlixyTopBar: View {
if let title = group.title { Text(title) }
Image(systemName: "chevron.down").font(.flixyCaption)
}
.font(.flixyCaption)
.font(.flixyBody)
.foregroundStyle(containsSelection ? accent : Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.tight)
.padding(.horizontal, FlixySpacing.loose)
.padding(.vertical, FlixySpacing.snug)
.background {
if containsSelection { Capsule().fill(Color.flixyElevated) }
}