diff --git a/examples/ContextualTransitionExample.swift b/examples/ContextualTransitionExample.swift index 2dac4e2..db4ba3e 100644 --- a/examples/ContextualTransitionExample.swift +++ b/examples/ContextualTransitionExample.swift @@ -285,7 +285,7 @@ private class PushBackTransition: Transition { let size = TransitionSpring(back: contextView.bounds.size, fore: fitSize, direction: ctx.direction) runtime.toggle(size, inReactionTo: draggable) - runtime.add(size, to: runtime.get(replicaView).reactiveLayer.size) + runtime.add(size, to: runtime.get(replicaView).layer.size) let opacity = TransitionSpring(back: 0, fore: 1, direction: ctx.direction) runtime.add(opacity, to: runtime.get(ctx.fore.view.layer).opacity) diff --git a/examples/FabTransitionExample.swift b/examples/FabTransitionExample.swift index b74b443..fd6189c 100644 --- a/examples/FabTransitionExample.swift +++ b/examples/FabTransitionExample.swift @@ -124,14 +124,14 @@ private class CircularRevealTransition: Transition { let foreShadowPath = CGRect(origin: .zero(), size: expandedSize) let shadowPath = tween(back: floodFillView.layer.shadowPath!, fore: UIBezierPath(ovalIn: foreShadowPath).cgPath, ctx: ctx) - let floodLayer = runtime.get(floodFillView).reactiveLayer + let floodLayer = runtime.get(floodFillView).layer runtime.add(expansion, to: floodLayer.size) runtime.add(fadeOut, to: floodLayer.opacity) runtime.add(radius, to: floodLayer.cornerRadius) runtime.add(shadowPath, to: floodLayer.shadowPath) let shiftIn = tween(back: ctx.fore.view.layer.position.y + 40, fore: ctx.fore.view.layer.position.y, ctx: ctx) - runtime.add(shiftIn, to: runtime.get(ctx.fore.view).reactiveLayer.positionY) + runtime.add(shiftIn, to: runtime.get(ctx.fore.view).layer.positionY) let maskShiftIn = tween(back: CGFloat(-40), fore: CGFloat(0), ctx: ctx) runtime.add(maskShiftIn, to: runtime.get(maskLayer).positionY) diff --git a/src/MotionRuntime.swift b/src/MotionRuntime.swift index 360b403..83850c8 100644 --- a/src/MotionRuntime.swift +++ b/src/MotionRuntime.swift @@ -170,24 +170,24 @@ public final class MotionRuntime { // MARK: Reactive object storage /** - Returns a reactive version of the given object and caches the returned result for future access. + Returns a reactive version of the given object. */ - public func get(_ view: UIView) -> ReactiveUIView { - return get(view) { .init($0, runtime: self) } + public func get(_ view: UIView) -> Reactive { + return Reactive(view) } /** - Returns a reactive version of the given object and caches the returned result for future access. + Returns a reactive version of the given object. */ - public func get(_ layer: CALayer) -> ReactiveCALayer { - return get(layer) { .init($0) } + public func get(_ layer: CALayer) -> Reactive { + return Reactive(layer) } /** - Returns a reactive version of the given object and caches the returned result for future access. + Returns a reactive version of the given object. */ - public func get(_ layer: CAShapeLayer) -> ReactiveCAShapeLayer { - return get(layer) { .init($0) } + public func get(_ layer: CAShapeLayer) -> Reactive { + return Reactive(layer) } /** diff --git a/src/interactions/Draggable.swift b/src/interactions/Draggable.swift index ff882a7..8bb0c1f 100644 --- a/src/interactions/Draggable.swift +++ b/src/interactions/Draggable.swift @@ -69,7 +69,7 @@ public final class Draggable: Gesturable, Interaction, T guard let gestureRecognizer = dequeueGestureRecognizer(withReactiveView: reactiveView) else { return } - let position = reactiveView.reactiveLayer.position + let position = reactiveView.layer.position runtime.connect(enabled, to: ReactiveProperty(initialValue: gestureRecognizer.isEnabled) { enabled in gestureRecognizer.isEnabled = enabled diff --git a/src/interactions/Rotatable.swift b/src/interactions/Rotatable.swift index ea7388d..3a27f90 100644 --- a/src/interactions/Rotatable.swift +++ b/src/interactions/Rotatable.swift @@ -38,7 +38,7 @@ public final class Rotatable: Gesturable, Interacti guard let gestureRecognizer = dequeueGestureRecognizer(withReactiveView: reactiveView) else { return } - let rotation = reactiveView.reactiveLayer.rotation + let rotation = reactiveView.layer.rotation runtime.connect(enabled, to: ReactiveProperty(initialValue: gestureRecognizer.isEnabled) { enabled in gestureRecognizer.isEnabled = enabled diff --git a/src/interactions/Scalable.swift b/src/interactions/Scalable.swift index 3b8e894..ac445db 100644 --- a/src/interactions/Scalable.swift +++ b/src/interactions/Scalable.swift @@ -38,7 +38,7 @@ public final class Scalable: Gesturable, Interaction, guard let gestureRecognizer = dequeueGestureRecognizer(withReactiveView: reactiveView) else { return } - let scale = reactiveView.reactiveLayer.scale + let scale = reactiveView.layer.scale runtime.connect(enabled, to: ReactiveProperty(initialValue: gestureRecognizer.isEnabled) { enabled in gestureRecognizer.isEnabled = enabled diff --git a/src/protocols/Gesturable.swift b/src/protocols/Gesturable.swift index a833712..2527f1f 100644 --- a/src/protocols/Gesturable.swift +++ b/src/protocols/Gesturable.swift @@ -124,13 +124,13 @@ public class Gesturable { /** Prepares and returns the gesture recognizer that should be used to drive this interaction. */ - func dequeueGestureRecognizer(withReactiveView reactiveView: ReactiveUIView) -> T? { + func dequeueGestureRecognizer(withReactiveView reactiveView: Reactive) -> T? { let gestureRecognizer = self.nextGestureRecognizer _nextGestureRecognizer = nil switch config { case .registerNewRecognizerToTargetView: - reactiveView.view.addGestureRecognizer(gestureRecognizer!) + reactiveView._object.addGestureRecognizer(gestureRecognizer!) default: () } diff --git a/src/reactivetypes/Reactive+CALayer.swift b/src/reactivetypes/Reactive+CALayer.swift new file mode 100644 index 0000000..ce5ac4b --- /dev/null +++ b/src/reactivetypes/Reactive+CALayer.swift @@ -0,0 +1,299 @@ +/* + Copyright 2016-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import UIKit + +extension Reactive where O: CALayer { + + public var anchorPoint: ReactiveProperty { + let layer = _object + return _properties.named(#function) { + return createCoreAnimationProperty(#function, + initialValue: layer.anchorPoint, + externalWrite: { layer.anchorPoint = $0 }, + keyPath: "anchorPoint") + } + } + + public var anchorPointAdjustment: ReactiveProperty { + let anchorPoint = self.anchorPoint + let position = self.position + let layer = _object + return _properties.named(#function) { + return .init("\(pretty(layer)).\(#function)", initialValue: .init(anchorPoint: anchorPoint.value, position: position.value)) { + anchorPoint.value = $0.anchorPoint; position.value = $0.position + } + } + } + + public var cornerRadius: ReactiveProperty { + let layer = _object + return _properties.named(#function) { + return createCoreAnimationProperty(#function, + initialValue: layer.cornerRadius, + externalWrite: { layer.cornerRadius = $0 }, + keyPath: "cornerRadius") + } + } + + public var height: ReactiveProperty { + let size = self.size + return _properties.named(#function) { + return createCoreAnimationProperty(#function, + initialValue: size.value.height, + externalWrite: { var dimensions = size.value; dimensions.height = $0; size.value = dimensions }, + keyPath: "bounds.size.height") + } + } + + public var opacity: ReactiveProperty { + let layer = _object + return _properties.named(#function) { + return createCoreAnimationProperty(#function, + initialValue: CGFloat(layer.opacity), + externalWrite: { layer.opacity = Float($0) }, + keyPath: "opacity") + } + } + + public var position: ReactiveProperty { + let layer = _object + return _properties.named(#function) { + return createCoreAnimationProperty(#function, + initialValue: layer.position, + externalWrite: { layer.position = $0 }, + keyPath: "position") + } + } + + public var positionX: ReactiveProperty { + let position = self.position + return _properties.named(#function) { + return createCoreAnimationProperty(#function, + initialValue: position.value.x, + externalWrite: { var point = position.value; point.x = $0; position.value = point }, + keyPath: "position.x") + } + } + + public var positionY: ReactiveProperty { + let position = self.position + return _properties.named(#function) { + return createCoreAnimationProperty(#function, + initialValue: position.value.y, + externalWrite: { var point = position.value; point.y = $0; position.value = point }, + keyPath: "position.y") + } + } + + public var rotation: ReactiveProperty { + let layer = _object + return _properties.named(#function) { + return createCoreAnimationProperty(#function, + initialValue: layer.value(forKeyPath: "transform.rotation.z") as! CGFloat, + externalWrite: { layer.setValue($0, forKeyPath: "transform.rotation.z") }, + keyPath: "transform.rotation.z") + } + } + + public var scale: ReactiveProperty { + let layer = _object + return _properties.named(#function) { + return createCoreAnimationProperty(#function, + initialValue: layer.value(forKeyPath: "transform.scale") as! CGFloat, + externalWrite: { layer.setValue($0, forKeyPath: "transform.scale") }, + keyPath: "transform.scale.xy") + } + } + + public var size: ReactiveProperty { + let layer = _object + return _properties.named(#function) { + return createCoreAnimationProperty(#function, + initialValue: layer.bounds.size, + externalWrite: { layer.bounds.size = $0 }, + keyPath: "bounds.size") + } + } + + public var shadowPath: ReactiveProperty { + let layer = _object + return _properties.named(#function) { + return createCoreAnimationProperty(#function, + initialValue: layer.shadowPath!, + externalWrite: { layer.shadowPath = $0 }, + keyPath: "shadowPath") + } + } + + public var width: ReactiveProperty { + let size = self.size + return _properties.named(#function) { + return createCoreAnimationProperty(#function, + initialValue: size.value.width, + externalWrite: { var dimensions = size.value; dimensions.width = $0; size.value = dimensions }, + keyPath: "bounds.size.width") + } + } + + func createCoreAnimationProperty(_ name: String, initialValue: T, externalWrite: @escaping NextChannel, keyPath: String) -> ReactiveProperty { + let layer = _object + var decomposedKeys = Set() + let property = ReactiveProperty("\(pretty(layer)).\(name)", initialValue: initialValue, externalWrite: { value in + let actionsWereDisabled = CATransaction.disableActions() + CATransaction.setDisableActions(true) + externalWrite(value) + CATransaction.setDisableActions(actionsWereDisabled) + }, coreAnimation: { event in + switch event { + case .add(let info): + if let timeline = info.timeline { + layer.timeline = timeline + } + + let animation = info.animation.copy() as! CAPropertyAnimation + + animation.duration *= TimeInterval(simulatorDragCoefficient()) + + if layer.speed == 0, let lastTimelineState = layer.lastTimelineState { + animation.beginTime = TimeInterval(lastTimelineState.beginTime) + animation.beginTime + } else { + animation.beginTime = layer.convertTime(CACurrentMediaTime(), from: nil) + animation.beginTime + } + + animation.keyPath = keyPath + + if let unsafeMakeAdditive = info.makeAdditive { + let makeAdditive: ((Any, Any) -> Any) = { from, to in + // When mapping properties to properties it's possible for the values to get implicitly + // wrapped in an NSNumber instance. This may cause the generic makeAdditive + // implementation to fail to cast to T, so we unbox the type here instead. + if let from = from as? NSNumber, let to = to as? NSNumber { + return from.doubleValue - to.doubleValue + } + return unsafeMakeAdditive(from, to) + } + + if let basicAnimation = animation as? CABasicAnimation { + basicAnimation.fromValue = makeAdditive(basicAnimation.fromValue!, basicAnimation.toValue!) + basicAnimation.toValue = makeAdditive(basicAnimation.toValue!, basicAnimation.toValue!) + basicAnimation.isAdditive = true + + } else if let keyframeAnimation = animation as? CAKeyframeAnimation { + let lastValue = keyframeAnimation.values!.last! + keyframeAnimation.values = keyframeAnimation.values!.map { makeAdditive($0, lastValue) } + keyframeAnimation.isAdditive = true + } + } + + // Core Animation springs do not support multi-dimensional velocity, so we bear the burden + // of decomposing multi-dimensional springs here. + if let springAnimation = animation as? CASpringAnimation + , springAnimation.isAdditive + , let initialVelocity = info.initialVelocity as? CGPoint + , let delta = springAnimation.fromValue as? CGPoint { + let decomposed = decompose(springAnimation: springAnimation, + delta: delta, + initialVelocity: initialVelocity) + + CATransaction.begin() + CATransaction.setCompletionBlock(info.onCompletion) + layer.add(decomposed.0, forKey: info.key + ".x") + layer.add(decomposed.1, forKey: info.key + ".y") + CATransaction.commit() + + decomposedKeys.insert(info.key) + return + } + + if let initialVelocity = info.initialVelocity { + applyInitialVelocity(initialVelocity, to: animation) + } + + CATransaction.begin() + CATransaction.setCompletionBlock(info.onCompletion) + layer.add(animation, forKey: info.key + "." + keyPath) + CATransaction.commit() + + case .remove(let key): + if let presentationLayer = layer.presentation() { + layer.setValue(presentationLayer.value(forKeyPath: keyPath), forKeyPath: keyPath) + } + if decomposedKeys.contains(key) { + layer.removeAnimation(forKey: key + ".x") + layer.removeAnimation(forKey: key + ".y") + decomposedKeys.remove(key) + + } else { + layer.removeAnimation(forKey: key + "." + keyPath) + } + } + }) + var lastView: UIView? + property.shouldVisualizeMotion = { view, containerView in + if lastView != view, let lastView = lastView { + lastView.removeFromSuperview() + } + view.isUserInteractionEnabled = false + if let superlayer = layer.superlayer { + view.frame = superlayer.convert(superlayer.bounds, to: containerView.layer) + } else { + view.frame = containerView.bounds + } + containerView.addSubview(view) + lastView = view + } + + return property + } +} + +private func decompose(springAnimation: CASpringAnimation, delta: CGPoint, initialVelocity: CGPoint) -> (CASpringAnimation, CASpringAnimation) { + let xAnimation = springAnimation.copy() as! CASpringAnimation + let yAnimation = springAnimation.copy() as! CASpringAnimation + xAnimation.fromValue = delta.x + yAnimation.fromValue = delta.y + xAnimation.toValue = 0 + yAnimation.toValue = 0 + + if delta.x != 0 { + xAnimation.initialVelocity = initialVelocity.x / -delta.x + } + if delta.y != 0 { + yAnimation.initialVelocity = initialVelocity.y / -delta.y + } + + xAnimation.keyPath = springAnimation.keyPath! + ".x" + yAnimation.keyPath = springAnimation.keyPath! + ".y" + + return (xAnimation, yAnimation) +} + +private func applyInitialVelocity(_ initialVelocity: Any, to animation: CAPropertyAnimation) { + if let springAnimation = animation as? CASpringAnimation, springAnimation.isAdditive { + // Additive animations have a toValue of 0 and a fromValue of negative delta (where the model + // value came from). + guard let initialVelocity = initialVelocity as? CGFloat, let delta = springAnimation.fromValue as? CGFloat else { + // Unsupported velocity type. + return + } + if delta != 0 { + // CASpringAnimation's initialVelocity is proportional to the distance to travel, i.e. our + // delta. + springAnimation.initialVelocity = initialVelocity / -delta + } + } +} diff --git a/src/reactivetypes/Reactive+CAShapeLayer.swift b/src/reactivetypes/Reactive+CAShapeLayer.swift new file mode 100644 index 0000000..c109b19 --- /dev/null +++ b/src/reactivetypes/Reactive+CAShapeLayer.swift @@ -0,0 +1,33 @@ +/* + Copyright 2016-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import UIKit + +import Foundation + +extension Reactive where O: CAShapeLayer { + + public var path: ReactiveProperty { + let layer = _object + return _properties.named(#function) { + return createCoreAnimationProperty(#function, + initialValue: layer.path!, + externalWrite: { layer.path = $0 }, + keyPath: "path") + } + } + +} diff --git a/src/reactivetypes/Reactive+UIView.swift b/src/reactivetypes/Reactive+UIView.swift new file mode 100644 index 0000000..76c8d75 --- /dev/null +++ b/src/reactivetypes/Reactive+UIView.swift @@ -0,0 +1,58 @@ +/* + Copyright 2016-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import UIKit + +extension Reactive where O: UIView { + + public var isUserInteractionEnabled: ReactiveProperty { + let view = _object + return _properties.named(#function) { + return .init("\(pretty(view)).\(#function)", initialValue: view.isUserInteractionEnabled) { + view.isUserInteractionEnabled = $0 + } + } + } + + public var backgroundColor: ReactiveProperty { + let view = _object + return _properties.named(#function) { + return .init("\(pretty(view)).\(#function)", initialValue: view.backgroundColor!) { + view.backgroundColor = $0 + } + } + } + + public var alpha: ReactiveProperty { + let view = _object + return _properties.named(#function) { + return .init("\(pretty(view)).\(#function)", initialValue: view.alpha) { + view.alpha = $0 + } + } + } + + public var layer: Reactive { + let view = _object + return Reactive(view.layer) + } + + @available(*, deprecated, message: "Use layer instead.") + public var reactiveLayer: Reactive { + let view = _object + return Reactive(view.layer) + } +} diff --git a/src/reactivetypes/Reactive.swift b/src/reactivetypes/Reactive.swift new file mode 100644 index 0000000..cd4ba92 --- /dev/null +++ b/src/reactivetypes/Reactive.swift @@ -0,0 +1,111 @@ +/* + Copyright 2016-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation + +/** + A reactive representation of an object uses a global cache to fetch reactive property instances. + + Reactive property instances for a given object are shared across reactive instances. E.g. + + Reactive(view).position === Reactive(view).position // true + + ## Memory considerations + + - Reactive instances keep a strong reference to their object. + - Reactive properties are weakly held by the global property cache. + + ## Extending this type + + Use protocol extensions to extend this type for specific object types. For example: + + extension Reactive where O: UIView { + + public var isUserInteractionEnabled: ReactiveProperty { + let view = _object + return _properties.named(#function) { + return .init(initialValue: view.isUserInteractionEnabled) { + view.isUserInteractionEnabled = $0 + } + } + } + */ +public final class Reactive { + + /** + Creates a reactive representation of the given object. + */ + public init(_ object: O) { + self._object = object + + if let cache = globalCache.object(forKey: object) { + self._properties = cache + } else { + let cache = ReactiveCache() + globalCache.setObject(cache, forKey: object) + self._properties = cache + } + } + + /** + The object backing this reactive instance. + */ + public let _object: O + + /** + The property cache for this object instance. + */ + public let _properties: ReactiveCache +} + +/** + A reactive cache is created for an object as weak storage for reactive properties. + + Properties can be queried by name. The cache does not keep a strong reference to the stored + properties. If no references are kept to a queried property then it will be released and a new + reactive property will be returned on a subsequent invocation. + */ +public final class ReactiveCache: CustomDebugStringConvertible { + + /** + Queries a property with a given name, creating a new instance if no property exists yet. + + onCacheMiss is invoked if the property is not cached. The returned reactive property will be + stored in the cache and returned. + */ + func named(_ name: String, onCacheMiss: () -> ReactiveProperty) -> ReactiveProperty { + if let property = cache.object(forKey: name as NSString) { + return property as! ReactiveProperty + } + let property = onCacheMiss() + cache.setObject(property, forKey: name as NSString) + return property + } + + // Reactive properties are weakly held because they hold a reference to the object. If we kept a + // strong reference to the property then the globalCache weak key for the object would never reach + // zero and we'd have a memory leak, even if the property, object, and reactive instance were all + // dereferenced in client code. + private let cache = NSMapTable(keyOptions: .strongMemory, + valueOptions: [.weakMemory, .objectPointerPersonality]) + + public var debugDescription: String { + return cache.debugDescription + } +} + +private var globalCache = NSMapTable(keyOptions: [.weakMemory, .objectPointerPersonality], + valueOptions: [.strongMemory, .objectPointerPersonality]) diff --git a/src/reactivetypes/ReactiveCALayer.swift b/src/reactivetypes/ReactiveCALayer.swift deleted file mode 100644 index 674ab22..0000000 --- a/src/reactivetypes/ReactiveCALayer.swift +++ /dev/null @@ -1,348 +0,0 @@ -/* - Copyright 2016-present The Material Motion Authors. All Rights Reserved. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ - -import IndefiniteObservable -import UIKit - -public class ReactiveCALayer { - public let layer: CALayer - - public lazy var cornerRadius: ReactiveProperty = { - let layer = self.layer - return createCoreAnimationProperty(#function, - initialValue: layer.cornerRadius, - externalWrite: { layer.cornerRadius = $0 }, - keyPath: "cornerRadius", - reactiveLayer: self) - }() - - public lazy var opacity: ReactiveProperty = { - let layer = self.layer - return createCoreAnimationProperty(#function, - initialValue: CGFloat(layer.opacity), - externalWrite: { layer.opacity = Float($0) }, - keyPath: "opacity", - reactiveLayer: self) - }() - - public lazy var position: ReactiveProperty = { - let layer = self.layer - return createCoreAnimationProperty(#function, - initialValue: layer.position, - externalWrite: { layer.position = $0 }, - keyPath: "position", - reactiveLayer: self) - }() - - public lazy var positionX: ReactiveProperty = { - let position = self.position - return createCoreAnimationProperty(#function, - initialValue: position.value.x, - externalWrite: { var point = position.value; point.x = $0; position.value = point }, - keyPath: "position.x", - reactiveLayer: self) - }() - - public lazy var positionY: ReactiveProperty = { - let position = self.position - return createCoreAnimationProperty(#function, - initialValue: position.value.y, - externalWrite: { var point = position.value; point.y = $0; position.value = point }, - keyPath: "position.y", - reactiveLayer: self) - }() - - public lazy var size: ReactiveProperty = { - let layer = self.layer - return createCoreAnimationProperty(#function, - initialValue: layer.bounds.size, - externalWrite: { layer.bounds.size = $0 }, - keyPath: "bounds.size", - reactiveLayer: self) - }() - - public lazy var width: ReactiveProperty = { - let size = self.size - return createCoreAnimationProperty(#function, - initialValue: size.value.width, - externalWrite: { var dimensions = size.value; dimensions.width = $0; size.value = dimensions }, - keyPath: "bounds.size.width", - reactiveLayer: self) - }() - - public lazy var height: ReactiveProperty = { - let size = self.size - return createCoreAnimationProperty(#function, - initialValue: size.value.height, - externalWrite: { var dimensions = size.value; dimensions.height = $0; size.value = dimensions }, - keyPath: "bounds.size.height", - reactiveLayer: self) - }() - - public lazy var anchorPoint: ReactiveProperty = { - let layer = self.layer - return createCoreAnimationProperty(#function, - initialValue: layer.anchorPoint, - externalWrite: { layer.anchorPoint = $0 }, - keyPath: "anchorPoint", - reactiveLayer: self) - }() - - public lazy var anchorPointAdjustment: ReactiveProperty = { - let anchorPoint = self.anchorPoint - let position = self.position - let layer = self.layer - return ReactiveProperty(#function, - initialValue: .init(anchorPoint: anchorPoint.value, position: position.value), - externalWrite: { anchorPoint.value = $0.anchorPoint; position.value = $0.position }) - }() - - public lazy var rotation: ReactiveProperty = { - let layer = self.layer - return createCoreAnimationProperty(#function, - initialValue: layer.value(forKeyPath: "transform.rotation.z") as! CGFloat, - externalWrite: { layer.setValue($0, forKeyPath: "transform.rotation.z") }, - keyPath: "transform.rotation.z", - reactiveLayer: self) - }() - - public lazy var scale: ReactiveProperty = { - let layer = self.layer - return createCoreAnimationProperty(#function, - initialValue: layer.value(forKeyPath: "transform.scale") as! CGFloat, - externalWrite: { layer.setValue($0, forKeyPath: "transform.scale") }, - keyPath: "transform.scale.xy", - reactiveLayer: self) - }() - - public lazy var shadowPath: ReactiveProperty = { - let layer = self.layer - return createCoreAnimationProperty(#function, - initialValue: layer.shadowPath!, - externalWrite: { layer.shadowPath = $0 }, - keyPath: "shadowPath", - reactiveLayer: self) - }() - - fileprivate var timeline: Timeline? { - didSet { - if oldValue === timeline { - return - } - guard let timeline = timeline else { - timelineSubscription = nil - return - } - - timelineSubscription = timeline.subscribeToValue { [weak self] state in - guard let strongSelf = self else { return } - strongSelf.lastTimelineState = state - - if state.paused { - strongSelf.layer.speed = 0 - strongSelf.layer.timeOffset = TimeInterval(state.beginTime + state.timeOffset) - - } else if strongSelf.layer.speed == 0 { // Unpause the layer. - // The following logic is the magic sauce required to reconnect a CALayer with the - // render server's clock. - let pausedTime = strongSelf.layer.timeOffset - strongSelf.layer.speed = 1 - strongSelf.layer.timeOffset = 0 - strongSelf.layer.beginTime = 0 - let timeSincePause = strongSelf.layer.convertTime(CACurrentMediaTime(), from: nil) - pausedTime - strongSelf.layer.beginTime = timeSincePause - } - } - } - } - fileprivate var decomposedKeys = Set() - fileprivate var lastTimelineState: Timeline.Snapshot? - private var timelineSubscription: Subscription? - - init(_ layer: CALayer) { - self.layer = layer - } -} - -/** - Creates a Core Animation-compatible reactive property instance. - */ -public func createCoreAnimationProperty(_ name: String, initialValue: T, externalWrite: @escaping NextChannel, keyPath: String, reactiveLayer: ReactiveCALayer) -> ReactiveProperty { - let layer = reactiveLayer.layer - let property = ReactiveProperty("\(pretty(reactiveLayer)).\(name)", initialValue: initialValue, externalWrite: { value in - let actionsWereDisabled = CATransaction.disableActions() - CATransaction.setDisableActions(true) - externalWrite(value) - CATransaction.setDisableActions(actionsWereDisabled) - }, coreAnimation: { [weak reactiveLayer] event in - guard let strongReactiveLayer = reactiveLayer else { return } - switch event { - case .add(let info): - if let timeline = info.timeline { - strongReactiveLayer.timeline = timeline - } - - let animation = info.animation.copy() as! CAPropertyAnimation - - animation.duration *= TimeInterval(simulatorDragCoefficient()) - - if layer.speed == 0, let lastTimelineState = strongReactiveLayer.lastTimelineState { - animation.beginTime = TimeInterval(lastTimelineState.beginTime) + animation.beginTime - } else { - animation.beginTime = layer.convertTime(CACurrentMediaTime(), from: nil) + animation.beginTime - } - - animation.keyPath = keyPath - - if let unsafeMakeAdditive = info.makeAdditive { - let makeAdditive: ((Any, Any) -> Any) = { from, to in - // When mapping properties to properties it's possible for the values to get implicitly - // wrapped in an NSNumber instance. This may cause the generic makeAdditive - // implementation to fail to cast to T, so we unbox the type here instead. - if let from = from as? NSNumber, let to = to as? NSNumber { - return from.doubleValue - to.doubleValue - } - return unsafeMakeAdditive(from, to) - } - - if let basicAnimation = animation as? CABasicAnimation { - basicAnimation.fromValue = makeAdditive(basicAnimation.fromValue!, basicAnimation.toValue!) - basicAnimation.toValue = makeAdditive(basicAnimation.toValue!, basicAnimation.toValue!) - basicAnimation.isAdditive = true - - } else if let keyframeAnimation = animation as? CAKeyframeAnimation { - let lastValue = keyframeAnimation.values!.last! - keyframeAnimation.values = keyframeAnimation.values!.map { makeAdditive($0, lastValue) } - keyframeAnimation.isAdditive = true - } - } - - // Core Animation springs do not support multi-dimensional velocity, so we bear the burden - // of decomposing multi-dimensional springs here. - if let springAnimation = animation as? CASpringAnimation - , springAnimation.isAdditive - , let initialVelocity = info.initialVelocity as? CGPoint - , let delta = springAnimation.fromValue as? CGPoint { - let decomposed = decompose(springAnimation: springAnimation, - delta: delta, - initialVelocity: initialVelocity) - - CATransaction.begin() - CATransaction.setCompletionBlock(info.onCompletion) - layer.add(decomposed.0, forKey: info.key + ".x") - layer.add(decomposed.1, forKey: info.key + ".y") - CATransaction.commit() - - strongReactiveLayer.decomposedKeys.insert(info.key) - return - } - - if let initialVelocity = info.initialVelocity { - applyInitialVelocity(initialVelocity, to: animation) - } - - CATransaction.begin() - CATransaction.setCompletionBlock(info.onCompletion) - layer.add(animation, forKey: info.key + "." + keyPath) - CATransaction.commit() - - case .remove(let key): - if let presentationLayer = layer.presentation() { - layer.setValue(presentationLayer.value(forKeyPath: keyPath), forKeyPath: keyPath) - } - if strongReactiveLayer.decomposedKeys.contains(key) { - layer.removeAnimation(forKey: key + ".x") - layer.removeAnimation(forKey: key + ".y") - strongReactiveLayer.decomposedKeys.remove(key) - - } else { - layer.removeAnimation(forKey: key + "." + keyPath) - } - } - }) - var lastView: UIView? - property.shouldVisualizeMotion = { view, containerView in - if lastView != view, let lastView = lastView { - lastView.removeFromSuperview() - } - view.isUserInteractionEnabled = false - if let superlayer = layer.superlayer { - view.frame = superlayer.convert(superlayer.bounds, to: containerView.layer) - } else { - view.frame = containerView.bounds - } - containerView.addSubview(view) - lastView = view - } - - return property -} - -public class ReactiveCAShapeLayer: ReactiveCALayer { - public let shapeLayer: CAShapeLayer - - /** A property representing the layer's .path value. */ - public lazy var path: ReactiveProperty = { - let layer = self.shapeLayer - return createCoreAnimationProperty(#function, - initialValue: layer.path!, - externalWrite: { layer.path = $0 }, - keyPath: "path", - reactiveLayer: self) - }() - - init(_ shapeLayer: CAShapeLayer) { - self.shapeLayer = shapeLayer - super.init(shapeLayer) - } -} - -private func decompose(springAnimation: CASpringAnimation, delta: CGPoint, initialVelocity: CGPoint) -> (CASpringAnimation, CASpringAnimation) { - let xAnimation = springAnimation.copy() as! CASpringAnimation - let yAnimation = springAnimation.copy() as! CASpringAnimation - xAnimation.fromValue = delta.x - yAnimation.fromValue = delta.y - xAnimation.toValue = 0 - yAnimation.toValue = 0 - - if delta.x != 0 { - xAnimation.initialVelocity = initialVelocity.x / -delta.x - } - if delta.y != 0 { - yAnimation.initialVelocity = initialVelocity.y / -delta.y - } - - xAnimation.keyPath = springAnimation.keyPath! + ".x" - yAnimation.keyPath = springAnimation.keyPath! + ".y" - - return (xAnimation, yAnimation) -} - -private func applyInitialVelocity(_ initialVelocity: Any, to animation: CAPropertyAnimation) { - if let springAnimation = animation as? CASpringAnimation, springAnimation.isAdditive { - // Additive animations have a toValue of 0 and a fromValue of negative delta (where the model - // value came from). - guard let initialVelocity = initialVelocity as? CGFloat, let delta = springAnimation.fromValue as? CGFloat else { - // Unsupported velocity type. - return - } - if delta != 0 { - // CASpringAnimation's initialVelocity is proportional to the distance to travel, i.e. our - // delta. - springAnimation.initialVelocity = initialVelocity / -delta - } - } -} diff --git a/src/reactivetypes/ReactiveUIView.swift b/src/reactivetypes/ReactiveUIView.swift deleted file mode 100644 index f36ff34..0000000 --- a/src/reactivetypes/ReactiveUIView.swift +++ /dev/null @@ -1,53 +0,0 @@ -/* - Copyright 2016-present The Material Motion Authors. All Rights Reserved. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ - -import UIKit - -public class ReactiveUIView { - public let view: UIView - - public lazy var isUserInteractionEnabled: ReactiveProperty = { - let view = self.view - return ReactiveProperty("\(pretty(view)).\(#function)", - initialValue: view.isUserInteractionEnabled, - externalWrite: { view.isUserInteractionEnabled = $0 }) - }() - - public lazy var backgroundColor: ReactiveProperty = { - let view = self.view - return ReactiveProperty("\(pretty(view)).\(#function)", - initialValue: view.backgroundColor!, - externalWrite: { view.backgroundColor = $0 }) - }() - - public lazy var alpha: ReactiveProperty = { - let view = self.view - return ReactiveProperty("\(pretty(view)).\(#function)", - initialValue: view.alpha, - externalWrite: { view.alpha = $0 }) - }() - - public lazy var reactiveLayer: ReactiveCALayer = { - return self.runtime?.get(self.view.layer) ?? ReactiveCALayer(self.view.layer) - }() - - init(_ view: UIView, runtime: MotionRuntime) { - self.view = view - self.runtime = runtime - } - - private weak var runtime: MotionRuntime? -} diff --git a/src/timeline/CALayer+Timeline.swift b/src/timeline/CALayer+Timeline.swift new file mode 100644 index 0000000..914f15a --- /dev/null +++ b/src/timeline/CALayer+Timeline.swift @@ -0,0 +1,68 @@ +/* + Copyright 2016-present The Material Motion Authors. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import IndefiniteObservable +import UIKit + +extension CALayer { + private class TimelineInfo { + var timeline: Timeline? + var lastState: Timeline.Snapshot? + var subscription: Subscription? + } + + private struct AssociatedKeys { + static var timelineInfo = "MDMTimelineInfo" + } + + var timeline: Timeline? { + get { return (objc_getAssociatedObject(self, &AssociatedKeys.timelineInfo) as? TimelineInfo)?.timeline } + set { + let timelineInfo = (objc_getAssociatedObject(self, &AssociatedKeys.timelineInfo) as? TimelineInfo) ?? TimelineInfo() + timelineInfo.timeline = newValue + objc_setAssociatedObject(self, &AssociatedKeys.timelineInfo, timelineInfo, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + guard let timeline = timelineInfo.timeline else { + timelineInfo.subscription = nil + return + } + + timelineInfo.subscription = timeline.subscribeToValue { [weak self] state in + guard let strongSelf = self else { return } + timelineInfo.lastState = state + + if state.paused { + strongSelf.speed = 0 + strongSelf.timeOffset = TimeInterval(state.beginTime + state.timeOffset) + + } else if strongSelf.speed == 0 { // Unpause the layer. + // The following logic is the magic sauce required to reconnect a CALayer with the + // render server's clock. + let pausedTime = strongSelf.timeOffset + strongSelf.speed = 1 + strongSelf.timeOffset = 0 + strongSelf.beginTime = 0 + let timeSincePause = strongSelf.convertTime(CACurrentMediaTime(), from: nil) - pausedTime + strongSelf.beginTime = timeSincePause + } + } + } + } + + var lastTimelineState: Timeline.Snapshot? { + return (objc_getAssociatedObject(self, &AssociatedKeys.timelineInfo) as? TimelineInfo)?.lastState + } +} diff --git a/tests/unit/MotionRuntimeTests.swift b/tests/unit/MotionRuntimeTests.swift index 58a2d8f..6dd9b8f 100644 --- a/tests/unit/MotionRuntimeTests.swift +++ b/tests/unit/MotionRuntimeTests.swift @@ -28,7 +28,7 @@ class MotionRuntimeTests: XCTestCase { let reactiveShapeLayer = runtime.get(shapeLayer) let reactiveCastedLayer = runtime.get(castedLayer) - XCTAssertTrue(reactiveShapeLayer === reactiveCastedLayer) + XCTAssertTrue(reactiveShapeLayer._properties === reactiveCastedLayer._properties) } func testInteractionsReturnsEmptyArrayWithoutAnyAddedInteractions() { diff --git a/tests/unit/ReactivePropertyTests.swift b/tests/unit/ReactivePropertyTests.swift index 328a9d5..b51b12c 100644 --- a/tests/unit/ReactivePropertyTests.swift +++ b/tests/unit/ReactivePropertyTests.swift @@ -113,4 +113,72 @@ class ReactivePropertyTests: XCTestCase { waitForExpectations(timeout: 0) } + + // MARK: Reactive objects + + func testReactivePropertyInstancesAreIdenticalAcrossInstances() { + let view = UIView() + XCTAssertTrue(Reactive(view).isUserInteractionEnabled === Reactive(view).isUserInteractionEnabled) + } + + func testPropertiesReleasedWhenDereferenced() { + let view = UIView() + var prop1: ReactiveProperty? = Reactive(view).isUserInteractionEnabled + let objectIdentifier = ObjectIdentifier(prop1!) + + prop1 = nil + + let prop2 = Reactive(view).isUserInteractionEnabled + XCTAssertTrue(objectIdentifier != ObjectIdentifier(prop2)) + } + + func testObjectRetainedByReactiveType() { + var reactive: Reactive? + weak var weakView: UIView? + + autoreleasepool { + let view = UIView() + weakView = view + reactive = Reactive(view) + } + + XCTAssertNotNil(weakView) + XCTAssertNotNil(reactive) + } + + func testObjectReleasedWhenReactiveTypeReleased() { + var reactive: Reactive? + weak var weakView: UIView? + + let allocate = { + let view = UIView() + weakView = view + reactive = Reactive(view) + } + allocate() + + reactive = nil + + XCTAssertNil(weakView) + + // Resolve compiler warning about not reading reactive after writing to it. + XCTAssertNil(reactive) + } + + func testReactiveObjectNotGloballyRetained() { + let view = UIView() + weak var weakReactive: Reactive? = Reactive(view) + + XCTAssertNil(weakReactive) + } + + func testObjectNotGloballyRetained() { + var view: UIView? = UIView() + weak var weakView: UIView? = view + let _ = Reactive(view!) + + view = nil + + XCTAssertNil(weakView) + } }