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:
co-authored by
Claude Opus 5
parent
e5e01838f7
commit
e7de8a9055
@@ -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,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.
|
||||
///
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Ein Bildspeicher, der sich von überall lesen lässt.
|
||||
///
|
||||
/// `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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user