From 425b6653654035255f755619b70c392835e21b78 Mon Sep 17 00:00:00 2001 From: Ryota matsumoto Date: Tue, 2 Jun 2026 12:38:16 +0900 Subject: [PATCH 1/2] Make floating display scene-aware (#192) ## What changed - Added `UIWindowScene`-aware initializers to `FloatingDisplayController` and `FloatingDisplayTarget` - Propagated scene/window context into `SafeAreaFinder` - Replaced `SafeAreaFinder.shared` usage with owner-local finder instances - Scoped safe-area notifications to the originating finder instance to avoid cross-scene mixing - Preserved the existing initializer path with fallback behavior for backward compatibility ## Why - The original implementation assumed a single active app scene - `FloatingDisplayTarget` selected a foreground scene implicitly - safe-area lookup could resolve the wrong window because scene context was not passed through - this breaks multi-scene apps ## Context from the request - Pass the target scene into `FloatingDisplayController` initialization - Fix the logic that effectively behaved like a single-app assumption via `.foregroundActive` - Make safe-area resolution scene-aware without relying on a shared singleton instance --- Sources/FluidCore/SafeAreaFinder.swift | 46 ++++++++++-- .../FluidPictureInPictureController.swift | 10 ++- .../FloatingDisplayController.swift | 11 +++ .../FluidSnackbar/FloatingDisplayTarget.swift | 74 +++++++++++++++---- 4 files changed, 120 insertions(+), 21 deletions(-) diff --git a/Sources/FluidCore/SafeAreaFinder.swift b/Sources/FluidCore/SafeAreaFinder.swift index 2c14d725e..a6b65e82d 100644 --- a/Sources/FluidCore/SafeAreaFinder.swift +++ b/Sources/FluidCore/SafeAreaFinder.swift @@ -12,9 +12,11 @@ import UIKit public final class SafeAreaFinder: NSObject { public static let notificationName = Notification.Name(rawValue: "app.muukii.fluid.SafeAreaInsetsManager") - - @MainActor - public static let shared = SafeAreaFinder() + + @available(iOS 13.0, *) + public weak var windowScene: UIWindowScene? + + public weak var window: UIWindow? private var currentInsets: UIEdgeInsets? = nil @@ -30,10 +32,31 @@ public final class SafeAreaFinder: NSObject { private nonisolated(unsafe) var currentDisplayLink: CADisplayLink! - private override init() { + public override init() { + + super.init() + + setUpDisplayLink() + } + + @available(iOS 13.0, *) + public init(windowScene: UIWindowScene) { + self.windowScene = windowScene + + super.init() + + setUpDisplayLink() + } + + public init(window: UIWindow) { + self.window = window super.init() + setUpDisplayLink() + } + + private func setUpDisplayLink() { currentDisplayLink = .init(target: self, selector: #selector(handle)) currentDisplayLink.preferredFramesPerSecond = 1 currentDisplayLink.add(to: .main, forMode: .default) @@ -60,6 +83,19 @@ public final class SafeAreaFinder: NSObject { } @objc private dynamic func handle() { + if let window { + _handle(in: window) + return + } + + if #available(iOS 13.0, *), let windowScene { + guard let window = windowScene.windows.first(where: \.isKeyWindow) ?? windowScene.windows.first else { + return + } + _handle(in: window) + return + } + guard let window = UIApplication.shared.delegate?.window ?? nil else { return } @@ -134,7 +170,7 @@ public final class SafeAreaFinder: NSObject { if currentInsets != maximumInsets { currentInsets = maximumInsets - NotificationCenter.default.post(name: Self.notificationName, object: maximumInsets) + NotificationCenter.default.post(name: Self.notificationName, object: maximumInsets, userInfo: ["finder": self]) } } diff --git a/Sources/FluidPictureInPicture/FluidPictureInPictureController.swift b/Sources/FluidPictureInPicture/FluidPictureInPictureController.swift index d8e87850c..a1b618a6e 100644 --- a/Sources/FluidPictureInPicture/FluidPictureInPictureController.swift +++ b/Sources/FluidPictureInPicture/FluidPictureInPictureController.swift @@ -104,6 +104,7 @@ extension FluidPictureInPictureController { let containerView: ContainerView = .init() let sizeForFloating = CGSize(width: 100, height: 140) + let safeAreaFinder = SafeAreaFinder() private(set) var state: State = .init() { didSet { @@ -145,6 +146,7 @@ extension FluidPictureInPictureController { } @objc private func handleInsetsUpdate(notification: Notification) { + guard notification.userInfo?["finder"] as? SafeAreaFinder === safeAreaFinder else { return } let inset = notification.object as! UIEdgeInsets state.inset = inset setNeedsLayout() @@ -229,11 +231,13 @@ extension FluidPictureInPictureController { override func didMoveToWindow() { super.didMoveToWindow() - + + safeAreaFinder.window = window + if window != nil { - SafeAreaFinder.shared.start() + safeAreaFinder.start() } else { - SafeAreaFinder.shared.pause() + safeAreaFinder.pause() } } diff --git a/Sources/FluidSnackbar/FloatingDisplayController.swift b/Sources/FluidSnackbar/FloatingDisplayController.swift index 536234a5e..9ea00b332 100644 --- a/Sources/FluidSnackbar/FloatingDisplayController.swift +++ b/Sources/FluidSnackbar/FloatingDisplayController.swift @@ -37,6 +37,17 @@ open class FloatingDisplayController { self.displayTarget = .init(edgeTargetSafeArea: edgeTargetSafeArea) } + @available(iOS 13.0, *) + public init( + edgeTargetSafeArea: FloatingDisplayTarget.EdgeTargetSafeArea, + windowScene: UIWindowScene + ) { + self.displayTarget = .init( + edgeTargetSafeArea: edgeTargetSafeArea, + windowScene: windowScene + ) + } + // MARK: - Functions private func drain() { diff --git a/Sources/FluidSnackbar/FloatingDisplayTarget.swift b/Sources/FluidSnackbar/FloatingDisplayTarget.swift index 03a4fe0fc..a77801b66 100644 --- a/Sources/FluidSnackbar/FloatingDisplayTarget.swift +++ b/Sources/FluidSnackbar/FloatingDisplayTarget.swift @@ -48,6 +48,7 @@ public final class FloatingDisplayTarget { } private let notificationWindow: NotificationWindow + private let safeAreaFinder: SafeAreaFinder private let notificationViewController: NotificationViewController public var additionalSafeAreaInsets: UIEdgeInsets { @@ -78,24 +79,35 @@ public final class FloatingDisplayTarget { public init(edgeTargetSafeArea: EdgeTargetSafeArea) { - self.notificationViewController = .init(edgeTargetSafeArea: edgeTargetSafeArea) - if #available(iOS 13, *) { - let windowScene = UIApplication.shared .connectedScenes .lazy - .filter { $0.activationState == .foregroundActive } - .compactMap { $0 as? UIWindowScene } + .filter({ $0.activationState == .foregroundActive }) + .compactMap({ $0 as? UIWindowScene }) .first - - if let windowScene = windowScene { + if let windowScene { + self.safeAreaFinder = .init(windowScene: windowScene) + self.notificationViewController = .init( + edgeTargetSafeArea: edgeTargetSafeArea, + safeAreaFinder: safeAreaFinder + ) notificationWindow = .init(windowScene: windowScene) } else { + self.safeAreaFinder = .init() + self.notificationViewController = .init( + edgeTargetSafeArea: edgeTargetSafeArea, + safeAreaFinder: safeAreaFinder + ) notificationWindow = .init(frame: .zero) } } else { + self.safeAreaFinder = .init() + self.notificationViewController = .init( + edgeTargetSafeArea: edgeTargetSafeArea, + safeAreaFinder: safeAreaFinder + ) notificationWindow = .init(frame: .zero) } @@ -108,6 +120,28 @@ public final class FloatingDisplayTarget { notificationViewController.endAppearanceTransition() } + @available(iOS 13.0, *) + public init( + edgeTargetSafeArea: EdgeTargetSafeArea, + windowScene: UIWindowScene + ) { + + self.safeAreaFinder = .init(windowScene: windowScene) + self.notificationViewController = .init( + edgeTargetSafeArea: edgeTargetSafeArea, + safeAreaFinder: safeAreaFinder + ) + self.notificationWindow = .init(windowScene: windowScene) + + notificationWindow.windowLevel = UIWindow.Level(rawValue: 5) + notificationWindow.isHidden = true + notificationWindow.backgroundColor = UIColor.clear + notificationWindow.frame = UIScreen.main.bounds + notificationViewController.beginAppearanceTransition(true, animated: false) + notificationWindow.rootViewController = notificationViewController + notificationViewController.endAppearanceTransition() + } + deinit { Task { @MainActor [notificationWindow] in notificationWindow.isHidden = false @@ -149,9 +183,14 @@ extension FloatingDisplayTarget { fileprivate final class NotificationViewController: UIViewController { private let edgeTargetSafeArea: EdgeTargetSafeArea + private let safeAreaFinder: SafeAreaFinder - init(edgeTargetSafeArea: EdgeTargetSafeArea) { + init( + edgeTargetSafeArea: EdgeTargetSafeArea, + safeAreaFinder: SafeAreaFinder + ) { self.edgeTargetSafeArea = edgeTargetSafeArea + self.safeAreaFinder = safeAreaFinder super.init(nibName: nil, bundle: nil) } @@ -160,7 +199,10 @@ extension FloatingDisplayTarget { } override fileprivate func loadView() { - view = View(edgeTargetSafeArea: edgeTargetSafeArea) + view = View( + edgeTargetSafeArea: edgeTargetSafeArea, + safeAreaFinder: safeAreaFinder + ) } override fileprivate func viewDidLoad() { @@ -172,6 +214,7 @@ extension FloatingDisplayTarget { fileprivate class View: UIView { private let edgeTargetSafeArea: EdgeTargetSafeArea + private let safeAreaFinder: SafeAreaFinder private var _safeAreaLayoutGuide: UILayoutGuide = .init() private var activeWindowSafeAreaLayoutGuideConstraintLeft: NSLayoutConstraint? @@ -181,9 +224,13 @@ extension FloatingDisplayTarget { private var hasSafeAreaFinderActivated: Bool = false - init(edgeTargetSafeArea: EdgeTargetSafeArea) { + init( + edgeTargetSafeArea: EdgeTargetSafeArea, + safeAreaFinder: SafeAreaFinder + ) { self.edgeTargetSafeArea = edgeTargetSafeArea + self.safeAreaFinder = safeAreaFinder super.init(frame: .zero) @@ -248,13 +295,14 @@ extension FloatingDisplayTarget { NotificationCenter.default.addObserver( self, selector: #selector(handleInsetsUpdate), name: SafeAreaFinder.notificationName, object: nil) - SafeAreaFinder.shared.start() + safeAreaFinder.start() } } @objc private func handleInsetsUpdate(notification: Notification) { guard hasSafeAreaFinderActivated else { return } + guard notification.userInfo?["finder"] as? SafeAreaFinder === safeAreaFinder else { return } let insets = notification.object as! UIEdgeInsets self.activeWindowSafeAreaLayoutGuideConstraintLeft?.constant = insets.left @@ -287,9 +335,9 @@ extension FloatingDisplayTarget { } deinit { - Task { @MainActor [hasSafeAreaFinderActivated] in + Task { @MainActor [hasSafeAreaFinderActivated, safeAreaFinder] in if hasSafeAreaFinderActivated { - SafeAreaFinder.shared.pause() + safeAreaFinder.pause() } } } From ae465675d97b8842894278b07a28e92bfdb36506 Mon Sep 17 00:00:00 2001 From: Muukii Date: Tue, 2 Jun 2026 17:06:29 +0900 Subject: [PATCH 2/2] Update --- Sources/FluidCore/SafeAreaFinder.swift | 80 +++++++---------- .../FluidPictureInPictureController.swift | 7 +- .../FloatingDisplayController.swift | 4 - .../FluidSnackbar/FloatingDisplayTarget.swift | 88 +++++++------------ 4 files changed, 69 insertions(+), 110 deletions(-) diff --git a/Sources/FluidCore/SafeAreaFinder.swift b/Sources/FluidCore/SafeAreaFinder.swift index a6b65e82d..60b352d13 100644 --- a/Sources/FluidCore/SafeAreaFinder.swift +++ b/Sources/FluidCore/SafeAreaFinder.swift @@ -16,51 +16,28 @@ public final class SafeAreaFinder: NSObject { @available(iOS 13.0, *) public weak var windowScene: UIWindowScene? - public weak var window: UIWindow? - private var currentInsets: UIEdgeInsets? = nil - private var referenceCounter: Int = 0 { - didSet { - if referenceCounter > 0 { - currentDisplayLink.isPaused = false - } else { - currentDisplayLink.isPaused = true - } - } - } - - private nonisolated(unsafe) var currentDisplayLink: CADisplayLink! + private var isRunning: Bool = false - public override init() { - - super.init() - - setUpDisplayLink() - } + private nonisolated(unsafe) var currentDisplayLink: CADisplayLink? @available(iOS 13.0, *) - public init(windowScene: UIWindowScene) { + public init(windowScene: UIWindowScene?) { self.windowScene = windowScene super.init() - - setUpDisplayLink() - } - - public init(window: UIWindow) { - self.window = window - - super.init() - - setUpDisplayLink() } private func setUpDisplayLink() { + guard currentDisplayLink == nil else { + return + } + currentDisplayLink = .init(target: self, selector: #selector(handle)) - currentDisplayLink.preferredFramesPerSecond = 1 - currentDisplayLink.add(to: .main, forMode: .default) - currentDisplayLink.isPaused = true + currentDisplayLink?.preferredFramesPerSecond = 1 + currentDisplayLink?.add(to: .main, forMode: .default) + currentDisplayLink?.isPaused = false } public func request() { @@ -69,36 +46,47 @@ public final class SafeAreaFinder: NSObject { } public func start() { - referenceCounter += 1 + guard isRunning == false else { + request() + return + } + + isRunning = true + setUpDisplayLink() request() } + /// Stops polling and releases the display link so the finder can deallocate when its owner goes away. + public func stop() { + guard isRunning || currentDisplayLink != nil else { + return + } + + isRunning = false + currentInsets = nil + currentDisplayLink?.invalidate() + currentDisplayLink = nil + } + + /// Stops polling. Kept as a compatibility alias for older callers that used the reference-counted API. public func pause() { - referenceCounter -= 1 + stop() } deinit { - currentDisplayLink?.isPaused = true currentDisplayLink?.invalidate() } @objc private dynamic func handle() { - if let window { - _handle(in: window) - return - } - if #available(iOS 13.0, *), let windowScene { - guard let window = windowScene.windows.first(where: \.isKeyWindow) ?? windowScene.windows.first else { - return - } - _handle(in: window) + guard let windowScene else { return } - guard let window = UIApplication.shared.delegate?.window ?? nil else { + guard let window = windowScene.windows.first(where: \.isKeyWindow) ?? windowScene.windows.first else { return } + _handle(in: window) } diff --git a/Sources/FluidPictureInPicture/FluidPictureInPictureController.swift b/Sources/FluidPictureInPicture/FluidPictureInPictureController.swift index a1b618a6e..10c618f24 100644 --- a/Sources/FluidPictureInPicture/FluidPictureInPictureController.swift +++ b/Sources/FluidPictureInPicture/FluidPictureInPictureController.swift @@ -104,7 +104,7 @@ extension FluidPictureInPictureController { let containerView: ContainerView = .init() let sizeForFloating = CGSize(width: 100, height: 140) - let safeAreaFinder = SafeAreaFinder() + let safeAreaFinder: SafeAreaFinder private(set) var state: State = .init() { didSet { @@ -124,6 +124,9 @@ extension FluidPictureInPictureController { override init( frame: CGRect ) { + + self.safeAreaFinder = .init(windowScene: nil) + super.init(frame: frame) let dragGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture)) @@ -232,7 +235,7 @@ extension FluidPictureInPictureController { override func didMoveToWindow() { super.didMoveToWindow() - safeAreaFinder.window = window + safeAreaFinder.windowScene = window?.windowScene if window != nil { safeAreaFinder.start() diff --git a/Sources/FluidSnackbar/FloatingDisplayController.swift b/Sources/FluidSnackbar/FloatingDisplayController.swift index 9ea00b332..5e4841922 100644 --- a/Sources/FluidSnackbar/FloatingDisplayController.swift +++ b/Sources/FluidSnackbar/FloatingDisplayController.swift @@ -33,10 +33,6 @@ open class FloatingDisplayController { // MARK: - Initializers - public init(edgeTargetSafeArea: FloatingDisplayTarget.EdgeTargetSafeArea) { - self.displayTarget = .init(edgeTargetSafeArea: edgeTargetSafeArea) - } - @available(iOS 13.0, *) public init( edgeTargetSafeArea: FloatingDisplayTarget.EdgeTargetSafeArea, diff --git a/Sources/FluidSnackbar/FloatingDisplayTarget.swift b/Sources/FluidSnackbar/FloatingDisplayTarget.swift index a77801b66..f08169d22 100644 --- a/Sources/FluidSnackbar/FloatingDisplayTarget.swift +++ b/Sources/FluidSnackbar/FloatingDisplayTarget.swift @@ -40,9 +40,16 @@ public final class FloatingDisplayTarget { left: .activeWindow ) } + + fileprivate var containsActiveWindowEdge: Bool { + top == .activeWindow + || right == .activeWindow + || bottom == .activeWindow + || left == .activeWindow + } } - public enum TargetSafeArea { + public enum TargetSafeArea: Equatable { case notificationWindow case activeWindow } @@ -70,54 +77,17 @@ public final class FloatingDisplayTarget { } public func makeWindowVisible() { + _ = notificationViewController.view notificationWindow.isHidden = false - } - public func hideWindow() { - notificationWindow.isHidden = true - } - - public init(edgeTargetSafeArea: EdgeTargetSafeArea) { - - if #available(iOS 13, *) { - let windowScene = UIApplication.shared - .connectedScenes - .lazy - .filter({ $0.activationState == .foregroundActive }) - .compactMap({ $0 as? UIWindowScene }) - .first - if let windowScene { - self.safeAreaFinder = .init(windowScene: windowScene) - self.notificationViewController = .init( - edgeTargetSafeArea: edgeTargetSafeArea, - safeAreaFinder: safeAreaFinder - ) - notificationWindow = .init(windowScene: windowScene) - } else { - self.safeAreaFinder = .init() - self.notificationViewController = .init( - edgeTargetSafeArea: edgeTargetSafeArea, - safeAreaFinder: safeAreaFinder - ) - notificationWindow = .init(frame: .zero) - } - - } else { - self.safeAreaFinder = .init() - self.notificationViewController = .init( - edgeTargetSafeArea: edgeTargetSafeArea, - safeAreaFinder: safeAreaFinder - ) - notificationWindow = .init(frame: .zero) + if notificationViewController.needsActiveWindowSafeArea { + safeAreaFinder.start() } + } - notificationWindow.windowLevel = UIWindow.Level(rawValue: 5) + public func hideWindow() { notificationWindow.isHidden = true - notificationWindow.backgroundColor = UIColor.clear - notificationWindow.frame = UIScreen.main.bounds - notificationViewController.beginAppearanceTransition(true, animated: false) - notificationWindow.rootViewController = notificationViewController - notificationViewController.endAppearanceTransition() + safeAreaFinder.stop() } @available(iOS 13.0, *) @@ -142,11 +112,11 @@ public final class FloatingDisplayTarget { notificationViewController.endAppearanceTransition() } - deinit { - Task { @MainActor [notificationWindow] in + deinit { + Task { @MainActor [safeAreaFinder, notificationWindow] in + safeAreaFinder.stop() notificationWindow.isHidden = false } - } } @@ -185,6 +155,10 @@ extension FloatingDisplayTarget { private let edgeTargetSafeArea: EdgeTargetSafeArea private let safeAreaFinder: SafeAreaFinder + fileprivate var needsActiveWindowSafeArea: Bool { + edgeTargetSafeArea.containsActiveWindowEdge + } + init( edgeTargetSafeArea: EdgeTargetSafeArea, safeAreaFinder: SafeAreaFinder @@ -236,7 +210,7 @@ extension FloatingDisplayTarget { addLayoutGuide(_safeAreaLayoutGuide) - var containsActievWindowSafeAreaEdge: Bool = false + var containsActiveWindowSafeAreaEdge: Bool = false switch edgeTargetSafeArea.top { case .notificationWindow: @@ -245,7 +219,7 @@ extension FloatingDisplayTarget { case .activeWindow: activeWindowSafeAreaLayoutGuideConstraintTop = topAnchor.constraint( equalTo: _safeAreaLayoutGuide.topAnchor) - containsActievWindowSafeAreaEdge = true + containsActiveWindowSafeAreaEdge = true } switch edgeTargetSafeArea.right { @@ -256,7 +230,7 @@ extension FloatingDisplayTarget { case .activeWindow: activeWindowSafeAreaLayoutGuideConstraintRight = rightAnchor.constraint( equalTo: _safeAreaLayoutGuide.rightAnchor) - containsActievWindowSafeAreaEdge = true + containsActiveWindowSafeAreaEdge = true } switch edgeTargetSafeArea.bottom { @@ -267,7 +241,7 @@ extension FloatingDisplayTarget { case .activeWindow: activeWindowSafeAreaLayoutGuideConstraintBottom = bottomAnchor.constraint( equalTo: _safeAreaLayoutGuide.bottomAnchor) - containsActievWindowSafeAreaEdge = true + containsActiveWindowSafeAreaEdge = true } switch edgeTargetSafeArea.left { @@ -277,10 +251,10 @@ extension FloatingDisplayTarget { case .activeWindow: activeWindowSafeAreaLayoutGuideConstraintLeft = leftAnchor.constraint( equalTo: _safeAreaLayoutGuide.leftAnchor) - containsActievWindowSafeAreaEdge = true + containsActiveWindowSafeAreaEdge = true } - if containsActievWindowSafeAreaEdge { + if containsActiveWindowSafeAreaEdge { NSLayoutConstraint.activate( [ @@ -295,7 +269,6 @@ extension FloatingDisplayTarget { NotificationCenter.default.addObserver( self, selector: #selector(handleInsetsUpdate), name: SafeAreaFinder.notificationName, object: nil) - safeAreaFinder.start() } } @@ -335,10 +308,9 @@ extension FloatingDisplayTarget { } deinit { - Task { @MainActor [hasSafeAreaFinderActivated, safeAreaFinder] in - if hasSafeAreaFinderActivated { - safeAreaFinder.pause() - } + NotificationCenter.default.removeObserver(self) + Task { @MainActor [safeAreaFinder] in + safeAreaFinder.stop() } } }