From 51ac34ada49013590d1c79480256faef8017624e Mon Sep 17 00:00:00 2001 From: featherless Date: Wed, 15 Nov 2017 15:13:40 -0500 Subject: [PATCH] Add support for removing added animations (#42) * Add an animation registrar. * Docs. * Misc consolidation. * Remove newline. * Comment. * Remove unused method. * Rename to stop. * Docs. * Generics. * Comments. * Runloop. --- .../project.pbxproj | 4 + src/MDMMotionAnimator.h | 17 +++ src/MDMMotionAnimator.m | 26 ++--- src/private/MDMAnimationRegistrar.h | 38 +++++++ src/private/MDMAnimationRegistrar.m | 102 ++++++++++++++++++ src/private/MDMRegisteredAnimation.h | 28 +++++ src/private/MDMRegisteredAnimation.m | 39 +++++++ tests/unit/AnimationRemovalTests.swift | 89 +++++++++++++++ 8 files changed, 330 insertions(+), 13 deletions(-) create mode 100644 src/private/MDMAnimationRegistrar.h create mode 100644 src/private/MDMAnimationRegistrar.m create mode 100644 src/private/MDMRegisteredAnimation.h create mode 100644 src/private/MDMRegisteredAnimation.m create mode 100644 tests/unit/AnimationRemovalTests.swift diff --git a/examples/apps/Catalog/MotionAnimatorCatalog.xcodeproj/project.pbxproj b/examples/apps/Catalog/MotionAnimatorCatalog.xcodeproj/project.pbxproj index 2ea580d..a85e332 100644 --- a/examples/apps/Catalog/MotionAnimatorCatalog.xcodeproj/project.pbxproj +++ b/examples/apps/Catalog/MotionAnimatorCatalog.xcodeproj/project.pbxproj @@ -18,6 +18,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 */; }; + 66A6A6681FBA158000DE54CB /* AnimationRemovalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66A6A6671FBA158000DE54CB /* AnimationRemovalTests.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 */; }; @@ -62,6 +63,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 = ""; }; + 66A6A6671FBA158000DE54CB /* AnimationRemovalTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationRemovalTests.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 = ""; }; @@ -208,6 +210,7 @@ 66FD99F81EE9FBA000C53A82 /* unit */ = { isa = PBXGroup; children = ( + 66A6A6671FBA158000DE54CB /* AnimationRemovalTests.swift */, 66FD99F91EE9FBBE00C53A82 /* MotionAnimatorTests.m */, 668726491EF04B4C00113675 /* MotionAnimatorTests.swift */, 66BF5A8E1FB0E4CB00E864F6 /* ImplicitAnimationTests.swift */, @@ -491,6 +494,7 @@ 6625876C1FB4DB9C00BC7DF1 /* InitialVelocityTests.swift in Sources */, 660636021FACC24300C3DFB8 /* TimeScaleFactorTests.swift in Sources */, 66BF5A8F1FB0E4CB00E864F6 /* ImplicitAnimationTests.swift in Sources */, + 66A6A6681FBA158000DE54CB /* AnimationRemovalTests.swift in Sources */, 6687264A1EF04B4C00113675 /* MotionAnimatorTests.swift in Sources */, 66FD99FA1EE9FBBE00C53A82 /* MotionAnimatorTests.m in Sources */, ); diff --git a/src/MDMMotionAnimator.h b/src/MDMMotionAnimator.h index 0824f6b..a3457ac 100644 --- a/src/MDMMotionAnimator.h +++ b/src/MDMMotionAnimator.h @@ -132,4 +132,21 @@ NS_SWIFT_NAME(MotionAnimator) animations:(nonnull void (^)(void))animations completion:(nullable void(^)(void))completion; +/** + Removes every animation added by this animator. + + Removing animations in this manner will give the appearance of each animated layer property + instantaneously jumping to its animated destination. + */ +- (void)removeAllAnimations; + +/** + Commits the presentation layer value to the model layer value for every active animation's key path + and then removes every animation. + + This method is most commonly called in reaction to the initiation of a gesture so that any + in-flight animations are stopped at their current on-screen position. + */ +- (void)stopAllAnimations; + @end diff --git a/src/MDMMotionAnimator.m b/src/MDMMotionAnimator.m index a983cb5..5262a43 100644 --- a/src/MDMMotionAnimator.m +++ b/src/MDMMotionAnimator.m @@ -20,17 +20,20 @@ #import "CATransaction+MotionAnimator.h" #import "private/CABasicAnimation+MotionAnimator.h" +#import "private/MDMAnimationRegistrar.h" #import "private/MDMUIKitValueCoercion.h" #import "private/MDMBlockAnimations.h" #import "private/MDMDragCoefficient.h" @implementation MDMMotionAnimator { NSMutableArray *_tracers; + MDMAnimationRegistrar *_registrar; } - (instancetype)init { self = [super init]; if (self) { + _registrar = [[MDMAnimationRegistrar alloc] init]; _timeScaleFactor = 1; _additive = true; } @@ -93,23 +96,12 @@ - (void)animateWithTiming:(MDMMotionTiming)timing animation.fillMode = kCAFillModeBackwards; } - if (completion) { - [CATransaction begin]; - [CATransaction setCompletionBlock:completion]; - } - - // When we use a nil key, Core Animation will ensure that the animation is added with a - // unique key - this enables our additive animations to stack upon one another. NSString *key = _additive ? nil : keyPath; - [layer addAnimation:animation forKey:key]; + [_registrar addAnimation:animation toLayer:layer forKey:key completion:completion]; for (void (^tracer)(CALayer *, CAAnimation *) in _tracers) { tracer(layer, animation); } - - if (completion) { - [CATransaction commit]; - } } } @@ -165,5 +157,13 @@ - (CGFloat)computedTimeScaleFactor { return MDMSimulatorAnimationDragCoefficient() * timeScaleFactor; } -@end +- (void)removeAllAnimations { + [_registrar removeAllAnimations]; +} + +- (void)stopAllAnimations { + [_registrar commitCurrentAnimationValuesToAllLayers]; + [_registrar removeAllAnimations]; +} +@end diff --git a/src/private/MDMAnimationRegistrar.h b/src/private/MDMAnimationRegistrar.h new file mode 100644 index 0000000..7aaadf5 --- /dev/null +++ b/src/private/MDMAnimationRegistrar.h @@ -0,0 +1,38 @@ +/* + 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 + +// Tracks and manipulates animations that have been added to a layer. +@interface MDMAnimationRegistrar : NSObject + +// Invokes the layer's addAnimation:forKey: method with the provided animation and key and tracks +// its association. Upon completion of the animation, the provided optional completion block will be +// executed. +- (void)addAnimation:(nonnull CABasicAnimation *)animation + toLayer:(nonnull CALayer *)layer + forKey:(nonnull NSString *)key + completion:(void(^ __nullable)(void))completion; + +// For every active animation, reads the associated layer's presentation layer key path and writes +// it to the layer. +- (void)commitCurrentAnimationValuesToAllLayers; + +// Removes all active animations from their associated layer. +- (void)removeAllAnimations; + +@end diff --git a/src/private/MDMAnimationRegistrar.m b/src/private/MDMAnimationRegistrar.m new file mode 100644 index 0000000..01a6679 --- /dev/null +++ b/src/private/MDMAnimationRegistrar.m @@ -0,0 +1,102 @@ +/* + 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 "MDMAnimationRegistrar.h" + +#import "MDMRegisteredAnimation.h" + +@implementation MDMAnimationRegistrar { + NSMapTable *> *_layersToRegisteredAnimation; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _layersToRegisteredAnimation = [NSMapTable mapTableWithKeyOptions:NSPointerFunctionsWeakMemory + valueOptions:NSPointerFunctionsStrongMemory]; + } + return self; +} + +#pragma mark - Private + +- (void)forEachAnimation:(void (^)(CALayer *, CABasicAnimation *, NSString *))work { + // Copy the registered animations before iteration in case further modifications happen to the + // registered animations. Consider if we remove an animation, its associated completion block + // might invoke logic that adds a new animation, potentially modifying our collections. + for (CALayer *layer in [_layersToRegisteredAnimation copy]) { + NSSet *keyPathAnimations = [_layersToRegisteredAnimation objectForKey:layer]; + for (MDMRegisteredAnimation *keyPathAnimation in [keyPathAnimations copy]) { + if (![keyPathAnimation.animation isKindOfClass:[CABasicAnimation class]]) { + continue; + } + + work(layer, [keyPathAnimation.animation copy], keyPathAnimation.key); + } + } +} + +#pragma mark - Public + +- (void)addAnimation:(CABasicAnimation *)animation + toLayer:(CALayer *)layer + forKey:(NSString *)key + completion:(void(^)(void))completion { + if (key == nil) { + key = [NSUUID UUID].UUIDString; + } + + NSMutableSet *animatedKeyPaths = [_layersToRegisteredAnimation objectForKey:layer]; + if (!animatedKeyPaths) { + animatedKeyPaths = [[NSMutableSet alloc] init]; + [_layersToRegisteredAnimation setObject:animatedKeyPaths forKey:layer]; + } + MDMRegisteredAnimation *keyPathAnimation = + [[MDMRegisteredAnimation alloc] initWithKey:key animation:animation]; + [animatedKeyPaths addObject:keyPathAnimation]; + + [CATransaction begin]; + [CATransaction setCompletionBlock:^{ + [animatedKeyPaths removeObject:keyPathAnimation]; + + if (completion) { + completion(); + } + }]; + + [layer addAnimation:animation forKey:key]; + + [CATransaction commit]; +} + +- (void)commitCurrentAnimationValuesToAllLayers { + [self forEachAnimation:^(CALayer *layer, CABasicAnimation *animation, NSString *key) { + id presentationLayer = [layer presentationLayer]; + if (presentationLayer != nil) { + id presentationValue = [presentationLayer valueForKeyPath:animation.keyPath]; + [layer setValue:presentationValue forKeyPath:animation.keyPath]; + } + }]; +} + +- (void)removeAllAnimations { + [self forEachAnimation:^(CALayer *layer, CABasicAnimation *animation, NSString *key) { + [layer removeAnimationForKey:key]; + }]; + [_layersToRegisteredAnimation removeAllObjects]; +} + +@end diff --git a/src/private/MDMRegisteredAnimation.h b/src/private/MDMRegisteredAnimation.h new file mode 100644 index 0000000..4eb9f71 --- /dev/null +++ b/src/private/MDMRegisteredAnimation.h @@ -0,0 +1,28 @@ +/* + 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 MDMRegisteredAnimation : NSObject + +- (instancetype)initWithKey:(NSString *)key animation:(CABasicAnimation *)animation; + +@property(nonatomic, copy, readonly) NSString *key; + +@property(nonatomic, strong, readonly) CABasicAnimation *animation; + +@end diff --git a/src/private/MDMRegisteredAnimation.m b/src/private/MDMRegisteredAnimation.m new file mode 100644 index 0000000..67e0bf3 --- /dev/null +++ b/src/private/MDMRegisteredAnimation.m @@ -0,0 +1,39 @@ +/* + 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 "MDMRegisteredAnimation.h" + +@implementation MDMRegisteredAnimation + +- (instancetype)initWithKey:(NSString *)key animation:(CABasicAnimation *)animation { + self = [super init]; + if (self) { + _key = [key copy]; + _animation = animation; + } + return self; +} + +- (NSUInteger)hash { + return _key.hash; +} + +- (BOOL)isEqual:(id)object { + return [_key isEqual:object]; +} + +@end + diff --git a/tests/unit/AnimationRemovalTests.swift b/tests/unit/AnimationRemovalTests.swift new file mode 100644 index 0000000..f56f21e --- /dev/null +++ b/tests/unit/AnimationRemovalTests.swift @@ -0,0 +1,89 @@ +/* + 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 + +#if IS_BAZEL_BUILD +import _MotionAnimator +#else +import MotionAnimator +#endif + +class AnimationRemovalTests: XCTestCase { + var animator: MotionAnimator! + var timing: MotionTiming! + var view: UIView! + + var originalImplementation: IMP? + override func setUp() { + super.setUp() + + animator = MotionAnimator() + timing = MotionTiming(delay: 0, + duration: 1, + curve: MotionCurveMakeBezier(p1x: 0, p1y: 0, p2x: 0, p2y: 0), + 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) + } + + override func tearDown() { + animator = nil + timing = nil + view = nil + + super.tearDown() + } + + func testAllAdditiveAnimationsGetsRemoved() { + animator.animate(with: timing, to: view.layer, withValues: [1, 0], keyPath: .opacity) + animator.animate(with: timing, to: view.layer, withValues: [0, 0.5], keyPath: .opacity) + + XCTAssertEqual(view.layer.animationKeys()!.count, 2) + + animator.removeAllAnimations() + + XCTAssertNil(view.layer.animationKeys()) + XCTAssertEqual(view.layer.opacity, 0.5) + } + + func testCommitAndRemoveAllAnimationsCommitsThePresentationValue() { + var didComplete = false + CATransaction.begin() + CATransaction.setCompletionBlock { + didComplete = true + } + + animator.animate(with: timing, to: view.layer, withValues: [1, 0], keyPath: .opacity) + animator.animate(with: timing, to: view.layer, withValues: [0, 0.5], keyPath: .opacity) + + CATransaction.commit() + + XCTAssertEqual(view.layer.animationKeys()!.count, 2) + + RunLoop.main.run(until: .init(timeIntervalSinceNow: 0.01)) + + XCTAssertFalse(didComplete) + + animator.stopAllAnimations() + + XCTAssertNil(view.layer.animationKeys()) + XCTAssertEqual(view.layer.opacity, view.layer.presentation()?.opacity) + } +}