Created
May 31, 2016 18:59
-
-
Save mitchellporter/e2440e6c6c26d93673481f9e4c6d5eac to your computer and use it in GitHub Desktop.
Propagating Custom Property Animation Addition and Removal to Sublayers
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
| // | |
| // In this custom layer, changes to a "highlightedPercentage" key will switch between showing normal and highlighted image views. | |
| // In addition, the size of the layer itself changes depending on these image views as well. This layer needs to fully support atomic, | |
| // model changes to this property as well as animated changes to this key without forcing main thread-bound view layout calls on | |
| // every frame of animated changes. | |
| // | |
| // | |
| // CATransaction.begin() | |
| // CATransaction.setDisableActions(true) | |
| // | |
| // layer.highlightedPercentage = 1.0 // Model change, immediate, causes main thread-layout, could be implicit | |
| // | |
| // CATransaction.commit() | |
| // | |
| // let highlightedPercentageAnimation = CABasicAnimation(key: "highlightedPercentage") | |
| // highlightedPercentageAnimation.duration = 1.0 | |
| // highlightedPercentageAnimation.fromValue = 0.0 | |
| // | |
| // // Animated change, not main thread-bound | |
| // layer.addAnimation(highlightedPercentageAnimation, forKey: "highlightedPercentageAnimation") | |
| // | |
| // Later... | |
| // | |
| // layer.removeAnimationForKey("highlightedPercentageAnimation") | |
| // | |
| private let LayerHighlightedPercentageKey = "highlightedPercentage" | |
| private let LayerBoundsAnimationKey = "boundsAnimation" | |
| private let LayerOpacityAnimationKey = "opacityAnimation" | |
| // MARK: CustomLayer Class - | |
| class CustomLayer: CALayer { | |
| // MARK: - Properties | |
| var normalImageSize: CGSize! = CGSizeZero | |
| var normalImageViewLayer: CALayer? { | |
| didSet { | |
| if let normalImageViewLayer = normalImageViewLayer { | |
| CATransaction.begin() | |
| CATransaction.setDisableActions(true) | |
| normalImageViewLayer.anchorPoint = CGPointZero // Easier than recentering the position on bounds change | |
| CATransaction.commit() | |
| } | |
| } | |
| } | |
| var highlightedImageSize: CGSize! = CGSizeZero | |
| var highlightedImageViewLayer: CALayer? { | |
| didSet { | |
| if let highlightedImageViewLayer = highlightedImageViewLayer { | |
| CATransaction.begin() | |
| CATransaction.setDisableActions(true) | |
| highlightedImageViewLayer.anchorPoint = CGPointZero // Easier than recentering the position on bounds change | |
| CATransaction.commit() | |
| } | |
| } | |
| } | |
| @NSManaged var highlightedPercentage: Float // @NSManaged is required make this an Objective-C style dynamic property | |
| // MARK: - Object Lifecycle | |
| // init(layer:) is the designated initializer called by the presentation layer every frame during an animation. It's used to copy | |
| // the custom property values over. "layer" may be a model layer, but it's likely to be another presentation layer. | |
| override required init(layer: AnyObject) { | |
| super.init(layer: layer) | |
| if let layer = layer as? CustomLayer { | |
| highlightedPercentage = layer.highlightedPercentage | |
| } else { | |
| highlightedPercentage = 0.0 | |
| } | |
| } | |
| override init() { | |
| super.init() | |
| commonInitialization() | |
| } | |
| required init?(coder decoder: NSCoder) { | |
| super.init(coder: decoder) | |
| commonInitialization() | |
| } | |
| private func commonInitialization() { | |
| // @NSManaged properties can't have didSet blocks, so this is the only way to be notified when a property changes. Additionally, | |
| // attempting to remove the observation in deinit crashes. Core Animation is apparently doing this automatically for all Objective-C | |
| // style dynamic properties, so we don't (and can't) do it ourselves. | |
| addObserver(self, forKeyPath: LayerHighlightedPercentageKey, options: [], context: nil) | |
| } | |
| // MARK: - Providing Actions for Animations | |
| // needsDisplayForKey(_:) is required to receive display callbacks for changes to layer properties, including custom properties. | |
| // In the event that display() is overridden to perform actual main-thread bound layer updates, returning custom property keys in this | |
| // function is necessary for it to call display() during every frame of an animation to a custom property. | |
| override class func needsDisplayForKey(key: String) -> Bool { | |
| var needsDisplay = super.needsDisplayForKey(key) | |
| if key == LayerHighlightedPercentageKey { | |
| needsDisplay = true | |
| } | |
| return needsDisplay | |
| } | |
| // actionForKey(_:) allows for implicit animation creation for custom properties, even without a UIKit animation context. | |
| override func actionForKey(key: String) -> CAAction? { | |
| var action = super.actionForKey(key) | |
| if key == LayerHighlightedPercentageKey { | |
| // Create reference action from an existing CALayer key. This reference action will have all the default properties specified | |
| // by the implicit animation context that is invoking an action for the custom animated property. | |
| action = super.actionForKey("opacity") | |
| if let action = action as? CABasicAnimation { | |
| action.keyPath = key | |
| action.fromValue = valueForKeyPath(key) | |
| } | |
| } | |
| return action | |
| } | |
| // MARK: - Sublayer Layout | |
| // In the case of this layer, sublayers need to be manipulated in response to the value of a custom root layer property. layoutSublayers() | |
| // needs to be called only when appropriate; while display() _will_ be called at every frame of an animation for the custom property | |
| // (assuming needsDisplayForKey(_:) was set up appropriately), making geometry changes in display() will invoke layer (and view) layout | |
| // logic at every animation frame, which is bad. layoutSublayers() should only be invoked on model changes, not presentation changes. | |
| override func layoutSublayers() { | |
| super.layoutSublayers() | |
| guard let normalImageViewLayer = normalImageViewLayer, highlightedImageViewLayer = highlightedImageViewLayer else { return } | |
| let minimumSize = normalImageSize | |
| let maximumSize = highlightedImageSize | |
| let sizeDifference = CGSize(width: (maximumSize.width - minimumSize.width), height: (maximumSize.height - minimumSize.height)) | |
| var bounds = CGRectZero | |
| bounds.size.width = minimumSize.width + (CGFloat(highlightedPercentage) * sizeDifference.width) | |
| bounds.size.height = minimumSize.height + (CGFloat(highlightedPercentage) * sizeDifference.height) | |
| self.bounds = bounds | |
| normalImageViewLayer.bounds = bounds | |
| highlightedImageViewLayer.bounds = bounds | |
| normalImageViewLayer.opacity = 1.0 - highlightedPercentage | |
| highlightedImageViewLayer.opacity = highlightedPercentage | |
| } | |
| // MARK: - Forwarding Animation Addition and Removal | |
| override func addAnimation(animation: CAAnimation, forKey key: String?) { | |
| super.addAnimation(animation, forKey: key) | |
| propagateHighlightedPercentageAnimationAdditionIfNecessary(animation) | |
| } | |
| override func removeAnimationForKey(key: String) { | |
| guard let animation = animationForKey(key) else { return } | |
| super.removeAnimationForKey(key) | |
| propagateHighlightedPercentageAnimationRemovalIfNecessary(animation) | |
| } | |
| override func removeAllAnimations() { | |
| super.removeAllAnimations() | |
| guard let normalImageViewLayer = normalImageViewLayer, highlightedImageViewLayer = highlightedImageViewLayer else { return } | |
| normalImageViewLayer.removeAllAnimations() | |
| highlightedImageViewLayer.removeAllAnimations() | |
| } | |
| // Here is how we actually animate the sublayers of a layer with a custom animatable property. When we detect that an animation is added | |
| // or removed that contains the keypath to a custom animatable layer property, we have to propagate those animations to the sublayers as | |
| // appropriate. Immutable animation copies are added to layers, so modifying the animation after it's already been added to the layer | |
| // is fine. | |
| private func propagateHighlightedPercentageAnimationAdditionIfNecessary(animation: CAAnimation) { | |
| guard let normalImageViewLayer = normalImageViewLayer, highlightedImageViewLayer = highlightedImageViewLayer else { return } | |
| var highlightedPercentageAnimation: CAAnimation? = nil | |
| if let animationGroup = animation as? CAAnimationGroup, animations = animationGroup.animations { | |
| for animationGroupAnimation in animations { | |
| guard let propertyAnimation = animationGroupAnimation as? CAPropertyAnimation where propertyAnimation.keyPath == LayerHighlightedPercentageKey else { continue } | |
| highlightedPercentageAnimation = propertyAnimation | |
| } | |
| } else if let propertyAnimation = animation as? CAPropertyAnimation where propertyAnimation.keyPath == LayerHighlightedPercentageKey { | |
| highlightedPercentageAnimation = propertyAnimation | |
| } | |
| if let highlightedPercentageAnimation = highlightedPercentageAnimation { | |
| highlightedPercentageAnimation.beginTime = animation.beginTime | |
| let minimumSize = normalImageSize | |
| let maximumSize = highlightedImageSize | |
| let sizeDifference = CGSize(width: (maximumSize.width - minimumSize.width), height: (maximumSize.height - minimumSize.height)) | |
| if let basicAnimation = highlightedPercentageAnimation as? CABasicAnimation { | |
| // Bounds | |
| let boundsAnimation = basicAnimation.copy() as! CABasicAnimation | |
| boundsAnimation.keyPath = "bounds" | |
| if let fromValue = boundsAnimation.fromValue as? NSNumber { | |
| var fromBounds = CGRectZero | |
| fromBounds.size.width = minimumSize.width + (CGFloat(fromValue.floatValue) * sizeDifference.width) | |
| fromBounds.size.height = minimumSize.height + (CGFloat(fromValue.floatValue) * sizeDifference.height) | |
| boundsAnimation.fromValue = NSValue(CGRect: fromBounds) | |
| } | |
| if let toValue = boundsAnimation.toValue as? NSNumber { | |
| var toBounds = CGRectZero | |
| toBounds.size.width = minimumSize.width + (CGFloat(toValue.floatValue) * sizeDifference.width) | |
| toBounds.size.height = minimumSize.height + (CGFloat(toValue.floatValue) * sizeDifference.height) | |
| boundsAnimation.toValue = NSValue(CGRect: toBounds) | |
| } | |
| if let byValue = boundsAnimation.byValue as? NSNumber { | |
| var byBounds = CGRectZero | |
| byBounds.size.width = minimumSize.width + (CGFloat(byValue.floatValue) * sizeDifference.width) | |
| byBounds.size.height = minimumSize.height + (CGFloat(byValue.floatValue) * sizeDifference.height) | |
| boundsAnimation.byValue = NSValue(CGRect: byBounds) | |
| } | |
| addAnimation(boundsAnimation, forKey: LayerBoundsAnimationKey) | |
| normalImageViewLayer.addAnimation(boundsAnimation, forKey: LayerBoundsAnimationKey) | |
| highlightedImageViewLayer.addAnimation(boundsAnimation, forKey: LayerBoundsAnimationKey) | |
| // Opacity | |
| let normalOpacityAnimation = basicAnimation.copy() as! CABasicAnimation | |
| normalOpacityAnimation.keyPath = "opacity" | |
| if let fromValue = normalOpacityAnimation.fromValue as? NSNumber { | |
| let fromOpacity = 1.0 - fromValue.floatValue | |
| normalOpacityAnimation.fromValue = fromOpacity | |
| } | |
| if let toValue = normalOpacityAnimation.toValue as? NSNumber { | |
| let toOpacity = 1.0 - toValue.floatValue | |
| normalOpacityAnimation.toValue = toOpacity | |
| } | |
| if let byValue = normalOpacityAnimation.byValue as? NSNumber { | |
| let byOpacity = -byValue.floatValue | |
| normalOpacityAnimation.byValue = byOpacity | |
| } | |
| let highlightedOpacityAnimation = basicAnimation.copy() as! CABasicAnimation | |
| highlightedOpacityAnimation.keyPath = "opacity" | |
| normalImageViewLayer.addAnimation(normalOpacityAnimation, forKey: LayerOpacityAnimationKey) | |
| highlightedImageViewLayer.addAnimation(highlightedOpacityAnimation, forKey: LayerOpacityAnimationKey) | |
| } else if let keyframeAnimation = highlightedPercentageAnimation as? CAKeyframeAnimation { | |
| guard let values = keyframeAnimation.values else { return } | |
| // Bounds | |
| let boundsAnimation = keyframeAnimation.copy() as! CAKeyframeAnimation | |
| boundsAnimation.keyPath = "bounds" | |
| var boundsValues = [NSValue]() | |
| let normalOpacityAnimation = keyframeAnimation.copy() as! CAKeyframeAnimation | |
| normalOpacityAnimation.keyPath = "opacity" | |
| var normalOpacityValues = [NSNumber]() | |
| let highlightedOpacityAnimation = keyframeAnimation.copy() as! CAKeyframeAnimation | |
| highlightedOpacityAnimation.keyPath = "opacity" | |
| var highlightedOpacityValues = [NSNumber]() | |
| for value in values { | |
| var bounds = CGRectZero | |
| var normalOpacity = Float(1.0) | |
| var highlightedOpacity = Float(0.0) | |
| if let value = value as? NSNumber { | |
| let floatValue = value.floatValue | |
| bounds.size.width = minimumSize.width + (CGFloat(floatValue) * sizeDifference.width) | |
| bounds.size.height = minimumSize.height + (CGFloat(floatValue) * sizeDifference.height) | |
| normalOpacity = 1.0 - floatValue | |
| highlightedOpacity = floatValue | |
| } | |
| boundsValues.append(NSValue(CGRect: bounds)) | |
| normalOpacityValues.append(normalOpacity) | |
| highlightedOpacityValues.append(highlightedOpacity) | |
| } | |
| boundsAnimation.values = boundsValues | |
| normalOpacityAnimation.values = normalOpacityValues | |
| highlightedOpacityAnimation.values = highlightedOpacityValues | |
| addAnimation(boundsAnimation, forKey: LayerBoundsAnimationKey) | |
| normalImageViewLayer.addAnimation(boundsAnimation, forKey: BoundsAnimationKey) | |
| highlightedImageViewLayer.addAnimation(boundsAnimation, forKey: BoundsAnimationKey) | |
| // Opacity | |
| normalImageViewLayer.addAnimation(normalOpacityAnimation, forKey: LayerOpacityAnimationKey) | |
| highlightedImageViewLayer.addAnimation(highlightedOpacityAnimation, forKey: LayerOpacityAnimationKey) | |
| } | |
| } | |
| } | |
| private func propagateHighlightedPercentageAnimationRemovalIfNecessary(animation: CAAnimation) { | |
| guard let normalImageViewLayer = normalImageViewLayer, highlightedImageViewLayer = highlightedImageViewLayer else { return } | |
| var highlightedPercentageAnimation: CAAnimation? = nil | |
| if let animationGroup = animation as? CAAnimationGroup, animations = animationGroup.animations { | |
| for animation in animations { | |
| guard let animation = animation as? CAPropertyAnimation where animation.keyPath == LayerHighlightedPercentageKey else { continue } | |
| highlightedPercentageAnimation = animation | |
| } | |
| } else if let animation = animation as? CAPropertyAnimation where animation.keyPath == LayerHighlightedPercentageKey { | |
| highlightedPercentageAnimation = animation | |
| } | |
| if highlightedPercentageAnimation != nil { | |
| removeAnimationForKey(LayerBoundsAnimationKey) | |
| normalImageViewLayer.removeAnimationForKey(LayerBoundsAnimationKey) | |
| highlightedImageViewLayer.removeAnimationForKey(LayerBoundsAnimationKey) | |
| normalImageViewLayer.removeAnimationForKey(LayerOpacityAnimationKey) | |
| highlightedImageViewLayer.removeAnimationForKey(LayerOpacityAnimationKey) | |
| } | |
| } | |
| // MARK: - KVO Overrides | |
| // If a model update to a custom layer property is made, we need to force a layout to occur because the layer's geometry needs | |
| // to change as well. | |
| override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) { | |
| if keyPath == CircularMenuItemLayerHighlightedPercentageKey { | |
| setNeedsLayout() | |
| layoutIfNeeded() // May or may not be needed, depending on whether the layout can wait until the next run loop cycle. | |
| } else { | |
| super.observeValueForKeyPath(keyPath, ofObject: object, change: change, context: context) | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment