Eigene Seitenleiste — überall dieselbe

Die System-Seitenleiste sitzt auf dem iPad als schwebende Fläche und auf dem
Mac angeklebt am Fensterrand. `preferredLayout = .overlap` ist die dafür
vorgesehene Bitte, und macOS verwirft sie stillschweigend — gemessen, nicht
vermutet.

Statt deshalb die ganze Navigation neu zu bauen, bleibt der `TabView` stehen
und verliert nur sein Kleid: `TabChromeSuppressor` blendet Seiten- und
Tableiste aus, gezeichnet wird darüber. Auswahl, ein eigener Navigationsstapel
je Eintrag, erhaltener Scrollstand beim Wechseln — all das macht weiter das
System. Dass das Ausblenden geht, war kein Ratschluss: derselbe Griff hatte die
Leiste vorher schon versuchsweise verschwinden lassen.

Neu sind `FlixySidebar` (schwebende Fläche mit eigenem Rand, Mediatheken
einklappbar, Konto unten) und `FlixyTopBar` (dieselben Einträge als Leiste
oben, Mediatheken als Menü, weil oben der Platz waagerecht begrenzt ist). Das
iPhone bleibt beim System: dort gibt es keine Seitenleiste, sondern die Leiste
unten, und die ist überall dieselbe.

Der Platz für die Leiste ist ein **Sicherheitsrand**, kein Abstand, und er
hängt je Bildschirm — weder `TabView` noch `NavigationStack` reichen ihn an
ihren Inhalt weiter. Damit bleibt eine Reihe fensterbreit und schiebt ihre
Kacheln beim Wischen hinter die Leiste, stellt aber im Ruhezustand keine
dorthin. Der Aufmacher darf über seinen Bereich hinaus; das `.clipped()` im
Karussell musste dafür weichen, es schnitt genau das ab.

Noch nicht dabei: „Bearbeiten" mit Ziehen und Ein-/Ausblenden. Die Einträge
stehen in fester Reihenfolge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 19:17:27 +02:00
co-authored by Claude Opus 5
parent b4c2bd3970
commit fccec1217b
9 changed files with 4257 additions and 3814 deletions
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -85,9 +85,13 @@ struct HeroCarousel: View {
} }
} }
.aspectRatio(FlixyAspectRatio.hero, contentMode: .fit) .aspectRatio(FlixyAspectRatio.hero, contentMode: .fit)
// Das Bild wechselt, der Platz nicht: ohne das Beschneiden schöbe // **Nicht** beschneiden: der Aufmacher greift bewusst nach links
// das hereinrutschende Bild die Seite um seine Strecke breiter. // über seinen Bereich hinaus, damit sein Bild unter der
.clipped() // Seitenleiste durchläuft. Ein `.clipped()` schnitt genau das ab —
// und übrig blieb ein schwarzer Streifen neben der Leiste.
//
// Für den Platz ist es auch nicht nötig: das Hereinrutschen läuft
// über `offset`, und das verschiebt nur, was gezeichnet wird.
// Wischen blättert weiter, jede Berührung hält das Wechseln an — // Wischen blättert weiter, jede Berührung hält das Wechseln an —
// auch die, die nur ein Antippen wird. Gleichzeitig, damit weder // auch die, die nur ein Antippen wird. Gleichzeitig, damit weder
// dem Wischen noch den Knöpfen darunter etwas weggenommen wird. // dem Wischen noch den Knöpfen darunter etwas weggenommen wird.
@@ -0,0 +1,267 @@
import SwiftUI
import FlixyDesign
import MediaKit
/// Wo die Navigation sitzt.
///
/// Die Wahl gehört dem Nutzer und muss den Neustart überleben — deshalb eine
/// eigene, gespeicherte Größe statt eines Zustands, der mit der Ansicht stirbt.
enum NavigationPlacement: String {
case sidebar
case top
}
/// Ein Eintrag in der Navigation.
struct NavigationEntry: Identifiable {
let destination: SidebarDestination
let title: Text
let symbol: String
var id: SidebarDestination { destination }
}
/// Eine Gruppe von Einträgen, mit oder ohne Überschrift.
struct NavigationGroup: Identifiable {
let id: String
let title: LocalizedStringKey?
let entries: [NavigationEntry]
}
// MARK: - Seitenleiste
/// Die Seitenleiste, wie sie auf dem iPad aussieht — überall.
///
/// Selbst gezeichnet, weil das System sie auf dem Mac angeklebt an den
/// Fensterrand setzt und sich das nicht umstellen lässt. Siehe
/// `TabChromeSuppressor`: das Verhalten kommt weiter vom `TabView`, nur das
/// Kleid ist unseres.
///
/// Eine schwebende Fläche mit Rand ringsum — genau das war die Bitte: „eine
/// eigene Bubble mit einem klaren Rand, der nicht der Fensterrand ist".
struct FlixySidebar: View {
let groups: [NavigationGroup]
@Binding var selection: SidebarDestination
@Binding var placement: NavigationPlacement
@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> = []
static let width: CGFloat = 235
/// Der Abstand zum Fensterrand. Er ist der eigentliche Punkt der Sache.
static let margin: CGFloat = 12
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: FlixySpacing.regular) {
ForEach(groups) { group in
section(group)
}
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.bottom, FlixySpacing.regular)
}
.scrollIndicators(.never)
Spacer(minLength: 0)
Divider().overlay(Color.flixySeparator)
AccountBar(selection: $selection)
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.snug)
}
.frame(width: Self.width)
.background {
RoundedRectangle(cornerRadius: FlixyRadius.panel, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: FlixyRadius.panel, style: .continuous)
.strokeBorder(Color.flixySeparator.opacity(0.6), lineWidth: 0.5)
}
}
.clipShape(RoundedRectangle(cornerRadius: FlixyRadius.panel, style: .continuous))
.padding(Self.margin)
}
private var header: some View {
HStack {
Spacer()
Button {
withAnimation(.snappy(duration: 0.25)) { placement = .top }
} label: {
Image(systemName: "rectangle.topthird.inset.filled")
.font(.flixyRowHeader)
}
.buttonStyle(.plain)
.foregroundStyle(Color.flixyTextSecondary)
.accessibilityLabel(Text("sidebar.moveToTop"))
}
.padding(.horizontal, FlixySpacing.regular)
.padding(.top, FlixySpacing.regular)
.padding(.bottom, FlixySpacing.snug)
}
@ViewBuilder
private func section(_ group: NavigationGroup) -> some View {
if let title = group.title {
Button {
withAnimation(.snappy(duration: 0.25)) {
if collapsed.contains(group.id) {
collapsed.remove(group.id)
} else {
collapsed.insert(group.id)
}
}
} label: {
HStack {
Text(title)
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
Spacer()
Image(systemName: "chevron.down")
.font(.flixyCaption)
.foregroundStyle(Color.flixyTextMuted)
.rotationEffect(.degrees(collapsed.contains(group.id) ? -90 : 0))
}
.padding(.horizontal, FlixySpacing.regular)
.padding(.top, FlixySpacing.snug)
}
.buttonStyle(.plain)
}
if !collapsed.contains(group.id) {
ForEach(group.entries) { entry in
row(entry)
}
}
}
private func row(_ entry: NavigationEntry) -> some View {
let isSelected = selection == entry.destination
return Button {
selection = entry.destination
} label: {
HStack(spacing: FlixySpacing.snug) {
Image(systemName: entry.symbol)
.font(.flixyBody)
.frame(width: 22, alignment: .center)
entry.title
.font(.flixyBody)
// Ein langer Mediathekenname darf die Leiste nicht
// breiter machen; sie hat eine feste Breite.
.lineLimit(1)
Spacer(minLength: 0)
}
.foregroundStyle(isSelected ? accent : Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.snug)
.background {
if isSelected {
Capsule().fill(Color.flixyElevated)
}
}
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
}
// MARK: - Leiste oben
/// Dieselben Einträge als schwebende Leiste oben.
///
/// Mediatheken stehen hier in einem Menü statt einzeln: oben ist der Platz
/// waagerecht begrenzt, und eine Reihe, die bei acht Mediatheken über den Rand
/// hinauswächst, wäre schlechter als ein Menü.
struct FlixyTopBar: View {
let groups: [NavigationGroup]
@Binding var selection: SidebarDestination
@Binding var placement: NavigationPlacement
@Environment(\.flixyAccent) private var accent
var body: some View {
HStack(spacing: FlixySpacing.tight) {
Button {
withAnimation(.snappy(duration: 0.25)) { placement = .sidebar }
} label: {
Image(systemName: "sidebar.leading")
.font(.flixyCaption)
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.tight)
}
.buttonStyle(.plain)
.foregroundStyle(Color.flixyTextSecondary)
.accessibilityLabel(Text("sidebar.moveToSide"))
ForEach(groups) { group in
if group.title == nil {
ForEach(group.entries) { entry in
pill(entry)
}
} else {
menu(group)
}
}
}
.padding(.horizontal, FlixySpacing.snug)
.padding(.vertical, FlixySpacing.tight)
.background {
Capsule().fill(.ultraThinMaterial)
.overlay { Capsule().strokeBorder(Color.flixySeparator.opacity(0.6), lineWidth: 0.5) }
}
.padding(.top, FlixySpacing.snug)
}
private func pill(_ entry: NavigationEntry) -> some View {
let isSelected = selection == entry.destination
return Button {
selection = entry.destination
} label: {
entry.title
.font(.flixyCaption)
.lineLimit(1)
.foregroundStyle(isSelected ? accent : Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.tight)
.background {
if isSelected { Capsule().fill(Color.flixyElevated) }
}
.contentShape(Capsule())
}
.buttonStyle(.plain)
}
private func menu(_ group: NavigationGroup) -> some View {
let containsSelection = group.entries.contains { $0.destination == selection }
return Menu {
ForEach(group.entries) { entry in
Button { selection = entry.destination } label: {
Label { entry.title } icon: { Image(systemName: entry.symbol) }
}
}
} label: {
HStack(spacing: FlixySpacing.tight) {
if let title = group.title { Text(title) }
Image(systemName: "chevron.down").font(.flixyCaption)
}
.font(.flixyCaption)
.foregroundStyle(containsSelection ? accent : Color.flixyTextPrimary)
.padding(.horizontal, FlixySpacing.regular)
.padding(.vertical, FlixySpacing.tight)
.background {
if containsSelection { Capsule().fill(Color.flixyElevated) }
}
.contentShape(Capsule())
}
.menuStyle(.button)
.buttonStyle(.plain)
}
}
+135 -21
View File
@@ -36,6 +36,13 @@ struct RootView: View {
@AppStorage("flixy.navigation.customization") @AppStorage("flixy.navigation.customization")
private var customization = TabViewCustomization() private var customization = TabViewCustomization()
/// Ob die Navigation an der Seite oder oben sitzt.
///
/// Gespeichert, weil die Wahl dem Nutzer gehört und den Neustart
/// überleben muss.
@AppStorage("flixy.navigation.placement")
private var placement: NavigationPlacement = .sidebar
var body: some View { var body: some View {
Group { Group {
switch session.phase { switch session.phase {
@@ -177,7 +184,7 @@ struct RootView: View {
/// Unterschied sichtbar. /// Unterschied sichtbar.
static let isOnMac = ProcessInfo.processInfo.isMacCatalystApp static let isOnMac = ProcessInfo.processInfo.isMacCatalystApp
private var library: some View { private var tabs: some View {
TabView(selection: $selection) { TabView(selection: $selection) {
Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) { Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) {
mediaStack { HomeView() } mediaStack { HomeView() }
@@ -248,28 +255,108 @@ struct RootView: View {
} }
.tabViewStyle(.sidebarAdaptable) .tabViewStyle(.sidebarAdaptable)
.tabViewCustomization($customization) .tabViewCustomization($customization)
// Auf dem Mac würde die Leiste sonst den Inhalt zur Seite schieben }
// und selbst am Fensterrand kleben. Auf dem iPad tut das System es
// ohnehin so; der Aufruf schadet dort nicht. /// Die Navigation, wie **wir** sie zeichnen.
.background { SidebarOverlapRequest().frame(width: 0, height: 0) } ///
// Das Konto sitzt unten in der Seitenleiste, nicht als Eintrag /// Der `TabView` darunter bleibt stehen und macht weiter, was er kann:
// zwischen den Mediatheken. Dort sucht man es. /// Auswahl, ein eigener Navigationsstapel je Eintrag, erhaltener
.tabViewSidebarBottomBar { /// Scrollstand beim Wechseln. Nur sein Kleid ist ausgeblendet — siehe
AccountBar(selection: $selection) /// `TabChromeSuppressor`. Gezeichnet wird darüber.
.padding(.horizontal, FlixySpacing.snug) ///
// Der Griff, an dem die Spalte hängt: dieser Inhalt sitzt in /// Der Grund steht dort ausführlich: die System-Seitenleiste sitzt auf dem
// der Seitenleiste, also nimmt sie seine Breitenangabe an. /// iPad als schwebende Fläche und auf dem Mac angeklebt am Fensterrand,
#if os(macOS) || targetEnvironment(macCatalyst) /// und das lässt sich nicht umstellen. Da beides gleich aussehen soll,
.navigationSplitViewColumnWidth( /// zeichnen wir es selbst.
min: Self.sidebarWidth.min, ///
ideal: Self.sidebarWidth.ideal, /// Auf dem iPhone bleibt alles beim System: dort gibt es keine
max: Self.sidebarWidth.max /// Seitenleiste, sondern die Leiste unten, und die ist überall dieselbe.
) private var library: some View {
Group {
#if os(iOS)
if sizeClass == .regular {
tabs
.background { TabChromeSuppressor().frame(width: 0, height: 0) }
.overlay(alignment: placement == .sidebar ? .topLeading : .top) {
chrome
}
} else {
tabs
}
#else
tabs
#endif #endif
} }
.miniPlayer(player, isVisible: playback.presentation == .mini) .miniPlayer(player, isVisible: playback.presentation == .mini)
} }
@ViewBuilder
private var chrome: some View {
switch placement {
case .sidebar:
FlixySidebar(groups: navigationGroups, selection: $selection, placement: $placement)
case .top:
// In der Mitte, und zwar erzwungen: die Leiste ist so breit wie
// ihr Inhalt, und eine Überlagerung richtet sie dann nicht
// zuverlässig mittig aus.
HStack {
Spacer(minLength: 0)
FlixyTopBar(groups: navigationGroups, selection: $selection, placement: $placement)
Spacer(minLength: 0)
}
}
}
private var chromeInsets: EdgeInsets {
switch placement {
case .sidebar:
EdgeInsets(
top: 0,
leading: FlixySidebar.width + FlixySidebar.margin * 2,
bottom: 0,
trailing: 0
)
case .top:
EdgeInsets(top: 52, leading: 0, bottom: 0, trailing: 0)
}
}
/// Die Einträge, aus denen die Leiste gebaut wird.
///
/// Dieselbe Liste für beide Formen: was an der Seite steht, steht auch
/// oben. Sonst wäre das Umschalten ein Wechsel der App und nicht nur
/// einer der Anordnung.
private var navigationGroups: [NavigationGroup] {
var groups: [NavigationGroup] = [
NavigationGroup(id: "main", title: nil, entries: [
NavigationEntry(destination: .home, title: Text("sidebar.home"), symbol: "house"),
NavigationEntry(destination: .downloads, title: Text("sidebar.downloads"), symbol: "arrow.down.circle"),
NavigationEntry(destination: .settings, title: Text("sidebar.settings"), symbol: "gearshape"),
NavigationEntry(destination: .search, title: Text("sidebar.search"), symbol: "magnifyingglass"),
])
]
var media = session.libraries.map { library in
NavigationEntry(
destination: .library(key: library.id, title: library.title),
title: Text(library.title),
symbol: symbol(for: library.kind)
)
}
if session.supportsLiveTV {
media.append(NavigationEntry(
destination: .liveTV,
title: Text("sidebar.liveTV"),
symbol: "dot.radiowaves.left.and.right"
))
}
if !media.isEmpty {
groups.append(NavigationGroup(id: "libraries", title: "sidebar.libraries", entries: media))
}
return groups
}
/// Ein Navigationsstapel, in dem Detailseiten aufgehen. /// Ein Navigationsstapel, in dem Detailseiten aufgehen.
/// ///
/// Das Ziel wird **einmal je Stapel** angemeldet, an seiner Wurzel. Vorher /// Das Ziel wird **einmal je Stapel** angemeldet, an seiner Wurzel. Vorher
@@ -279,15 +366,42 @@ struct RootView: View {
/// nicht; es passiert einfach nichts. /// nicht; es passiert einfach nichts.
private func mediaStack<Content: View>(@ViewBuilder content: () -> Content) -> some View { private func mediaStack<Content: View>(@ViewBuilder content: () -> Content) -> some View {
NavigationStack { NavigationStack {
content() chromed { content() }
.navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } .navigationDestination(for: MediaRoute.self) { route in
chromed { MediaDetailView(route: route) }
}
.navigationDestination(for: LibraryRoute.self) { route in .navigationDestination(for: LibraryRoute.self) { route in
chromed {
if let library = session.libraries.first(where: { $0.id == route.id }) { if let library = session.libraries.first(where: { $0.id == route.id }) {
LibraryView(library: library) LibraryView(library: library)
} }
} }
.navigationDestination(for: LiveTVRoute.self) { _ in LiveTVView() }
} }
.navigationDestination(for: LiveTVRoute.self) { _ in
chromed { LiveTVView() }
}
}
}
/// Gibt einem Bildschirm den Platz, den unsere Leiste einnimmt.
///
/// Je Bildschirm und nicht einmal um den Stapel herum: weder der `TabView`
/// noch der `NavigationStack` reichen einen Sicherheitsrand an ihren Inhalt
/// weiter — der Aufmacher lag beide Male zur Hälfte hinter der Leiste.
///
/// Als **Sicherheitsrand**, nicht als Abstand. Der Unterschied ist der
/// ganze Witz: eine Reihe bleibt damit fensterbreit und schiebt ihre
/// Kacheln beim Wischen hinter die Leiste, stellt aber im Ruhezustand
/// keine dorthin. Mit einem gewöhnlichen Abstand endete sie an der Kante.
@ViewBuilder
private func chromed<Content: View>(@ViewBuilder content: () -> Content) -> some View {
#if os(iOS)
let insets = sizeClass == .regular ? chromeInsets : EdgeInsets()
content()
.safeAreaPadding(insets)
#else
content()
#endif
} }
/// Die Breite der Seitenleiste auf dem Mac. /// Die Breite der Seitenleiste auf dem Mac.
@@ -1,68 +0,0 @@
#if os(iOS)
import SwiftUI
import UIKit
/// Bittet die Seitenleiste, den Inhalt zu **überlagern** statt ihn zu verdrängen.
///
/// `UITabBarControllerSidebar.preferredLayout` steht ab Werk auf `automatic`,
/// und das löst sich laut Apples eigener Beschreibung je nach Gerät anders auf:
/// auf iOS zu `overlap` — die Leiste schwebt als eigene Fläche über dem Inhalt —
/// und auf macOS zu `tile`, wo sie den Inhalt zur Seite schiebt und selbst am
/// Fensterrand klebt. Das ist der Unterschied, den man zwischen iPad und Mac
/// sieht, und er kommt nicht aus dieser App.
///
/// **Unter Mac Catalyst gemessen: es hilft dort nicht.** Der Griff sitzt
/// richtig — zum Beweis hat derselbe Weg die Leiste auf Kommando ganz
/// ausgeblendet —, aber `overlap` wird auf dem Mac stillschweigend verworfen
/// und es bleibt bei `tile`. Der Aufruf bleibt trotzdem stehen: er ist die
/// richtige Bitte, er kostet nichts, und sollte „Designed for iPad" sich näher
/// am iPhone-Bau verhalten als Catalyst, greift er dort.
///
/// SwiftUI reicht die Eigenschaft nicht durch, also wird der zuständige
/// `UITabBarController` gesucht. Gesucht wird vom eigenen Platz aus nach oben
/// bis zur Wurzel und von dort nach unten — die Leiste liegt in der
/// SwiftUI-Sicht *über* uns, nicht unter uns, und die reine Elternkette führt
/// deshalb an ihr vorbei.
struct SidebarOverlapRequest: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
Probe()
}
func updateUIViewController(_ controller: UIViewController, context: Context) {}
/// Ein Controller, der nichts zeigt und nur da ist, um im Baum zu stehen.
private final class Probe: UIViewController {
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
apply()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// Ein zweites Mal: beim ersten Anhängen steht der
// Tab-Controller noch nicht zwingend im Baum.
apply()
}
private func apply() {
guard let controller = Self.tabBarController(from: self) else { return }
guard controller.sidebar.preferredLayout != .overlap else { return }
controller.sidebar.preferredLayout = .overlap
}
private static func tabBarController(from start: UIViewController) -> UITabBarController? {
var root = start
while let parent = root.parent { root = parent }
return search(root)
}
private static func search(_ controller: UIViewController) -> UITabBarController? {
if let found = controller as? UITabBarController { return found }
for child in controller.children {
if let found = search(child) { return found }
}
return nil
}
}
}
#endif
+69
View File
@@ -0,0 +1,69 @@
#if os(iOS)
import SwiftUI
import UIKit
/// Blendet die vom System gezeichnete Seitenleiste und Tableiste aus.
///
/// Der Grund ist das Aussehen, nicht das Verhalten. `UITabBarController`
/// zeichnet seine Seitenleiste auf dem iPad als schwebende Fläche und auf dem
/// Mac angeklebt am Fensterrand — und das lässt sich nicht umstellen.
/// `preferredLayout = .overlap` ist die dafür vorgesehene Bitte; macOS verwirft
/// sie stillschweigend. Gemessen, nicht vermutet.
///
/// Statt deshalb die ganze Navigation selbst zu bauen, bleibt der
/// `TabView` **stehen** und verliert nur sein Kleid: Auswahl, getrennte
/// Navigationsstapel je Eintrag, erhaltener Scrollstand beim Wechseln,
/// Zustandspflege — all das macht weiter das System. Gezeichnet wird darüber.
///
/// Dass das geht, war kein Ratschluss: derselbe Griff hat die Leiste
/// versuchsweise schon einmal verschwinden lassen.
struct TabChromeSuppressor: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController { Probe() }
func updateUIViewController(_ controller: UIViewController, context: Context) {}
/// Ein Controller, der nichts zeigt und nur da ist, um im Baum zu stehen.
private final class Probe: UIViewController {
override func didMove(toParent parent: UIViewController?) {
super.didMove(toParent: parent)
suppress()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// Ein zweites Mal: beim ersten Anhängen steht der Tab-Controller
// noch nicht zwingend im Baum.
suppress()
}
override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
// Und bei jedem Umbau: das System stellt die Leisten wieder her,
// wenn sich die Größenklasse ändert — beim Drehen etwa.
suppress()
}
private func suppress() {
guard let controller = Self.tabBarController(from: self) else { return }
if !controller.sidebar.isHidden { controller.sidebar.isHidden = true }
if !controller.isTabBarHidden { controller.isTabBarHidden = true }
}
/// Sucht vom eigenen Platz aus nach oben bis zur Wurzel und von dort
/// nach unten: die Leiste liegt in der SwiftUI-Sicht *über* uns, die
/// reine Elternkette führt an ihr vorbei.
private static func tabBarController(from start: UIViewController) -> UITabBarController? {
var root = start
while let parent = root.parent { root = parent }
return search(root)
}
private static func search(_ controller: UIViewController) -> UITabBarController? {
if let found = controller as? UITabBarController { return found }
for child in controller.children {
if let found = search(child) { return found }
}
return nil
}
}
}
#endif
+5 -4
View File
@@ -44,10 +44,11 @@ final class LayoutSnapshots: XCTestCase {
Thread.sleep(forTimeInterval: 2) Thread.sleep(forTimeInterval: 2)
shoot(app, "10-seitenleiste") shoot(app, "10-seitenleiste")
// Der Knopf oben rechts in der Leiste schaltet um. Er trägt keine // Über die Beschriftung gesucht, nicht über die Stellung: „der zweite
// übersetzte Beschriftung, also über seine Stellung gesucht. // Knopf" war einmal der Umschalter und nach einem Umbau die Kontozeile,
let toggle = app.buttons.element(boundBy: 1) // und der Test öffnete fortan das Kontomenü.
XCTAssertTrue(toggle.exists) let toggle = app.buttons["Navigation nach oben"]
XCTAssertTrue(toggle.waitForExistence(timeout: 5))
toggle.tap() toggle.tap()
for step in 0..<8 { for step in 0..<8 {
@@ -115,7 +115,9 @@ 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)
// Das Bild läuft über seinen Bereich hinaus — unter die // Das Bild läuft über seinen Bereich hinaus — unter die
// Seitenleiste und bis an den Fensterrand. Der Effekt spiegelt die // Seitenleiste und bis an den Fensterrand. Dass es das darf, hängt
// daran, dass der Aufmacher **nicht** beschnitten wird; siehe
// `HeroCarousel`. Der Effekt spiegelt die
// Ränder und zeichnet sie weich weiter; hinter der Leiste steht // Ränder und zeichnet sie weich weiter; hinter der Leiste steht
// damit ein anderer Ausschnitt desselben Plakats. // damit ein anderer Ausschnitt desselben Plakats.
// //
@@ -62,6 +62,9 @@ public enum FlixyRadius {
public static let card: CGFloat = 8 public static let card: CGFloat = 8
public static let sheet: CGFloat = 16 public static let sheet: CGFloat = 16
public static let control: CGFloat = 10 public static let control: CGFloat = 10
/// Eine schwebende Fläche — die Seitenleiste. So rund wie das System sie
/// auf dem iPad zeichnet; daran misst sich der Nachbau.
public static let panel: CGFloat = 22
} }
/// Die Seitenverhältnisse, in denen Plex seine Bilder liefert. /// Die Seitenverhältnisse, in denen Plex seine Bilder liefert.