Skip to main content

CAStateController & Friends

ยท 4 min read

CALayer has internal/private support for two pretty cool things: archives and states. We'll talk about states first, as they're the more complex part of the exercise.

A CALayer can have any number of states which are CAState objects containing a number of CAStateElements. The elements configure the state, and can target any sublayer of the root layer, and any keyPath on CALayer. It looks like it can also have a source (which is a CAStateElement itself) but I'm not sure what that specifically means. A state can also be basedOn another CAState, presumably one that preceeds it in the state diagram of the layer; an initial state is one that a layer can start off with (and doesn't need a name to uniquely identify it). On top of that, a CALayer may also have a number of stateTransitions which are CAStateTransition objects (containing CAStateTransitionElements). The typical use case will be to create elements with a target and an animation representing the keyPath that needs to be animated between the two states. Be sure to set the toState and fromState on your transition to match the unique state names from earlier. If you use the string "*", I believe it refers to any possible state. Here's a code sample:

let s1 = CAState()
s1.name = "inactive"
s1.isInitial = true
let e1 = CAStateSetValue()
e1.keyPath = "backgroundColor"
e1.value = NSColor.red.cgColor
e1.target = layer
s1.addElement(e1)

let s2 = CAState()
s2.name = "active"
let e2 = CAStateSetValue()
e2.keyPath = "backgroundColor"
e2.value = NSColor.blue.cgColor
e2.target = layer
s2.addElement(e2)

let t1 = CAStateTransition()
t1.fromState = "*"
t1.toState = "active"
let e3 = CAStateTransitionElement()
e3.key = "backgroundColor"
e3.animation = CABasicAnimation(keyPath: "backgroundColor")
e3.target = layer
e3.isEnabled = true
e3.duration = 2.0
t1.elements = [e3]

let t2 = CAStateTransition()
t1.fromState = "*"
t1.toState = "inactive"
let e4 = CAStateTransitionElement()
e4.key = "backgroundColor"
e4.animation = CABasicAnimation(keyPath: "backgroundColor")
e4.target = layer
e4.isEnabled = true
e4.duration = 2.0
t2.elements = [e4]

let t3 = CAStateTransition()
t3.fromState = "inactive"
t3.toState = "active"
let e5 = CAStateTransitionElement()
e5.key = "backgroundColor"
e5.animation = CABasicAnimation(keyPath: "backgroundColor")
e5.target = layer
e5.isEnabled = true
e5.duration = 2.0
t3.elements = [e5]

let t4 = CAStateTransition()
t4.fromState = "active"
t4.toState = "inactive"
let e6 = CAStateTransitionElement()
e6.key = "backgroundColor"
e6.animation = CABasicAnimation(keyPath: "backgroundColor")
e6.target = layer
e6.isEnabled = true
e6.duration = 2.0
t4.elements = [e6]

layer.states = [s1, s2]
layer.stateTransitions = [t1, t2, t3, t4]

DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
self.ctrl = CAStateController(layer: layer)!
self.ctrl.setInitialStatesOfLayer(layer, transitionSpeed: 0.5)

DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) {
self.ctrl.setState(s2, ofLayer: layer, transitionSpeed: 0.5)
}
}

To actually apply and modify states on a layer, you'll want to create a CAStateController. It's simple to use, as you initially call setInitialStatesOfLayer(_:) and to change a state, call setState(ofLayer:). If you include the transitionSpeed argument, it'll set a duration for any possible transitions to occur (independently of the duration of its elements).

I don't recommend using CAStateController and CAState without having written a good CoreAnimation "Interface Builder" to help you design states and layers correctly. Since Apple likely has this tool and we don't, it's probably why the API is private at the moment. A first step towards this, however, is to use CAML packages correctly. There is a CAPackage class to aid in all of this, but we'll use CAMLWriter and CAMLParser instead, which mirror NSKeyedArchiver and NSKeyedUnarchiver, but serialize into a "core animation archive" (caar) which uses a different XML format. Here's a code sample:

// Write:
let data = NSMutableData()
let writer = CAMLWriter(data: data)
writer?.encode(layer)

// Read:
let parser = CAMLParser()
_ = parser.parseData(data as Data)
let layer2 = parser.result as! CALayer

Pretty simple right!? Now the challenge is to build an interface designer to model CALayers and CAStates. Encode it into a caml file and decode it at runtime, and you're all set. Use a CAPackage to neatly wrap the loading logic -- check out AVMicaPackage in AVKit, some of the packages in DictationServices (within PrivateFrameworks/SpeechObjects), or PassKitUIFoundation.

Regarding CAState, I'm still not sure what CAState.locked, CAStateElement.source, CAStateControllerLayer (which isn't even a CALayer subclass??) or CAStateControllerUndo (and related undoStack) do. I'll investigate those but if anyone has any idea, drop me a line and let me know!