| 1 | import SwiftUI |
| 2 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| 3 | import AppKit |
| 4 | #else |
| 5 | import UIKit |
| 6 | #endif |
| 7 | |
| 8 | // π€ A view's live window frame, read on demand β the lightbox zooms out from |
| 9 | // it (Mac & iOS), and the Mac's swipe / window-drag monitors test hit points |
| 10 | // against it. Every consumer reads at tap/event time; NOTHING needs a |
| 11 | // continuously-updated value. |
| 12 | // |
| 13 | // The frame must NOT be tracked with a GeometryReader + `onChange(of:)`: that's |
| 14 | // a continuous observation of a value that changes on EVERY layout pass. A chat |
| 15 | // open runs several passes per displayed frame (scroll-settle pin, avatar |
| 16 | // spring, composer inset, keyboard) and β on macOS β a window maximise/restore |
| 17 | // animates the window frame across many passes, so SwiftUI flags |
| 18 | // "onChange(of: CGRect) action tried to update multiple times per frame": real |
| 19 | // main-thread layout churn (the #580 stall on iOS; the #899 resize churn on |
| 20 | // Mac). Moving the write off @State was NOT enough β the loop wasn't |
| 21 | // self-invalidation, it's that we were observing a high-frequency value at all. |
| 22 | // |
| 23 | // So nothing observes anything on either platform: a passive backing view sits |
| 24 | // in the tracked view's background and `rect` asks IT for its window frame only |
| 25 | // at the moment it's needed. The holder stays a plain reference so no write |
| 26 | // ever invalidates the view. Only ever touched on the main actor (SwiftUI view |
| 27 | // code / AppKit event monitors), hence @MainActor. |
| 28 | @MainActor |
| 29 | public final class LiveFrame { |
| 30 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| 31 | weak var anchor: NSView? |
| 32 | /// The anchor's frame in SwiftUI `.global` space. AppKit window coords are |
| 33 | /// bottom-left; SwiftUI `.global` is top-left β flip against the content |
| 34 | /// view's height, exactly as the swipe / drag monitors flip their event |
| 35 | /// points (`content.bounds.height - locationInWindow.y`). |
| 36 | public var rect: CGRect { |
| 37 | guard let anchor, let window = anchor.window else { return .zero } |
| 38 | let w = anchor.convert(anchor.bounds, to: nil) // window base coords (bottom-left) |
| 39 | let h = window.contentView?.bounds.height ?? window.frame.height |
| 40 | return CGRect(x: w.minX, y: h - w.maxY, width: w.width, height: w.height) |
| 41 | } |
| 42 | #else |
| 43 | weak var anchor: UIView? |
| 44 | public var rect: CGRect { |
| 45 | guard let anchor, anchor.window != nil else { return .zero } |
| 46 | return anchor.convert(anchor.bounds, to: nil) // window coords == SwiftUI .global (top-left) |
| 47 | } |
| 48 | #endif |
| 49 | |
| 50 | public init() {} |
| 51 | } |
| 52 | |
| 53 | public extension View { |
| 54 | /// Keep `holder.rect` answerable with this view's window frame, computed |
| 55 | /// lazily at read time via a passive backing view β no `onChange(of: CGRect)` |
| 56 | /// layout churn (see LiveFrame). |
| 57 | func trackingFrame(into holder: LiveFrame) -> some View { |
| 58 | background(FrameAnchor(holder: holder)) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | // The passive anchor: fills the tracked view (as its background), never draws, |
| 63 | // never participates in hit-testing β exists only so LiveFrame can convert its |
| 64 | // bounds to window coordinates on demand. |
| 65 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| 66 | private struct FrameAnchor: NSViewRepresentable { |
| 67 | let holder: LiveFrame |
| 68 | |
| 69 | func makeNSView(context: Context) -> NSView { |
| 70 | let v = PassiveView() |
| 71 | holder.anchor = v |
| 72 | return v |
| 73 | } |
| 74 | |
| 75 | func updateNSView(_ v: NSView, context: Context) { |
| 76 | holder.anchor = v |
| 77 | } |
| 78 | |
| 79 | // Transparent to clicks so the tracked content (and anything beneath it) |
| 80 | // keeps its taps β the same idiom as AnimatedGIFView's backing view. |
| 81 | private final class PassiveView: NSView { |
| 82 | override func hitTest(_ point: NSPoint) -> NSView? { nil } |
| 83 | } |
| 84 | } |
| 85 | #else |
| 86 | private struct FrameAnchor: UIViewRepresentable { |
| 87 | let holder: LiveFrame |
| 88 | |
| 89 | func makeUIView(context: Context) -> UIView { |
| 90 | let v = UIView() |
| 91 | v.isUserInteractionEnabled = false |
| 92 | v.backgroundColor = .clear |
| 93 | holder.anchor = v |
| 94 | return v |
| 95 | } |
| 96 | |
| 97 | func updateUIView(_ v: UIView, context: Context) { |
| 98 | holder.anchor = v |
| 99 | } |
| 100 | } |
| 101 | #endif |
| 102 | |