Created
November 12, 2025 14:40
-
-
Save SoundBlaster/7a0fd48512f357907525f1bdca49504d to your computer and use it in GitHub Desktop.
Trying to sync sublayer and UIView layer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import UIKit | |
| final class SynchronizedBoundsAction: NSObject, CAAction { | |
| private let wrappedAction: CAAction? | |
| private weak var targetLayer: MyLayer? | |
| private weak var sublayer: CALayer? | |
| private var displayLink: CADisplayLink? | |
| private var startTime: CFTimeInterval = 0 | |
| private var duration: CFTimeInterval = 0 | |
| private var eventKey: String = "" | |
| init(wrapping action: CAAction?) { | |
| self.wrappedAction = action | |
| super.init() | |
| } | |
| func run(forKey event: String, object anObject: Any, arguments dict: [AnyHashable : Any]?) { | |
| wrappedAction?.run(forKey: event, object: anObject, arguments: dict) | |
| guard let layer = anObject as? MySublayer else { return } | |
| guard let superlayer = layer.superlayer as? MyLayer else { return } | |
| targetLayer = superlayer | |
| sublayer = layer | |
| eventKey = event | |
| startTime = CACurrentMediaTime() | |
| duration = resolveDuration(for: layer, event: event) | |
| syncSublayer() | |
| guard duration > 0 else { return } | |
| stopDisplayLink() | |
| let link = CADisplayLink(target: self, selector: #selector(updateLayers)) | |
| link.add(to: .main, forMode: .common) | |
| link.preferredFrameRateRange = CAFrameRateRange(minimum: 120, maximum: 120, preferred: 120) | |
| displayLink = link | |
| } | |
| @objc private func updateLayers() { | |
| guard let layer = targetLayer else { | |
| stopDisplayLink() | |
| return | |
| } | |
| syncSublayer() | |
| let elapsed = CACurrentMediaTime() - startTime | |
| if elapsed >= duration || !hasActiveBoundsAnimation(on: layer) { | |
| syncSublayer() | |
| stopDisplayLink() | |
| } | |
| } | |
| private func syncSublayer() { | |
| guard | |
| let layer = targetLayer, | |
| let sublayer = sublayer | |
| else { return } | |
| let presentationBounds = layer.presentation()?.bounds ?? layer.bounds | |
| CATransaction.begin() | |
| CATransaction.setDisableActions(true) | |
| print("sync \(presentationBounds)") | |
| sublayer.bounds = CGRect(origin: .zero, size: presentationBounds.size) | |
| CATransaction.commit() | |
| } | |
| private func resolveDuration(for layer: CALayer, event: String) -> CFTimeInterval { | |
| let candidateKeys = [event, "bounds", "bounds.size", "bounds.origin"] | |
| for key in candidateKeys { | |
| if let animation = layer.animation(forKey: key) { | |
| if let springAnimation = animation as? CASpringAnimation { | |
| return springAnimation.perceptualDuration | |
| // return springAnimation.settlingDuration | |
| } | |
| if animation.duration > 0 { | |
| return animation.duration | |
| } | |
| } | |
| } | |
| let transactionDuration = CATransaction.animationDuration() | |
| return transactionDuration > 0 ? transactionDuration : 0 | |
| } | |
| private func hasActiveBoundsAnimation(on layer: CALayer) -> Bool { | |
| let candidateKeys = [eventKey, "bounds", "bounds.size", "bounds.origin"] | |
| return candidateKeys.contains { key in | |
| guard !key.isEmpty else { return false } | |
| return layer.animation(forKey: key) != nil | |
| } | |
| } | |
| private func stopDisplayLink() { | |
| displayLink?.invalidate() | |
| displayLink = nil | |
| } | |
| deinit { | |
| stopDisplayLink() | |
| } | |
| } | |
| class SublayerDelegate: NSObject, CALayerDelegate { | |
| func animationFromSuperlayer(of layer: CALayer, forKey event: String) -> CAAnimation? { | |
| guard let superlayer = layer.superlayer else { return nil } | |
| guard let firstAnimationKey = superlayer.animationKeys()?.first else { return nil } | |
| guard let animationForKey = superlayer.animation(forKey: event) else { | |
| // print("superlayer.animationKeys \(superlayer.animationKeys())") | |
| return superlayer.animation(forKey: firstAnimationKey) | |
| } | |
| return animationForKey | |
| } | |
| func action(for layer: CALayer, forKey event: String) -> (any CAAction)? { | |
| if event == "bounds" { | |
| return SynchronizedBoundsAction(wrapping: nil) | |
| } | |
| return NSNull() | |
| } | |
| func _action(for layer: CALayer, forKey event: String) -> (any CAAction)? { | |
| print("------- \(event)") | |
| layer.superlayer?.animationKeys()?.forEach({ print($0) }) | |
| print("-------") | |
| if ["bounds", "position"].contains(event) { | |
| guard let superAnimation = animationFromSuperlayer(of: layer, forKey: event) else { | |
| return NSNull() | |
| } | |
| if let springAnimation = superAnimation as? CASpringAnimation { | |
| if //event == "bounds" || event == "position", | |
| let copy = springAnimation.copy() as? CASpringAnimation { | |
| if event == "position2" { | |
| copy.keyPath = event | |
| let toSize = layer.model().value(forKeyPath: "bounds.size") | |
| let fromSize = layer.presentation()?.value(forKeyPath: "bounds.size") | |
| let diffSize = valueDiff(toSize, fromSize) | |
| if let diffSize = diffSize as? CGSize { | |
| print("\(event) super: \(fromSize) -> \(toSize) = \(diffSize)") | |
| copy.fromValue = CGSize.init(width: diffSize.width / 2, height: diffSize.height / 2) | |
| copy.toValue = CGPoint.zero | |
| } | |
| } else if event == "bounds" { | |
| let to = layer.model().value(forKeyPath: event) | |
| let from = layer.presentation()?.value(forKeyPath: event) | |
| let value = valueDiff(to, from) | |
| print("\(event) super: \(from) -> \(to) = \(value)") | |
| copy.fromValue = value | |
| copy.toValue = CGRect.zero | |
| } | |
| return copy | |
| } | |
| let animation = CASpringAnimation(perceptualDuration: springAnimation.perceptualDuration, bounce: springAnimation.bounce) | |
| animation.keyPath = event | |
| // это не работает для относительных анимаций с isAdditive=true! | |
| animation.fillMode = .both | |
| animation.isRemovedOnCompletion = true | |
| animation.isAdditive = springAnimation.isAdditive | |
| if animation.isAdditive { | |
| if event == "bounds" { | |
| let to = layer.superlayer?.model().value(forKeyPath: event) | |
| let from = layer.superlayer?.presentation()?.value(forKeyPath: event) | |
| let value = valueDiff(to, from) | |
| print("\(event) super: \(from) -> \(to) = \(value)") | |
| animation.fromValue = value | |
| animation.toValue = CGRect.zero | |
| } else if event == "position" { | |
| let toSize = layer.superlayer?.model().value(forKeyPath: "bounds.size") | |
| let fromSize = layer.superlayer?.presentation()?.value(forKeyPath: "bounds.size") | |
| let diffSize = valueDiff(toSize, fromSize) | |
| if let diffSize = diffSize as? CGSize { | |
| print("\(event) super: \(fromSize) -> \(toSize) = \(diffSize)") | |
| animation.fromValue = CGSize.init(width: diffSize.width / 2, height: diffSize.height / 2) | |
| animation.toValue = CGPoint.zero | |
| } | |
| } | |
| } else { | |
| animation.fromValue = layer.presentation()?.value(forKeyPath: event) | |
| } | |
| print("isAdditive: \(animation.isAdditive), fromValue: \(String(describing: animation.fromValue ?? "nil")), toValue: \(String(describing: animation.toValue ?? "nil"))") | |
| return animation | |
| } else if let basicAnimation = superAnimation as? CABasicAnimation { | |
| let animation = CABasicAnimation(keyPath: event) | |
| animation.duration = basicAnimation.duration | |
| animation.timingFunction = basicAnimation.timingFunction | |
| animation.fillMode = .both | |
| animation.isRemovedOnCompletion = true | |
| animation.isAdditive = basicAnimation.isAdditive | |
| if animation.isAdditive { | |
| if event == "bounds" { | |
| let to = layer.superlayer?.model().value(forKeyPath: event) | |
| let from = layer.superlayer?.presentation()?.value(forKeyPath: event) | |
| let value = valueDiff(to, from) | |
| print("\(event) super: \(from) -> \(to) = \(value)") | |
| animation.fromValue = value | |
| animation.toValue = CGRect.zero | |
| } else if event == "position" { | |
| let toSize = layer.superlayer?.model().value(forKeyPath: "bounds.size") | |
| let fromSize = layer.superlayer?.presentation()?.value(forKeyPath: "bounds.size") | |
| let diffSize = valueDiff(toSize, fromSize) | |
| if let diffSize = diffSize as? CGSize { | |
| print("\(event) super: \(fromSize) -> \(toSize) = \(diffSize)") | |
| animation.fromValue = CGSize.init(width: diffSize.width / 2, height: diffSize.height / 2) | |
| animation.toValue = CGPoint.zero | |
| } | |
| } | |
| } | |
| return animation | |
| } else { | |
| // not supported now | |
| } | |
| } | |
| return NSNull() | |
| } | |
| func valueDiff(_ from: Any?, _ to: Any?) -> Any? { | |
| guard let from, let to else { return nil } | |
| if let from = from as? CGSize, let to = to as? CGSize { | |
| return CGSize( | |
| width: to.width - from.width, | |
| height: to.height - from.height | |
| ) | |
| } else if let from = from as? CGPoint, let to = to as? CGPoint { | |
| return CGPoint( | |
| x: to.x - from.x, | |
| y: to.y - from.y | |
| ) | |
| } else if let from = from as? CGRect, let to = to as? CGRect { | |
| return CGRect( | |
| x: to.origin.x - from.origin.x, | |
| y: to.origin.y - from.origin.y, | |
| width: to.size.width - from.size.width, | |
| height: to.size.height - from.size.height | |
| ) | |
| } | |
| return nil | |
| } | |
| func directed(_ value: Any?, for direction: CGFloat) -> Any? { | |
| guard let value = value else { return nil } | |
| if let value = value as? CGSize { | |
| return CGSize(width: direction * value.width, height: direction * value.height) | |
| } else if let value = value as? CGPoint { | |
| return CGPoint(x: direction * value.x, y: direction * value.y) | |
| } else if let value = value as? CGRect { | |
| return CGRect(x: direction * value.origin.x, y: direction * value.origin.y, width: direction * value.size.width, height: direction * value.size.height) | |
| } | |
| return nil | |
| } | |
| func direction(from value: Any) -> CGFloat { | |
| if let value = value as? CGSize { | |
| print("size: \(value)") | |
| return value.width > 0 ? -1 : 1 | |
| } else if let value = value as? CGPoint { | |
| print("point: \(value)") | |
| return value.x > 0 ? -1 : 1 | |
| } else if let value = value as? CGRect { | |
| print("rect: \(value)") | |
| return value.size.width > 0 ? -1 : 1 | |
| } | |
| return 0 // unknown | |
| } | |
| } | |
| class MySublayer: CALayer { | |
| override init() { | |
| super.init() | |
| backgroundColor = UIColor.green.withAlphaComponent(0.5).cgColor | |
| } | |
| required init?(coder: NSCoder) { | |
| fatalError("init(coder:) has not been implemented") | |
| } | |
| override init(layer: Any) { | |
| super.init(layer: layer) | |
| } | |
| // open override func add(_ anim: CAAnimation, forKey key: String?) { | |
| // guard let key else { return } | |
| // | |
| // if let basicAnimation = anim as? CABasicAnimation { | |
| // print("\(type(of: self)) | add animation: \(basicAnimation.keyPath), \(basicAnimation.fromValue) -> \(basicAnimation.toValue)") | |
| // } | |
| // | |
| // let keyPath = if let propertyAnimation = anim as? CAPropertyAnimation, let k = propertyAnimation.keyPath { | |
| // k | |
| // } else { | |
| // key | |
| // } | |
| // | |
| // let fixedKey = if let index = getMaxIndexOfAnimation(for: self.superlayer, with: keyPath) { | |
| // keyPath + "-\(index)" | |
| // } else { | |
| // keyPath | |
| // } | |
| // | |
| // print("\(type(of: self)) | try to add animation for: \(key), but with fixed key: \(fixedKey)") | |
| // | |
| // super.add(anim, forKey: fixedKey) | |
| // | |
| // print("\(type(of: self)) | added animation, current animations: \(MySublayer.allAnimationKeys(of: self))") | |
| // print("\(type(of: self)) | added animation, current animations of superlayer: \(MySublayer.allAnimationKeys(of: self.superlayer))") | |
| // | |
| // } | |
| // | |
| // get index of bounds animation from the layer | |
| func getMaxIndexOfBoundsAnimation(of layer: CALayer?) -> Int? { | |
| getMaxIndexOfAnimation(for: layer, with: "bounds") | |
| } | |
| func getMaxIndexOfAnimation(for layer: CALayer?, with prefix: String) -> Int? { | |
| guard let keys = layer?.animationKeys() else { return nil } | |
| return extractIndex( | |
| from: sort( | |
| keys: filter( | |
| keys:keys, | |
| prefix: prefix | |
| ) | |
| ).first | |
| ) | |
| } | |
| // all animation keys in one string | |
| static func allAnimationKeys(of layer: CALayer?) -> String? { | |
| layer?.animationKeys()?.reduce(into: "") { partialResult, item in | |
| if partialResult?.count ?? 0 > 0 { | |
| partialResult?.append(", ") | |
| } | |
| partialResult?.append(item) | |
| } | |
| } | |
| // extract Int index from string like this "bounds.size-6" | |
| func extractIndex(from string: String?) -> Int? { | |
| guard let last = string?.split(separator: "-").last else { return nil } | |
| return Int(String(last)) | |
| } | |
| // filter array by prefix | |
| func filter(keys: [String], prefix: String) -> [String] { | |
| keys.filter { $0.hasPrefix(prefix) } | |
| } | |
| // sort keys array alphabetically | |
| func sort(keys: [String]) -> [String] { | |
| keys.sorted { $0.localizedCaseInsensitiveCompare($1) == ComparisonResult.orderedDescending } | |
| } | |
| } | |
| class MyLayer : CALayer { | |
| let sublayer = MySublayer() | |
| let sublayerDelegate = SublayerDelegate() | |
| override init() { | |
| super.init() | |
| backgroundColor = UIColor.blue.cgColor | |
| self.sublayer.anchorPoint = .zero | |
| self.sublayer.delegate = sublayerDelegate | |
| self.addSublayer(self.sublayer) | |
| } | |
| required init?(coder: NSCoder) { | |
| fatalError("init(coder:) has not been implemented") | |
| } | |
| override init(layer: Any) { | |
| super.init(layer: layer) | |
| } | |
| open override func add(_ anim: CAAnimation, forKey key: String?) { | |
| // guard let key else { return } | |
| // let mark: String.Element = "-" | |
| // let fixedKey = if let endIndex = key.firstIndex(of: mark) { | |
| // String(key[..<endIndex]) | |
| // } else { | |
| // key | |
| // } | |
| // print("\(type(of: self)) | add animation: \(String(describing: key)), fixed: \(fixedKey)") | |
| // if let basicAnimation = anim as? CABasicAnimation { | |
| // let currentValue = self.presentation()?.value(forKeyPath: fixedKey) | |
| // print("current state: \(String(describing: currentValue)), animation.fromValue: \(String(describing: basicAnimation.fromValue)), animation: \(basicAnimation.debugDescription)") | |
| // } | |
| print("\(type(of: self)) | add animation: \(anim.debugDescription)") | |
| super.add(anim, forKey: key) | |
| } | |
| override func layoutSublayers() { | |
| super.layoutSublayers() | |
| // self.sublayer.frame = .init(origin: .zero, size: bounds.size) | |
| self.sublayer.bounds = .init(origin: .zero, size: bounds.size) | |
| // self.sublayer.position = .init(x: bounds.midX, y: bounds.midY) | |
| } | |
| } | |
| class MyView : UIView { | |
| override init(frame: CGRect) { | |
| super.init(frame: frame) | |
| self.backgroundColor = .red | |
| } | |
| required init?(coder: NSCoder) { | |
| fatalError("init(coder:) has not been implemented") | |
| } | |
| override class var layerClass: AnyClass { | |
| return MyLayer.self | |
| } | |
| func toggleSublayerHiddenState() { | |
| (self.layer as? MyLayer)?.sublayer.isHidden.toggle() | |
| } | |
| // override func action(for layer: CALayer, forKey event: String) -> CAAction? { | |
| // let inheritedAction = super.action(for: layer, forKey: event) | |
| // | |
| // guard layer === self.layer, event.hasPrefix("bounds") else { | |
| // return inheritedAction | |
| // } | |
| // | |
| // return SynchronizedBoundsAction(wrapping: inheritedAction) | |
| // } | |
| } | |
| class ViewController: UIViewController { | |
| var isBigButton: Bool = false { | |
| didSet { | |
| self.view.setNeedsLayout() | |
| } | |
| } | |
| let myView = MyView(frame: .init(origin: .zero, size: .init(width: 100, height: 100))) | |
| let myViewAnimateButton = UIButton(type: .system) | |
| let hideSublayerButton = UIButton(type: .system) | |
| override func viewDidLoad() { | |
| super.viewDidLoad() | |
| view.addSubview(myView) | |
| myViewAnimateButton.setTitle("Animate", for: .normal) | |
| myViewAnimateButton.addTarget(self, action: #selector(animate), for: .touchUpInside) | |
| myViewAnimateButton.sizeToFit() | |
| view.addSubview(myViewAnimateButton) | |
| hideSublayerButton.setTitle("Hide sublayer (green)", for: .normal) | |
| hideSublayerButton.addTarget(self, action: #selector(hideSublayer), for: .touchUpInside) | |
| hideSublayerButton.sizeToFit() | |
| view.addSubview(hideSublayerButton) | |
| } | |
| override func viewWillLayoutSubviews() { | |
| super.viewWillLayoutSubviews() | |
| myView.bounds.size = isBigButton ? .init(width: 300, height: 300) : .init(width: 100, height: 100) | |
| myView.center = view.center | |
| myViewAnimateButton.center = .init(x: view.bounds.midX, y: view.bounds.maxY - 50) | |
| hideSublayerButton.center = .init(x: view.bounds.midX, y: view.bounds.maxY - 100) | |
| } | |
| @objc func animate() { | |
| self.isBigButton.toggle() | |
| UIView.animate( | |
| withDuration: 1, | |
| delay: 0, | |
| usingSpringWithDamping: 1.0, | |
| initialSpringVelocity: 0.0, | |
| options: [ | |
| // .curveEaseInOut, | |
| // .beginFromCurrentState, | |
| // .layoutSubviews | |
| ], | |
| ) { | |
| self.view.layoutIfNeeded() | |
| } | |
| } | |
| @objc func hideSublayer() { | |
| myView.toggleSublayerHiddenState() | |
| UIView.animate(withDuration: 0.2) { [weak self] in | |
| guard let self else { return } | |
| self.hideSublayerButton.setTitle(self.myView.layer.sublayers?.first?.isHidden == false ? "Show sublayer (green)" : "Hide sublayer (green)", for: .normal) | |
| self.hideSublayerButton.sizeToFit() | |
| self.view.setNeedsLayout() | |
| self.view.layoutIfNeeded() | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment