blob: 51299c8fe412213a0400639a3261f81b04ababc0 [file] [log] [blame]
// Copyright 2022 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import UIKit
/// Lets any UIView communicate when its coordinates in its window change.
///
/// This is useful to know when a view moved on the screen, even when it didn't change frame in its
/// own parent.
///
/// To get notified when the view moved in its window:
///
/// let myView = UIView()
/// myView.cr_onWindowCoordinatesChanged = { view in
/// // Print the window coordinates.
/// print("\(view) moved to \(view convertRect:view.bounds toView:nil)")
/// }
/// let parentView = UIView()
/// parentView.addSubview(myView)
/// let window = UIWindow()
/// window.addSubview(parentView) // → Calls the closure a first time.
/// parentView.frame = CGRect(x: 10, y: 20, width: 30, height: 40) // → Calls the closure.
///
/// Even though `myView`'s frame itself was not modified in `parentView`, the closure is called, as
/// actually, `myView` moved transitively in its window.
///
extension UIView {
/// MARK: Public
/// Called when the window coordinates of the view changed.
///
/// The view is passed as argument to the closure. Use it to avoid retaining the view in the
/// closure, otherwise the view will leak and never get deinitialized.
@objc var cr_onWindowCoordinatesChanged: ((UIView) -> Void)? {
get {
objc_getAssociatedObject(self, &UIView.OnWindowCoordinatesChangedKey) as? (UIView) -> Void
}
set {
objc_setAssociatedObject(
self, &UIView.OnWindowCoordinatesChangedKey, newValue, .OBJC_ASSOCIATION_COPY)
if newValue != nil {
// Make sure UIView supports window observing.
Self.cr_supportsWindowObserving = true
observation = observe(\.window, options: [.initial]) { [weak self] _, _ in
guard let self = self else { return }
if self.window != nil {
self.addMirrorViewInWindow()
// Additionally, call the closure here as the view moved to a window.
self.cr_onWindowCoordinatesChanged?(self)
} else {
self.removeMirrorViewInWindow()
}
}
} else {
observation = nil
}
}
}
/// MARK: Private
/// The currently set observation of the window property.
private var observation: NSKeyValueObservation? {
get {
objc_getAssociatedObject(self, &UIView.ObservationKey) as? NSKeyValueObservation
}
set {
objc_setAssociatedObject(
self, &UIView.ObservationKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
/// Inserts a direct subview to the receiver's window, with constraints such that the mirror view
/// always has the same window coordinates as the receiver. The mirror view calls the
/// `onWindowCoordinatesChanged` closure when its bounds change.
private func addMirrorViewInWindow() {
let mirrorViewInWindow = NotifyingView()
mirrorViewInWindow.backgroundColor = .clear
mirrorViewInWindow.isUserInteractionEnabled = false
mirrorViewInWindow.translatesAutoresizingMaskIntoConstraints = false
mirrorViewInWindow.onLayoutChanged = { [weak self] _ in
// Callback on the next turn of the run loop to wait for AutoLayout to have updated the entire
// hierarchy. (It can happen that AutoLayout updates the mirror view before the mirrored
// view.)
DispatchQueue.main.async {
guard let self = self else { return }
self.cr_onWindowCoordinatesChanged?(self)
}
}
guard let window = window else { fatalError() }
window.insertSubview(mirrorViewInWindow, at: 0)
NSLayoutConstraint.activate([
mirrorViewInWindow.topAnchor.constraint(equalTo: topAnchor),
mirrorViewInWindow.bottomAnchor.constraint(equalTo: bottomAnchor),
mirrorViewInWindow.leadingAnchor.constraint(equalTo: leadingAnchor),
mirrorViewInWindow.trailingAnchor.constraint(equalTo: trailingAnchor),
])
self.mirrorViewInWindow = mirrorViewInWindow
}
/// Removes the mirror view added by a call to `addMirrorViewInWindow`.
private func removeMirrorViewInWindow() {
mirrorViewInWindow?.onLayoutChanged = nil
mirrorViewInWindow?.removeFromSuperview()
mirrorViewInWindow = nil
}
/// The currently set mirror view.
private var mirrorViewInWindow: NotifyingView? {
get {
objc_getAssociatedObject(self, &UIView.MirrorViewInWindowKey) as? NotifyingView
}
set {
objc_setAssociatedObject(
self, &UIView.MirrorViewInWindowKey, newValue, .OBJC_ASSOCIATION_ASSIGN)
}
}
/// A simple view that calls a closure when its bounds and center changed.
private class NotifyingView: UIView {
var onLayoutChanged: ((UIView) -> Void)?
override var bounds: CGRect {
didSet {
onLayoutChanged?(self)
}
}
override var center: CGPoint {
didSet {
onLayoutChanged?(self)
}
}
}
/// Keys for storing associated objects.
private static var OnWindowCoordinatesChangedKey = ""
private static var ObservationKey = ""
private static var MirrorViewInWindowKey = ""
}