Bilder ohne Warten — und der Beweis, dass der Mac die Leiste selbst zeichnet

Der Schriftzug hing weiter nicht an der Animation, und der Zwischenspeicher war
nicht schuld: er lag im Actor, und ein `await` kostet immer mindestens einen
Durchlauf. Das neue Bild erschien deshalb im ersten Bild ohne Schriftzug und im
zweiten mit — er sprang also getrennt herein, obwohl er längst da war.

Der Speicher liegt jetzt außerhalb des Actors und lässt sich beim Zeichnen
lesen. Das gilt für alle Bilder: auch eine zurückscrollende Kachel blitzt nicht
mehr kurz grau auf.

Zur Seitenleiste auf dem Mac, gemessen statt vermutet: `automatic` löst sich
laut Apples eigener Beschreibung auf iOS zu `overlap` auf (die Leiste schwebt
über dem Inhalt) und auf macOS zu `tile` (sie schiebt ihn zur Seite und klebt
am Fensterrand). Der Griff dorthin sitzt richtig — derselbe Weg hat die Leiste
auf Kommando ganz ausgeblendet —, aber `overlap` wird auf dem Mac
stillschweigend verworfen. Die Bitte bleibt trotzdem stehen; sie kostet nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 17:40:46 +02:00
co-authored by Claude Opus 5
parent e5e01838f7
commit e7de8a9055
7 changed files with 183 additions and 19 deletions
+11
View File
@@ -170,6 +170,13 @@ struct RootView: View {
/// ausfahrbares Blatt, eine geteilte Ansicht und eine Reihe oben. Sie
/// konnten zusammen weniger als das eine Bauteil und mussten dreimal
/// gepflegt werden.
/// Läuft diese App gerade auf einem Mac?
///
/// Nicht über `#if` zu haben: „Designed for iPad" **ist** der iOS-Bau,
/// unverändert, nur auf anderer Hardware. Erst zur Laufzeit ist der
/// Unterschied sichtbar.
static let isOnMac = ProcessInfo.processInfo.isMacCatalystApp
private var library: some View {
TabView(selection: $selection) {
Tab("sidebar.home", systemImage: "house", value: SidebarDestination.home) {
@@ -241,6 +248,10 @@ struct RootView: View {
}
.tabViewStyle(.sidebarAdaptable)
.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.
.background { SidebarOverlapRequest().frame(width: 0, height: 0) }
// Das Konto sitzt unten in der Seitenleiste, nicht als Eintrag
// zwischen den Mediatheken. Dort sucht man es.
.tabViewSidebarBottomBar {
@@ -0,0 +1,68 @@
#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
+29
View File
@@ -29,6 +29,35 @@ final class LayoutSnapshots: XCTestCase {
try? data.write(to: folder.appendingPathComponent("\(name).png"))
}
/// Das Umschalten zwischen Seitenleiste und Leiste oben.
///
/// Beim Umschalten steht die Leiste kurz falsch, bevor sie sich fängt.
/// Fotografiert wird in Serie, weil genau der Zwischenzustand gesucht ist
/// und ein einzelnes Bild danach nichts zeigt.
func testSidebarToggle() throws {
let app = XCUIApplication()
app.launchArguments = ["-FlixyStubServer", "http://127.0.0.1:8096"]
app.launch()
XCUIDevice.shared.orientation = .landscapeLeft
XCTAssertTrue(app.staticTexts["Weiterschauen"].waitForExistence(timeout: 20))
Thread.sleep(forTimeInterval: 2)
shoot(app, "10-seitenleiste")
// Der Knopf oben rechts in der Leiste schaltet um. Er trägt keine
// übersetzte Beschriftung, also über seine Stellung gesucht.
let toggle = app.buttons.element(boundBy: 1)
XCTAssertTrue(toggle.exists)
toggle.tap()
for step in 0..<8 {
shoot(app, String(format: "11-umschalten-%d", step))
Thread.sleep(forTimeInterval: 0.1)
}
Thread.sleep(forTimeInterval: 2)
shoot(app, "12-oben")
}
func testHomeAndDetail() throws {
let app = XCUIApplication()
app.launchArguments = ["-FlixyStubServer", "http://127.0.0.1:8096"]
@@ -27,7 +27,16 @@ public actor ArtworkLoader {
public static let shared = ArtworkLoader()
private let session: URLSession
private let cache = NSCache<NSURL, PlatformImage>()
/// Der Speicher liegt **außerhalb** des Actors, damit er sich ohne
/// `await` lesen lässt.
///
/// Das klingt nach einer Kleinigkeit und ist der ganze Punkt: ein `await`
/// kostet immer mindestens einen Durchlauf, und genau der war beim
/// Überblenden des Aufmachers zu sehen. Das neue Bild erschien im ersten
/// Bild noch ohne Schriftzug, im zweiten mit — der Schriftzug sprang also
/// getrennt herein, obwohl er längst da war. Wer das Bild schon hat, soll
/// es im selben Augenblick zeigen können.
private let cache = ImageMemoryCache()
/// Läuft eine Anfrage schon, wird sie geteilt statt ein zweites Mal
/// gestellt. Ohne das lädt eine Reihe, die dasselbe Plakat zweimal zeigt,
/// es auch zweimal.
@@ -49,8 +58,15 @@ public actor ArtworkLoader {
cache.countLimit = 400
}
/// Das Bild, wenn es schon im Arbeitsspeicher liegt — ohne Warten.
///
/// `nonisolated`, damit eine Ansicht es beim Zeichnen aufrufen kann.
public nonisolated static func cachedImage(for url: URL) -> PlatformImage? {
shared.cache.image(for: url)
}
public func image(for url: URL) async -> PlatformImage? {
if let cached = cache.object(forKey: url as NSURL) { return cached }
if let cached = cache.image(for: url) { return cached }
if let running = inFlight[url] { return await running.value }
let task = Task<PlatformImage?, Never> { [session] in
@@ -65,16 +81,33 @@ public actor ArtworkLoader {
let image = await task.value
inFlight[url] = nil
if let image { cache.setObject(image, forKey: url as NSURL) }
if let image { cache.store(image, for: url) }
return image
}
/// Das Bild, wenn es schon im Arbeitsspeicher liegt.
}
/// Ein Bildspeicher, der sich von überall lesen lässt.
///
/// Damit eine Kachel, die zum zweiten Mal erscheint, nicht erst kurz grau
/// aufblitzt — das ist beim Scrollen der sichtbarste Unterschied.
public func cached(_ url: URL) -> PlatformImage? {
cache.object(forKey: url as NSURL)
/// `NSCache` ist laut Apples Dokumentation von sich aus für den Zugriff aus
/// mehreren Strängen gebaut — der Compiler weiß das nur nicht. Deshalb hier
/// von Hand versichert statt den Speicher in einen Actor zu sperren, was ihn
/// genau um die Eigenschaft brächte, für die er hier steht: ohne Warten
/// lesbar zu sein.
private final class ImageMemoryCache: @unchecked Sendable {
private let storage = NSCache<NSURL, PlatformImage>()
var countLimit: Int {
get { storage.countLimit }
set { storage.countLimit = newValue }
}
func image(for url: URL) -> PlatformImage? {
storage.object(forKey: url as NSURL)
}
func store(_ image: PlatformImage, for url: URL) {
storage.setObject(image, forKey: url as NSURL)
}
}
@@ -45,9 +45,21 @@ public struct FlixyArtwork: View {
.task(id: url) { await load() }
}
/// Das Bild, das jetzt gezeigt werden kann.
///
/// Der Blick in den Speicher steht **hier**, nicht in `load()`: eine
/// Kachel, die zum zweiten Mal erscheint, zeigt ihr Bild damit schon im
/// ersten Bild und blitzt nicht kurz grau auf. Über ein `await` ginge das
/// nicht — das kostet immer einen Durchlauf.
private var available: PlatformImage? {
if let loaded { return loaded }
guard let url else { return nil }
return ArtworkLoader.cachedImage(for: url)
}
@ViewBuilder
private var content: some View {
if let loaded {
if let loaded = available {
switch contentMode {
case .fill: Image(platform: loaded).resizable().scaledToFill()
case .fit: Image(platform: loaded).resizable().scaledToFit()
@@ -70,10 +82,9 @@ public struct FlixyArtwork: View {
return
}
// Liegt es schon im Arbeitsspeicher, gibt es kein Aufblitzen: ohne
// diesen Schritt wäre jede zurückscrollende Kachel kurz grau.
if let cached = await ArtworkLoader.shared.cached(url) {
loaded = cached
// Was schon da ist, zeigt `available` bereits — hier bleibt nur das
// Nachladen.
if ArtworkLoader.cachedImage(for: url) != nil {
hasFailed = false
return
}
@@ -72,7 +72,7 @@ public struct HeroBanner<Actions: View>: View {
@ViewBuilder
private func titleView(scale: CGFloat) -> some View {
Group {
if let logo {
if let logo = availableLogo {
Image(platform: logo)
.resizable()
.scaledToFit()
@@ -89,14 +89,22 @@ public struct HeroBanner<Actions: View>: View {
}
}
.task(id: logoURL) {
guard let logoURL else {
logo = nil
return
}
guard let logoURL, ArtworkLoader.cachedImage(for: logoURL) == nil else { return }
logo = await ArtworkLoader.shared.image(for: logoURL)
}
}
/// Der Schriftzug, wenn er **jetzt** gezeigt werden kann.
///
/// Das Karussell holt ihn vor; liegt er bereit, steht er schon im ersten
/// Bild des Übergangs da. Nur wenn nicht, wird nachgeladen — und dann
/// steht bis dahin der gesetzte Titel.
private var availableLogo: PlatformImage? {
if let logo { return logo }
guard let logoURL else { return nil }
return ArtworkLoader.cachedImage(for: logoURL)
}
private func plainTitle(scale: CGFloat) -> some View {
Text(title)
.font(.system(size: 34 * scale, weight: .bold))
+4
View File
@@ -9,6 +9,9 @@ import Foundation
// PyObjC nicht mehr mit, und `screencapture` kennt nur Kennungen, keine Namen.
let name = CommandLine.arguments.dropFirst().first ?? "Flixy"
/// Zweites Argument: die Prozesskennung. Nötig, sobald zwei Bauarten
/// desselben Programms gleichzeitig laufen — sonst trifft es die falsche.
let wantedPID = CommandLine.arguments.dropFirst(2).first.flatMap(Int.init)
guard let windows = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as? [[String: Any]] else {
FileHandle.standardError.write(Data("Fensterliste nicht lesbar\n".utf8))
@@ -19,6 +22,7 @@ let candidates = windows.filter { window in
guard let owner = window[kCGWindowOwnerName as String] as? String, owner == name else { return false }
// Ebene 0 ist das normale Fenster; alles darüber sind Menüs und Schatten.
guard (window[kCGWindowLayer as String] as? Int) == 0 else { return false }
if let wantedPID, (window[kCGWindowOwnerPID as String] as? Int) != wantedPID { return false }
return true
}