v2 API proposal
This is a proposal for changes to the Material Motion v1 APIs with the following goals:
- Remove the MotionRuntime.
- Interactions have exactly one target.
- Interactions are tightly bound to their systems. E.g. One Tween implementation that supports Core Animation, another that supports POP.
- Clear mechanisms for enabling/disabling interactions.
This proposal does not reflect an immediate commitment. It represents a possible direction that we'd like to move Material Motion v2 toward on each of the respective platforms. This issue is open for discussion and consideration from all applicable platforms.
V1 APIs vs V2 APIs in code
The following code snippets present a side-by-side comparison of v1 and the proposed v2 APIs.
Gestural interactions
// V1 draggable example
class DraggableExampleViewController: ExampleViewController {
// Step 1: Store a runtime for as long as the interactions need to be active
var runtime: MotionRuntime!
override func viewDidLoad() {
super.viewDidLoad()
let square = center(createExampleView(), within: view)
view.addSubview(square)
// Step 2: Create a runtime and provide a container view
runtime = MotionRuntime(containerView: view)
// Step 3: Create an interaction
let draggable = Draggable()
// Step 4: Associate the interaction with a target
runtime.add(draggable, to: square)
}
}
// V2 draggable example
class DraggableExampleViewController: ExampleViewController {
override func viewDidLoad() {
super.viewDidLoad()
let square = center(createExampleView(), within: view)
view.addSubview(square)
// Step 1: Create an interaction
let draggable = Draggable(square, relativeTo: view)
// Step 2: Start the interaction
draggable.enable()
}
}
Tween interactions
// V1 tween example
class TweenExampleViewController: ExampleViewController {
var runtime: MotionRuntime!
override func viewDidLoad() {
super.viewDidLoad()
let square = center(createExampleView(), within: view)
view.addSubview(square)
runtime = MotionRuntime(containerView: view)
let tween = Tween<CGFloat>(duration: 1, values: [1, 0, 1])
runtime.add(tween, to: runtime.get(square.layer).opacity)
let tap = runtime.get(UITapGestureRecognizer())
runtime.start(tween, whenActive: tap)
}
}
// V2 tween example
class TweenExampleViewController: ExampleViewController {
override func viewDidLoad() {
super.viewDidLoad()
let square = center(createExampleView(), within: view)
view.addSubview(square)
let tap = UITapGestureRecognizer()
view.addGestureRecognizer(tap)
let tween = Tween(for: Reactive(square.layer).opacityKeyPath)
tween.duration = 1
tween.values = [1, 0, 1]
Reactive(tap).didRecognize.subscribe { _ in tween.enable() }
}
}
API design considerations
Interactions own their targets
Interactions require their target upon initialization. Targets can't be changed post-initialization.
Note: Interactions may need to keep weak references to their targets in order not to create a circular dependency in the event that a target decides to hold on to a strong reference to the interaction in order to continue to configure it.
public class CATween: Interaction {
public init(for keyPath: CAKeyPath<T>)
}
public class Draggable: Interaction {
public init(_ view: UIView,
withGestureRecognizer existingGesture: UIPanGestureRecognizer,
relativeTo relativeView: UIView)
}
Interaction shape
All interactions can be enabled and disabled.
public protocol Interaction {
func enable()
func disable()
}
Enable/disable implementations
Interactions will subscribe to one or more streams in enable and unsubscribe from those streams in disable.
public func enable() {
guard subscriptions.isEmpty else { return }
subscriptions = [
someStream.subscribe(...)
]
}
public func disable() {
subscriptions.forEach { $0.unsubscribe() }
subscriptions.removeAll()
}
private var subscriptions: [Subscription] = []
draggable.enable()
spring.enable()
draggable.disable()
Enable/disable vs pause/play
public protocol PausableInteraction: Interaction {
var paused { get set }
}
All interactions can be enabled/disabled. Some interactions need an additional level of pausing and playing behavior.
E.g. When a TransitionSpring is enabled for the first time it will initialize the target property's value with the initial value. This is an important part of TransitionSpring's behavior. If TransitionSpring is used in a Tossable interaction, Tossable needs to be able to choose when to pause or play the spring interaction in response to the draggable interaction's state.
public func enable() {
guard subscriptions.isEmpty else { return }
guard let gesture = draggable.gesture else { return }
var spring = self.spring
if gesture.state == .began || gesture.state == .changed {
spring.paused = true // Ensure that the spring doesn't start right away.
}
spring.enable() // Allow the spring to write initial state if needed.
let reactiveGesture = Reactive(gesture)
subscriptions = [
reactiveGesture.didBegin { _ in
spring.paused = true
},
reactiveGesture.didEnd.velocity(in: relativeView).subscribeToValue { velocity in
spring.initialVelocity = velocity
spring.paused = false
}
]
draggable.enable()
}
Composition of interactions
Interactions are still encouraged to compose to one another via instance objects and to provide reasonable default initializers.
let tossable = Tossable(draggable: existingDraggable, spring: existingSpring)
If I can create Draggable like so:
let draggable = Draggable(view, relativeTo: containerView)
Then I should also be able to do this:
let tossable = Tossable(view, relativeTo: containerView)
And the Tossable initializer will extract the proper property for the Spring sub-interaction.
It should be possible to access the sub-interactions directly:
tossable.draggable
tossable.spring
Configuration of interactions
An interaction can expose one or more configuration properties. Once an interaction is initiated, these configuration properties may or may not cause additional changes to the interaction's behavior. This should be well-documented on a per-property basis.
E.g. a Spring's destination property can be changed before or after the interaction is enabled and the spring should react accordingly. If a Draggable interaction's gesture recognizer is changed after the interaction is enabled, however, then no change will be made to the currently-subscribed streams. [featherless]: I could also argue that interactions should restart any subscriptions if any properties change and that, if so, this may not necessarily need to be supported right away.
Constraints
Constraints affect specific parts of the interaction's behavior.
In the general case, interactions might expose an addConstraint API that affects the interaction's canonical behavior.
public func addConstraint(_ constraint: @escaping (MotionObservable<CGPoint>) -> MotionObservable<CGPoint>) {
constraints.append(constraint)
}
public private(set) var constraints: [(MotionObservable<CGPoint>) -> MotionObservable<CGPoint>] = []
draggable.addConstraint { $0.xLocked(to: bounds.midX) }
An interaction may choose to expose multiple constraint APIs for different aspects of the interaction's behavior. E.g. Tossable might expose one constraint API for affecting the absolute X/Y position of the target and another constraint API for affecting the velocity that's fed from the draggable interaction to the spring interaction.
Interaction lifecycle
Interactions live for as long as the targets to which they're associated.
This is how view systems are modeled. E.g. once a view is added to a view hierarchy you do not need to a keep a reference around in order for it to be shown. You can also query the view hierarchy to get children views if necessary - we would want to provide a similar mechanism for interactions.
E.g. if Draggable is associated with a view then that interaction should be deallocated when the view is deallocated.
Metadata
Remove of all metadata from the library. This only presently affects the Swift implementation.
This metadata was introduced in order to support potential inspection tooling that could visualize the runtime as a directed graph. This metadata pattern introduced a fixed overhead to all APIs both in terms of runtime memory and CPU cost and in terms of API maintenance cost. The benefits of these APIs have not proven substantial enough to justify their costs.
Open questions
If an interaction needs to maintain some sort of long-term state, e.g. a gesture recognizer delegate, where should this be stored?
- One option is to ensure that interaction objects, not just their behavior, stay alive for as long as their target object. This would require that the target object have a strong memory association to the interaction and that the interaction maintain only weak relationships to the target object. It would mean we could create a DirectlyManipulable interaction, associate it with a target view, and know that the default gesture recognizers will have their delegates configured to support multi-gesture recognition. DirectlyManipulable could also be a UIGestureRecognizerDelegate in order to allow clients to manually invoke the delegate methods if more complex behavior was required.
A proposal of work for movement from a v1 implementation to a v2 implementation:
- Delete all metadata-related APIs.
- Implement all v2 interactions in batches. E.g. all gestural interactions, then spring, then tween, then transition types.
- Convert components one-by-one to make use of the new interactions.
- Delete the MotionRuntime.
Note that this proposal only affects L1 Interaction and Runtime APIs. All operators and underlying streams tech will not be affected.
This broadly sounds good to me. Here are specific areas that may need clarification:
Streams
I understand the desire to have enable()/disable() methods in L1 APIs - most L1 users probably don't think in terms of streams. Still, I think it's important that we expose a reactive API - both for composition and to be approachable and interoperable for reactive programmers.
We can accomplish this by ensuring that every interaction which affects only a single property exposes a subscribe(observer) API, and that its enable/disable methods use this API to connect themselves.
enabled vs paused
If you were writing docs for enabled and paused, how would you explain the difference between them? Having multiple booleans that each map to some form of "can I use this thing right now?" is inherently confusing. (That doesn't mean it isn't the right approach, but it's a subtle difference that requires reading documentation, so it warrants extra skepticism.)
Reactive()
You have examples like Reactive(tap) and Reactive(gesture). Presumably, these are gesture delegates that expose streams for properties like didRecognize. Would an L1 user need to use the Reactive versions? How would they know to do this?
Configuration of Interactions
You specify that changing the properties of an interaction may or may not change the behavior of an interaction after it's initialized - if that's the case, those are probably better suited to constructor arguments than properties. There may be some nuance in the proposal that I'm missing.
Multiple targets
This proposal is explicitly for a 1:1 binding between interactions and targets. Since our project goal is to make interactive experiences sharable across applications, we will need a lower granularity artifact that coordinates multiple targets, e.g. for a bottom sheet where a scrim changes as a layer drags. (These were previously called directors, and may now be called motion components.) Does this proposal have an opinion on what shape they would take, or how we could get there?
Note: if a target is a bag of properties with a particular shape, perhaps a {multiple target interaction} can require a bag of targets in a particular shape.
Proposal looks good! The only thing that I'm trying to wrap my head around is the "Interaction lifecycle" section. Would tying the lifecycle of the interaction to that of its target still work if its target was not a view, but say a ReactiveProperty?
Would tying the lifecycle of the interaction to that of its target still work if its target was not a view, but say a ReactiveProperty?
A stream that is subscribed to will only continue to emit values so long as the head of the stream is still alive, so if a ReactiveProperty-based stream's property is released, so will all of the subscribed observers.
I understand the desire to have enable()/disable() methods in L1 APIs - most L1 users probably don't think in terms of streams. Still, I think it's important that we expose a reactive API - both for composition and to be approachable and interoperable for reactive programmers.
We can accomplish this by ensuring that every interaction which affects only a single property exposes a subscribe(observer) API, and that its enable/disable methods use this API to connect themselves.
Agreed that this may be a desirable thing. This feels like something we can add to the proposal as a feature over time.
If you were writing docs for enabled and paused, how would you explain the difference between them? Having multiple booleans that each map to some form of "can I use this thing right now?" is inherently confusing. (That doesn't mean it isn't the right approach, but it's a subtle difference that requires reading documentation, so it warrants extra skepticism.)
Our documentation will introduce enabled/disabled as the mechanism by which you ensure that an interaction becomes generally-reactive. Pausing an enabled interaction will not stop it from being reactive, though it may stop it from continuing its simulation (e.g. with a spring). Not all interactions will be pausable - likely just Spring for now. Tweens can probably also be pausable and scrubbable.
You have examples like Reactive(tap) and Reactive(gesture). Presumably, these are gesture delegates that expose streams for properties like didRecognize. Would an L1 user need to use the Reactive versions? How would they know to do this?
No, users won't have to use these reactive wrappers. These APIs are declarative sugar that we'll likely introduce late into any documentation.
You specify that changing the properties of an interaction may or may not change the behavior of an interaction after it's initialized - if that's the case, those are probably better suited to constructor arguments than properties. There may be some nuance in the proposal that I'm missing.
To be clear, properties may not change the behavior after it's been enabled. E.g. changing the gesture recognizer for an interaction after it's been enabled may require that you disable and re-enable the interaction in order for it to take effect.
That being said, I think we'll probably prefer erring on the side of reactivity in practice. The challenge is identifying what the reasonable behavior is when changing an interaction mid-flight - this is why I feel that these behaviors are something we'll need to spec out on a per-property basis.
This proposal is explicitly for a 1:1 binding between interactions and targets. Since our project goal is to make interactive experiences sharable across applications, we will need a lower granularity artifact that coordinates multiple targets, e.g. for a bottom sheet where a scrim changes as a layer drags. (These were previously called directors, and may now be called motion components.) Does this proposal have an opinion on what shape they would take, or how we could get there?
1:1 binding doesn't necessarily mean one object to one target. It could be one interaction bound to a collection of objects, e.g. a RadialReveal interaction might be provided with an array of views as its target.
Additional proposal: make the v1 Transition type a specialized Interaction type, one which accepts a transition context as a target.
protocol Transition: Interaction, Stateful {
init(context: TransitionContext)
}
transitionController.type = ModalTransition.self
// When presentation occurs:
let transition = transitionController.type()
transition.enable()
transition.state.subscribe {
if $0 == .atRest {
transition.disable()
// terminate the transition
}
}
Our documentation will introduce enabled/disabled as the mechanism by which you ensure that an interaction becomes generally-reactive. Pausing an enabled interaction will not stop it from being reactive, though it may stop it from continuing its simulation (e.g. with a spring). Not all interactions will be pausable - likely just Spring for now. Tweens can probably also be pausable and scrubbable.
What does it mean to be reactive but paused? If a spring is disabled and initialVelocity$ dispatches, does the spring update its internal state to reflect that velocity, or completely ignore it? What about when paused?
To be clear, properties may not change the behavior after it's been enabled. E.g. changing the gesture recognizer for an interaction after it's been enabled may require that you disable and re-enable the interaction in order for it to take effect.
This feels a bit nuanced/hand-wavey, but I'm OK with moving forward and worrying about this when we have a concrete problem.
(I'm presuming the transition example is iOS only, so I'm not concerned about it.)
What does it mean to be reactive but paused? If a spring is disabled and initialVelocity$ dispatches, does the spring update its internal state to reflect that velocity, or completely ignore it? What about when paused?
The disabled behavior will depend on the stream's behavior. If the velocity stream always emits a value on subscription, then the spring will receive that value when enabled.
The paused behavior is as expected: when paused, the velocity will be updated but the spring won't change its behavior in any way.
This feels a bit nuanced/hand-wavey, but I'm OK with moving forward and worrying about this when we have a concrete problem.
Yep - I'm not sure how strict we want to be on this part of the v2 spec just yet.
👍
👍
One general problem we discussed with this proposal during eng review today was about the fact that interactions will live for as long as their targets.
If we a consider a Transition as a transient operation, then it's undesirable for the motion described during the transition to continue to exist after the transition completes. Unfortunately, based on the way interaction lifecycles are defined with this spec, unless the interactions are explicitly disabled at the end of the transition they will continue to live for the lifetime of the view. This won't be problematic for simple interactions as they'll just be inert, but for complex connected interactions with constraints and dependencies we can easily get into situations where our interactions exert their behavior beyond the lifetime of the transition. It's unlikely that this is desirable.
Possible options to be considered:
- Require that transitions register their interactions with the transition coordinator. The transition coordinator will disable all interactions when the transition terminates.
- Make transitions disable themselves when they go out of scope.
The reason that the official Observable implementation has a complete channel is to alert observers that they are going to be forcibly unsubscribed. This means you can call headSubject.complete() and have headSubject.op1().op2().op3().subscribe(observer) be torn down.
If we're talking about nesting Interactions, do we need a similar pattern, where I can trust that calling tossable.disable() or tossable.subscribe().unsubscribe() will also disable nested Interations?
That's the current convention I've been exploring, yes. Anything that an interaction enables/subscribes to should be disabled/unsubscribed when the interaction is disabled.