Skip to content
This repository was archived by the owner on Aug 30, 2023. It is now read-only.

Commit f2724c0

Browse files
authored
Improved robustness of implicit animation support (#53)
* Checkpoint for templated animations. * Arg naming.
1 parent 68456b3 commit f2724c0

4 files changed

+83
-28
lines changed

src/MDMMotionAnimator.h

+2-1
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ NS_SWIFT_NAME(MotionAnimator)
160160
be able to implicitly animate its properties with MDMMotionAnimator. This is not necessary for
161161
layers that are backing a UIView.
162162
*/
163-
+ (nonnull id<CALayerDelegate>)sharedLayerDelegate;
163+
+ (nonnull id<CALayerDelegate>)sharedLayerDelegate
164+
__deprecated_msg("No longer needed for implicit animations of headless layers.");
164165

165166
@end

src/private/MDMBlockAnimations.m

+21-20
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ @interface MDMActionContext: NSObject
2525
@property(nonatomic, readonly) NSArray<MDMImplicitAction *> *interceptedActions;
2626
@end
2727

28-
// The original UIView method implementation of actionForLayer:forKey:.
29-
static IMP sOriginalActionForLayerImp = NULL;
28+
// The original CALayer method implementation of -actionForKey:
29+
static IMP sOriginalActionForKeyLayerImp = NULL;
30+
3031
static NSMutableArray<MDMActionContext *> *sActionContext = nil;
3132

3233
@implementation MDMImplicitAction
@@ -74,9 +75,9 @@ - (void)addActionForLayer:(CALayer *)layer
7475
@interface MDMLayerDelegate: NSObject <CALayerDelegate>
7576
@end
7677

77-
static id<CAAction> ActionForLayer(id self, SEL _cmd, CALayer *layer, NSString *event) {
78+
static id<CAAction> ActionForKey(CALayer *layer, SEL _cmd, NSString *event) {
7879
NSCAssert([NSStringFromSelector(_cmd) isEqualToString:
79-
NSStringFromSelector(@selector(actionForLayer:forKey:))],
80+
NSStringFromSelector(@selector(actionForKey:))],
8081
@"Invalid method signature.");
8182

8283
MDMActionContext *context = [sActionContext lastObject];
@@ -85,8 +86,8 @@ @interface MDMLayerDelegate: NSObject <CALayerDelegate>
8586
if (context == nil) {
8687
// Graceful handling of invalid state on non-debug builds for if our context is nil invokes our
8788
// original implementation:
88-
return ((id<CAAction>(*)(id, SEL, CALayer *, NSString *))sOriginalActionForLayerImp)
89-
(self, _cmd, layer, event);
89+
return ((id<CAAction>(*)(id, SEL, NSString *))sOriginalActionForKeyLayerImp)
90+
(layer, _cmd, event);
9091
}
9192

9293
// We don't have access to the "to" value of our animation here, so we unfortunately can't
@@ -103,33 +104,33 @@ @interface MDMLayerDelegate: NSObject <CALayerDelegate>
103104
return nil;
104105
}
105106

107+
SEL actionForKeySelector = @selector(actionForKey:);
108+
Method actionForKeyMethod = class_getInstanceMethod([CALayer class], actionForKeySelector);
109+
106110
// This method can be called recursively, so we maintain a context stack in the scope of this
107111
// method. Note that this is absolutely not thread safe, but neither is Core Animation.
108112
if (!sActionContext) {
109113
sActionContext = [NSMutableArray array];
110-
}
111-
[sActionContext addObject:[[MDMActionContext alloc] init]];
112114

113-
SEL selector = @selector(actionForLayer:forKey:);
114-
Method method = class_getInstanceMethod([UIView class], selector);
115-
116-
if (sOriginalActionForLayerImp == nil) {
117-
// Swap the original UIView implementation with our own so that we can intercept all
118-
// actionForLayer:forKey: events.
119-
sOriginalActionForLayerImp = method_setImplementation(method, (IMP)ActionForLayer);
115+
// Swap the original CALayer implementation with our own so that we can intercept all
116+
// actionForKey: events.
117+
sOriginalActionForKeyLayerImp = method_setImplementation(actionForKeyMethod,
118+
(IMP)ActionForKey);
120119
}
121120

121+
[sActionContext addObject:[[MDMActionContext alloc] init]];
122+
122123
work();
123124

124125
// Return any intercepted actions we received during the invocation of work.
125126
MDMActionContext *context = [sActionContext lastObject];
126127
[sActionContext removeLastObject];
127128

128129
if ([sActionContext count] == 0) {
129-
// Restore our original method if we've emptied the stack:
130-
method_setImplementation(method, sOriginalActionForLayerImp);
130+
// Restore our original method if we've emptied the stack.
131+
method_setImplementation(actionForKeyMethod, sOriginalActionForKeyLayerImp);
132+
sOriginalActionForKeyLayerImp = nil;
131133

132-
sOriginalActionForLayerImp = nil;
133134
sActionContext = nil;
134135
}
135136

@@ -140,10 +141,10 @@ @implementation MDMLayerDelegate
140141

141142
- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event {
142143
// Check whether we're inside of an MDMAnimateImplicitly block or not.
143-
if (sOriginalActionForLayerImp == nil) {
144+
if (sOriginalActionForKeyLayerImp == nil) {
144145
return nil; // Tell Core Animation to Keep searching for an action provider.
145146
}
146-
return ActionForLayer(layer, _cmd, layer, event);
147+
return ActionForKey(layer, _cmd, event);
147148
}
148149

149150
@end

tests/unit/HeadlessLayerImplicitAnimationTests.swift

+58-5
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@ class HeadlessLayerImplicitAnimationTests: XCTestCase {
6767
layer.opacity = 0.5
6868
CATransaction.commit()
6969

70-
XCTAssertEqual(layer.animationKeys()!, ["opacity"])
70+
let animation = layer.animation(forKey: "opacity") as! CABasicAnimation
71+
XCTAssertEqual(animation.keyPath, "opacity")
72+
XCTAssertEqual(animation.duration, 0.5)
7173
}
7274

7375
func testDoesNotImplicitlyAnimateInCATransactionWithActionsDisabled() {
@@ -80,12 +82,43 @@ class HeadlessLayerImplicitAnimationTests: XCTestCase {
8082
XCTAssertNil(layer.animationKeys())
8183
}
8284

85+
func testCATransactionTimingTakesPrecedenceOverUIViewTimingInside() {
86+
UIView.animate(withDuration: 0.5) {
87+
CATransaction.begin()
88+
CATransaction.setAnimationDuration(0.2)
89+
self.layer.opacity = 0.5
90+
CATransaction.commit()
91+
}
92+
93+
let animation = layer.animation(forKey: "opacity") as! CABasicAnimation
94+
XCTAssertEqual(animation.keyPath, "opacity")
95+
XCTAssertEqual(animation.duration, 0.2)
96+
}
97+
98+
// Verifies the somewhat counter-intuitive fact that CATransaction's animation duration always
99+
// takes precedence over UIView's animation duration. This means that animating a headless layer
100+
// using UIView animation APIs may not result in the expected timings.
101+
func testCATransactionTimingTakesPrecedenceOverUIViewTimingOutside() {
102+
CATransaction.begin()
103+
CATransaction.setAnimationDuration(0.2)
104+
UIView.animate(withDuration: 0.5) {
105+
self.layer.opacity = 0.5
106+
}
107+
CATransaction.commit()
108+
109+
let animation = layer.animation(forKey: "opacity") as! CABasicAnimation
110+
XCTAssertEqual(animation.keyPath, "opacity")
111+
XCTAssertEqual(animation.duration, 0.2)
112+
}
113+
83114
func testDoesImplicitlyAnimateInUIViewAnimateBlock() {
84115
UIView.animate(withDuration: 0.5) {
85116
self.layer.opacity = 0.5
86117
}
87118

88-
XCTAssertEqual(layer.animationKeys()!, ["opacity"])
119+
let animation = layer.animation(forKey: "opacity") as! CABasicAnimation
120+
XCTAssertEqual(animation.keyPath, "opacity")
121+
XCTAssertEqual(animation.duration, CATransaction.animationDuration())
89122
}
90123

91124
func testDoesNotImplicitlyAnimateInUIViewAnimateBlockWithActionsDisabledInside() {
@@ -110,8 +143,27 @@ class HeadlessLayerImplicitAnimationTests: XCTestCase {
110143
XCTAssertNil(layer.animationKeys())
111144
}
112145

146+
func testAnimatorTimingTakesPrecedenceOverCATransactionTiming() {
147+
let animator = MotionAnimator()
148+
animator.additive = false
149+
let timing = MotionTiming(delay: 0,
150+
duration: 1,
151+
curve: MotionCurveMakeBezier(p1x: 0, p1y: 0, p2x: 0, p2y: 0),
152+
repetition: .init(type: .none, amount: 0, autoreverses: false))
153+
154+
animator.animate(with: timing) {
155+
self.layer.opacity = 0.5
156+
}
157+
158+
let animation = layer.animation(forKey: "opacity") as! CABasicAnimation
159+
XCTAssertEqual(animation.keyPath, "opacity")
160+
XCTAssertEqual(animation.duration, timing.duration)
161+
}
162+
163+
// MARK: Deprecated tests.
164+
165+
@available(*, deprecated)
113166
func testDoesImplicitlyAnimateInCATransactionWithLayerDelegateAlone() {
114-
// Delegate will allow us to do implicit animations, but only via the motion animator.
115167
layer.delegate = MotionAnimator.sharedLayerDelegate()
116168

117169
CATransaction.begin()
@@ -122,8 +174,8 @@ class HeadlessLayerImplicitAnimationTests: XCTestCase {
122174
XCTAssertEqual(layer.animationKeys()!, ["opacity"])
123175
}
124176

177+
@available(*, deprecated)
125178
func testDoesNotImplicitlyAnimateInCATransactionWithLayerDelegateAloneAndActionsAreDisabled() {
126-
// Delegate will allow us to do implicit animations, but only via the motion animator.
127179
layer.delegate = MotionAnimator.sharedLayerDelegate()
128180

129181
CATransaction.begin()
@@ -135,8 +187,8 @@ class HeadlessLayerImplicitAnimationTests: XCTestCase {
135187
XCTAssertNil(layer.animationKeys())
136188
}
137189

190+
@available(*, deprecated)
138191
func testDoesImplicitlyAnimateInUIViewAnimateBlockWithLayerDelegateAlone() {
139-
// Delegate will allow us to do implicit animations, but only via the motion animator.
140192
layer.delegate = MotionAnimator.sharedLayerDelegate()
141193

142194
UIView.animate(withDuration: 0.5) {
@@ -146,6 +198,7 @@ class HeadlessLayerImplicitAnimationTests: XCTestCase {
146198
XCTAssertEqual(layer.animationKeys()!, ["opacity"])
147199
}
148200

201+
@available(*, deprecated)
149202
func testDoesImplicitlyAnimateWithLayerDelegateAndAnimator() {
150203
layer.delegate = MotionAnimator.sharedLayerDelegate()
151204

tests/unit/ImplicitAnimationTests.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,12 @@ class ImplicitAnimationTests: XCTestCase {
5353
CATransaction.flush()
5454

5555
originalImplementation =
56-
class_getMethodImplementation(UIView.self, #selector(UIView.action(for:forKey:)))
56+
class_getMethodImplementation(CALayer.self, #selector(CALayer.action(forKey:)))
5757
}
5858

5959
override func tearDown() {
6060
let implementation =
61-
class_getMethodImplementation(UIView.self, #selector(UIView.action(for:forKey:)))
61+
class_getMethodImplementation(CALayer.self, #selector(CALayer.action(forKey:)))
6262
XCTAssertEqual(originalImplementation, implementation)
6363

6464
animator = nil

0 commit comments

Comments
 (0)