diff --git a/examples/apps/Catalog/MotionAnimatorCatalog.xcodeproj/project.pbxproj b/examples/apps/Catalog/MotionAnimatorCatalog.xcodeproj/project.pbxproj index 355e98e..ae53e92 100644 --- a/examples/apps/Catalog/MotionAnimatorCatalog.xcodeproj/project.pbxproj +++ b/examples/apps/Catalog/MotionAnimatorCatalog.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 667A3F4C1DEE269400CB3A99 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 667A3F4A1DEE269400CB3A99 /* LaunchScreen.storyboard */; }; 667A3F541DEE273000CB3A99 /* TableOfContents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 667A3F531DEE273000CB3A99 /* TableOfContents.swift */; }; 6687264A1EF04B4C00113675 /* MotionAnimatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668726491EF04B4C00113675 /* MotionAnimatorTests.swift */; }; + 66BF5A8F1FB0E4CB00E864F6 /* ImplicitAnimationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66BF5A8E1FB0E4CB00E864F6 /* ImplicitAnimationTests.swift */; }; 66DD4BF51EEF0ECB00207119 /* CalendarCardExpansionExample.m in Sources */ = {isa = PBXBuildFile; fileRef = 66DD4BF41EEF0ECB00207119 /* CalendarCardExpansionExample.m */; }; 66DD4BF81EEF1C4B00207119 /* CalendarChipMotionSpec.m in Sources */ = {isa = PBXBuildFile; fileRef = 66DD4BF71EEF1C4B00207119 /* CalendarChipMotionSpec.m */; }; 66FD99FA1EE9FBBE00C53A82 /* MotionAnimatorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 66FD99F91EE9FBBE00C53A82 /* MotionAnimatorTests.m */; }; @@ -59,6 +60,7 @@ 667A3F4D1DEE269400CB3A99 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 667A3F531DEE273000CB3A99 /* TableOfContents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TableOfContents.swift; sourceTree = ""; }; 668726491EF04B4C00113675 /* MotionAnimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MotionAnimatorTests.swift; sourceTree = ""; }; + 66BF5A8E1FB0E4CB00E864F6 /* ImplicitAnimationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImplicitAnimationTests.swift; sourceTree = ""; }; 66DD4BF31EEF0ECB00207119 /* CalendarCardExpansionExample.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CalendarCardExpansionExample.h; sourceTree = ""; }; 66DD4BF41EEF0ECB00207119 /* CalendarCardExpansionExample.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CalendarCardExpansionExample.m; sourceTree = ""; }; 66DD4BF61EEF1C4B00207119 /* CalendarChipMotionSpec.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CalendarChipMotionSpec.h; sourceTree = ""; }; @@ -206,6 +208,7 @@ children = ( 66FD99F91EE9FBBE00C53A82 /* MotionAnimatorTests.m */, 668726491EF04B4C00113675 /* MotionAnimatorTests.swift */, + 66BF5A8E1FB0E4CB00E864F6 /* ImplicitAnimationTests.swift */, 660636011FACC24300C3DFB8 /* TimeScaleFactorTests.swift */, ); path = unit; @@ -483,6 +486,7 @@ buildActionMask = 2147483647; files = ( 660636021FACC24300C3DFB8 /* TimeScaleFactorTests.swift in Sources */, + 66BF5A8F1FB0E4CB00E864F6 /* ImplicitAnimationTests.swift in Sources */, 6687264A1EF04B4C00113675 /* MotionAnimatorTests.swift in Sources */, 66FD99FA1EE9FBBE00C53A82 /* MotionAnimatorTests.m in Sources */, ); diff --git a/src/MDMMotionAnimator.h b/src/MDMMotionAnimator.h index cf08b97..a24f19a 100644 --- a/src/MDMMotionAnimator.h +++ b/src/MDMMotionAnimator.h @@ -103,4 +103,29 @@ NS_SWIFT_NAME(MotionAnimator) keyPath:(nonnull MDMAnimatableKeyPath)keyPath completion:(nullable void(^)(void))completion; +/** + Performs `animations` using the timing provided. + + @param timing The timing to be used for the animation. + @param animations The block to be executed. Any animatable properties changed within this block + will result in animations being added to the view's layer with the provided timing. The block is + non-escaping. + */ +- (void)animateWithTiming:(MDMMotionTiming)timing animations:(nonnull void(^)(void))animations; + +/** + Performs `animations` using the timing provided and executes the completion handler once all added + animations have completed. + + @param timing The timing to be used for the animation. + @param animations The block to be executed. Any animatable properties changed within this block + will result in animations being added to the view's layer with the provided timing. The block is + non-escaping. + @param completion The completion handler will be executed once all added animations have come to + rest. The block is escaping and will be released once the animations have completed. + */ +- (void)animateWithTiming:(MDMMotionTiming)timing + animations:(nonnull void (^)(void))animations + completion:(nullable void(^)(void))completion; + @end diff --git a/src/MDMMotionAnimator.m b/src/MDMMotionAnimator.m index 2e3b067..2cf4169 100644 --- a/src/MDMMotionAnimator.m +++ b/src/MDMMotionAnimator.m @@ -21,6 +21,7 @@ #import "CATransaction+MotionAnimator.h" #import "private/CABasicAnimation+MotionAnimator.h" #import "private/MDMUIKitValueCoercion.h" +#import "private/MDMBlockAnimations.h" #import "private/MDMDragCoefficient.h" @implementation MDMMotionAnimator { @@ -120,6 +121,29 @@ - (void)animateWithTiming:(MDMMotionTiming)timing [CATransaction commit]; } +- (void)animateWithTiming:(MDMMotionTiming)timing animations:(void (^)(void))animations { + [self animateWithTiming:timing animations:animations completion:nil]; +} + +- (void)animateWithTiming:(MDMMotionTiming)timing + animations:(void (^)(void))animations + completion:(void(^)(void))completion { + NSArray *actions = MDMAnimateImplicitly(animations); + + [CATransaction begin]; + [CATransaction setCompletionBlock:completion]; + + for (MDMImplicitAction *action in actions) { + id currentValue = [action.layer valueForKeyPath:action.keyPath]; + [self animateWithTiming:timing + toLayer:action.layer + withValues:@[action.initialValue, currentValue] + keyPath:action.keyPath]; + } + + [CATransaction commit]; +} + - (void)addCoreAnimationTracer:(void (^)(CALayer *, CAAnimation *))tracer { if (!_tracers) { _tracers = [NSMutableArray array]; diff --git a/src/private/MDMBlockAnimations.h b/src/private/MDMBlockAnimations.h new file mode 100644 index 0000000..1be1125 --- /dev/null +++ b/src/private/MDMBlockAnimations.h @@ -0,0 +1,26 @@ +/* + Copyright 2017-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 +#import + +@interface MDMImplicitAction: NSObject +@property(nonatomic, strong, readonly) id initialValue; +@property(nonatomic, copy, readonly) NSString *keyPath; +@property(nonatomic, strong, readonly) CALayer *layer; +@end + +NSArray *MDMAnimateImplicitly(void (^animations)(void)); diff --git a/src/private/MDMBlockAnimations.m b/src/private/MDMBlockAnimations.m new file mode 100644 index 0000000..693930f --- /dev/null +++ b/src/private/MDMBlockAnimations.m @@ -0,0 +1,132 @@ +/* + Copyright 2017-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 "MDMBlockAnimations.h" + +#import +#import + +static IMP sOriginalActionForLayerImp = NULL; + +@interface MDMActionContext: NSObject +@property(nonatomic, readonly) NSArray *interceptedActions; +@end + +@implementation MDMImplicitAction + +- (instancetype)initWithLayer:(CALayer *)layer + keyPath:(NSString *)keyPath + initialValue:(id)initialValue { + self = [super init]; + if (self) { + _layer = layer; + _keyPath = [keyPath copy]; + _initialValue = initialValue; + } + return self; +} + +@end + +@implementation MDMActionContext { + NSMutableArray *_interceptedActions; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _interceptedActions = [NSMutableArray array]; + } + return self; +} + +- (void)addActionForLayer:(CALayer *)layer + keyPath:(NSString *)keyPath + withInitialValue:(id)initialValue { + [_interceptedActions addObject:[[MDMImplicitAction alloc] initWithLayer:layer + keyPath:keyPath + initialValue:initialValue]]; +} + +- (NSArray *)interceptedActions { + return [_interceptedActions copy]; +} + +@end + +static NSMutableArray *sActionContext = nil; + +static id ActionForLayer(id self, SEL _cmd, CALayer *layer, NSString *event) { + NSCAssert([NSStringFromSelector(_cmd) isEqualToString: + NSStringFromSelector(@selector(actionForLayer:forKey:))], + @"Invalid method signature."); + + MDMActionContext *context = [sActionContext lastObject]; + NSCAssert(context != nil, @"MotionAnimator action method invoked out of implicit scope."); + + if (context == nil) { + // Graceful handling of invalid state on non-debug builds for if our context is nil invokes our + // original implementation: + return ((id(*)(id, SEL, CALayer *, NSString *))sOriginalActionForLayerImp) + (self, _cmd, layer, event); + } + + // We don't have access to the "to" value of our animation here, so we unfortunately can't + // calculate additive values if the animator is configured as such. So, to support additive + // animations, we queue up the modified actions and then add them all at the end of our + // MDMAnimateBlock invocation. + id initialValue = [layer valueForKeyPath:event]; + [context addActionForLayer:layer keyPath:event withInitialValue:initialValue]; + return [NSNull null]; +} + +NSArray *MDMAnimateImplicitly(void (^work)(void)) { + if (!work) { + return nil; + } + + // This method can be called recursively, so we maintain a recursive context stack in the scope of + // this method. Note that this is absolutely not thread safe, but neither is Core Animation. + if (!sActionContext) { + sActionContext = [NSMutableArray array]; + } + [sActionContext addObject:[[MDMActionContext alloc] init]]; + + SEL selector = @selector(actionForLayer:forKey:); + Method method = class_getInstanceMethod([UIView class], selector); + + if (sOriginalActionForLayerImp == nil) { + // Swap the original UIView implementation with our own so that we can intercept all + // actionForLayer:forKey: events. All events will be + sOriginalActionForLayerImp = method_setImplementation(method, (IMP)ActionForLayer); + } + + work(); + + // Return any intercepted actions we received during the invocation of work. + MDMActionContext *context = [sActionContext lastObject]; + [sActionContext removeLastObject]; + + if ([sActionContext count] == 0) { + // Restore our original method if we've emptied the stack: + method_setImplementation(method, sOriginalActionForLayerImp); + + sOriginalActionForLayerImp = nil; + sActionContext = nil; + } + + return context.interceptedActions; +} diff --git a/tests/unit/ImplicitAnimationTests.swift b/tests/unit/ImplicitAnimationTests.swift new file mode 100644 index 0000000..cdda68d --- /dev/null +++ b/tests/unit/ImplicitAnimationTests.swift @@ -0,0 +1,187 @@ +/* + Copyright 2017-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 XCTest + +import MotionAnimator + +class ImplicitAnimationTests: XCTestCase { + var animator: MotionAnimator! + var timing: MotionTiming! + var view: UIView! + var addedAnimations: [CAAnimation]! + + var originalImplementation: IMP? + override func setUp() { + super.setUp() + + animator = MotionAnimator() + animator.additive = false + + timing = MotionTiming(delay: 0, + duration: 0.7, + curve: .init(type: .bezier, data: (0, 0, 1, 1)), + repetition: .init(type: .none, amount: 0, autoreverses: false)) + + let window = UIWindow() + window.makeKeyAndVisible() + view = UIView() // Need to animate a view's layer to get implicit animations. + window.addSubview(view) + + addedAnimations = [] + animator.addCoreAnimationTracer { (_, animation) in + self.addedAnimations.append(animation) + } + + originalImplementation = + class_getMethodImplementation(UIView.self, #selector(UIView.action(for:forKey:))) + } + + override func tearDown() { + let implementation = + class_getMethodImplementation(UIView.self, #selector(UIView.action(for:forKey:))) + XCTAssertEqual(originalImplementation, implementation) + + animator = nil + view = nil + addedAnimations = nil + + super.tearDown() + } + + func testNoActionAddsNoAnimations() { + animator.animate(with: timing) { + // No-op + } + + XCTAssertEqual(addedAnimations.count, 0) + } + + func testOneActionAddsOneAnimation() { + animator.animate(with: timing) { + self.view.alpha = 0 + } + + XCTAssertEqual(addedAnimations.count, 1) + let animation = addedAnimations.first as! CABasicAnimation + XCTAssertEqual(animation.keyPath, AnimatableKeyPath.opacity.rawValue) + XCTAssertEqual(animation.fromValue as! CGFloat, 1) + XCTAssertEqual(animation.toValue as! CGFloat, 0) + XCTAssertEqual(animation.duration, timing.duration) + + let addedCurve = MotionCurve(fromTimingFunction: animation.timingFunction!) + XCTAssertEqual(addedCurve.type, timing.curve.type) + XCTAssertEqual(addedCurve.data.0, timing.curve.data.0) + XCTAssertEqual(addedCurve.data.1, timing.curve.data.1) + XCTAssertEqual(addedCurve.data.2, timing.curve.data.2) + XCTAssertEqual(addedCurve.data.3, timing.curve.data.3) + } + + func testTwoActionsAddsTwoAnimations() { + animator.animate(with: timing) { + self.view.alpha = 0 + self.view.center = .init(x: 50, y: 50) + } + + XCTAssertEqual(addedAnimations.count, 2) + + do { + let animation = addedAnimations.first as! CABasicAnimation + XCTAssertFalse(animation.isAdditive) + XCTAssertEqual(animation.keyPath, AnimatableKeyPath.opacity.rawValue) + XCTAssertEqual(animation.fromValue as! CGFloat, 1) + XCTAssertEqual(animation.toValue as! CGFloat, 0) + XCTAssertEqual(animation.duration, timing.duration) + + let addedCurve = MotionCurve(fromTimingFunction: animation.timingFunction!) + XCTAssertEqual(addedCurve.type, timing.curve.type) + XCTAssertEqual(addedCurve.data.0, timing.curve.data.0) + XCTAssertEqual(addedCurve.data.1, timing.curve.data.1) + XCTAssertEqual(addedCurve.data.2, timing.curve.data.2) + XCTAssertEqual(addedCurve.data.3, timing.curve.data.3) + } + do { + let animation = addedAnimations[1] as! CABasicAnimation + XCTAssertFalse(animation.isAdditive) + XCTAssertEqual(animation.keyPath, AnimatableKeyPath.position.rawValue) + XCTAssertEqual(animation.fromValue as! CGPoint, .init(x: 0, y: 0)) + XCTAssertEqual(animation.toValue as! CGPoint, .init(x: 50, y: 50)) + XCTAssertEqual(animation.duration, timing.duration) + + let addedCurve = MotionCurve(fromTimingFunction: animation.timingFunction!) + XCTAssertEqual(addedCurve.type, timing.curve.type) + XCTAssertEqual(addedCurve.data.0, timing.curve.data.0) + XCTAssertEqual(addedCurve.data.1, timing.curve.data.1) + XCTAssertEqual(addedCurve.data.2, timing.curve.data.2) + XCTAssertEqual(addedCurve.data.3, timing.curve.data.3) + } + } + + func testFrameActionAddsTwoAnimations() { + animator.animate(with: timing) { + self.view.frame = .init(x: 0, y: 0, width: 100, height: 100) + } + + XCTAssertEqual(addedAnimations.count, 2) + + do { + let animation = addedAnimations + .flatMap { $0 as? CABasicAnimation } + .first(where: { $0.keyPath == AnimatableKeyPath.position.rawValue})! + XCTAssertFalse(animation.isAdditive) + XCTAssertEqual(animation.fromValue as! CGPoint, .init(x: 0, y: 0)) + XCTAssertEqual(animation.toValue as! CGPoint, .init(x: 50, y: 50)) + XCTAssertEqual(animation.duration, timing.duration) + + let addedCurve = MotionCurve(fromTimingFunction: animation.timingFunction!) + XCTAssertEqual(addedCurve.type, timing.curve.type) + XCTAssertEqual(addedCurve.data.0, timing.curve.data.0) + XCTAssertEqual(addedCurve.data.1, timing.curve.data.1) + XCTAssertEqual(addedCurve.data.2, timing.curve.data.2) + XCTAssertEqual(addedCurve.data.3, timing.curve.data.3) + } + do { + let animation = addedAnimations + .flatMap { $0 as? CABasicAnimation } + .first(where: { $0.keyPath == "bounds"})! + XCTAssertFalse(animation.isAdditive) + XCTAssertEqual(animation.fromValue as! CGRect, .init(x: 0, y: 0, width: 0, height: 0)) + XCTAssertEqual(animation.toValue as! CGRect, .init(x: 0, y: 0, width: 100, height: 100)) + XCTAssertEqual(animation.duration, timing.duration) + + let addedCurve = MotionCurve(fromTimingFunction: animation.timingFunction!) + XCTAssertEqual(addedCurve.type, timing.curve.type) + XCTAssertEqual(addedCurve.data.0, timing.curve.data.0) + XCTAssertEqual(addedCurve.data.1, timing.curve.data.1) + XCTAssertEqual(addedCurve.data.2, timing.curve.data.2) + XCTAssertEqual(addedCurve.data.3, timing.curve.data.3) + } + } + + func testOneActionAddsNoAnimationWhenActionsDisable() { + CATransaction.begin() + CATransaction.setDisableActions(true) + + animator.animate(with: timing) { + self.view.alpha = 0 + } + + CATransaction.commit() + + XCTAssertEqual(addedAnimations.count, 0) + XCTAssertEqual(view.alpha, 0) + } +}