Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save mitchellporter/e2440e6c6c26d93673481f9e4c6d5eac to your computer and use it in GitHub Desktop.

Select an option

Save mitchellporter/e2440e6c6c26d93673481f9e4c6d5eac to your computer and use it in GitHub Desktop.
Propagating Custom Property Animation Addition and Removal to Sublayers
//
// 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