// // FLAnimatedImageView.h // Flipboard // // Created by Raphael Schaad on 7/8/13. // Copyright (c) 2013-2015 Flipboard. All rights reserved. // #import "FLAnimatedImageView.h" #import "FLAnimatedImage.h" #import #if defined(DEBUG) && DEBUG @protocol FLAnimatedImageViewDebugDelegate @optional - (void)debug_animatedImageView:(FLAnimatedImageView *)animatedImageView waitingForFrame:(NSUInteger)index duration:(NSTimeInterval)duration; @end #endif @interface FLAnimatedImageView () // Override of public `readonly` properties as private `readwrite` @property (nonatomic, strong, readwrite) UIImage *currentFrame; @property (nonatomic, assign, readwrite) NSUInteger currentFrameIndex; @property (nonatomic, assign) NSUInteger loopCountdown; @property (nonatomic, assign) NSTimeInterval accumulator; @property (nonatomic, strong) CADisplayLink *displayLink; @property (nonatomic, assign) BOOL shouldAnimate; // Before checking this value, call `-updateShouldAnimate` whenever the animated image or visibility (window, superview, hidden, alpha) has changed. @property (nonatomic, assign) BOOL needsDisplayWhenImageBecomesAvailable; #if defined(DEBUG) && DEBUG @property (nonatomic, weak) id debug_delegate; #endif @end @implementation FLAnimatedImageView @synthesize runLoopMode = _runLoopMode; #pragma mark - Initializers // -initWithImage: isn't documented as a designated initializer of UIImageView, but it actually seems to be. // Using -initWithImage: doesn't call any of the other designated initializers. - (instancetype)initWithImage:(UIImage *)image { self = [super initWithImage:image]; if (self) { [self commonInit]; } return self; } // -initWithImage:highlightedImage: also isn't documented as a designated initializer of UIImageView, but it doesn't call any other designated initializers. - (instancetype)initWithImage:(UIImage *)image highlightedImage:(UIImage *)highlightedImage { self = [super initWithImage:image highlightedImage:highlightedImage]; if (self) { [self commonInit]; } return self; } - (instancetype)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; if (self) { [self commonInit]; } return self; } - (instancetype)initWithCoder:(NSCoder *)aDecoder { self = [super initWithCoder:aDecoder]; if (self) { [self commonInit]; } return self; } - (void)commonInit { self.runLoopMode = [[self class] defaultRunLoopMode]; } #pragma mark - Accessors #pragma mark Public - (void)setAnimatedImage:(FLAnimatedImage *)animatedImage { if (![_animatedImage isEqual:animatedImage]) { if (animatedImage) { // Clear out the image. super.image = nil; // Ensure disabled highlighting; it's not supported (see `-setHighlighted:`). super.highlighted = NO; // UIImageView seems to bypass some accessors when calculating its intrinsic content size, so this ensures its intrinsic content size comes from the animated image. [self invalidateIntrinsicContentSize]; } else { // Stop animating before the animated image gets cleared out. [self stopAnimating]; } _animatedImage = animatedImage; self.currentFrame = animatedImage.posterImage; self.currentFrameIndex = 0; if (animatedImage.loopCount > 0) { self.loopCountdown = animatedImage.loopCount; } else { self.loopCountdown = NSUIntegerMax; } self.accumulator = 0.0; // Start animating after the new animated image has been set. [self updateShouldAnimate]; if (self.shouldAnimate) { [self startAnimating]; } [self.layer setNeedsDisplay]; } } #pragma mark - Life Cycle - (void)dealloc { // Removes the display link from all run loop modes. [_displayLink invalidate]; } #pragma mark - UIView Method Overrides #pragma mark Observing View-Related Changes - (void)didMoveToSuperview { [super didMoveToSuperview]; [self updateShouldAnimate]; if (self.shouldAnimate) { [self startAnimating]; } else { [self stopAnimating]; } } - (void)didMoveToWindow { [super didMoveToWindow]; [self updateShouldAnimate]; if (self.shouldAnimate) { [self startAnimating]; } else { [self stopAnimating]; } } - (void)setAlpha:(CGFloat)alpha { [super setAlpha:alpha]; [self updateShouldAnimate]; if (self.shouldAnimate) { [self startAnimating]; } else { [self stopAnimating]; } } - (void)setHidden:(BOOL)hidden { [super setHidden:hidden]; [self updateShouldAnimate]; if (self.shouldAnimate) { [self startAnimating]; } else { [self stopAnimating]; } } #pragma mark Auto Layout - (CGSize)intrinsicContentSize { // Default to let UIImageView handle the sizing of its image, and anything else it might consider. CGSize intrinsicContentSize = [super intrinsicContentSize]; // If we have have an animated image, use its image size. // UIImageView's intrinsic content size seems to be the size of its image. The obvious approach, simply calling `-invalidateIntrinsicContentSize` when setting an animated image, results in UIImageView steadfastly returning `{UIViewNoIntrinsicMetric, UIViewNoIntrinsicMetric}` for its intrinsicContentSize. // (Perhaps UIImageView bypasses its `-image` getter in its implementation of `-intrinsicContentSize`, as `-image` is not called after calling `-invalidateIntrinsicContentSize`.) if (self.animatedImage) { intrinsicContentSize = self.image.size; } return intrinsicContentSize; } #pragma mark - UIImageView Method Overrides #pragma mark Image Data - (UIImage *)image { UIImage *image = nil; if (self.animatedImage) { // Initially set to the poster image. image = self.currentFrame; } else { image = super.image; } return image; } - (void)setImage:(UIImage *)image { if (image) { // Clear out the animated image and implicitly pause animation playback. self.animatedImage = nil; } super.image = image; } #pragma mark Animating Images - (NSTimeInterval)frameDelayGreatestCommonDivisor { // Presision is set to half of the `kFLAnimatedImageDelayTimeIntervalMinimum` in order to minimize frame dropping. const NSTimeInterval kGreatestCommonDivisorPrecision = 2.0 / kFLAnimatedImageDelayTimeIntervalMinimum; NSArray *delays = self.animatedImage.delayTimesForIndexes.allValues; // Scales the frame delays by `kGreatestCommonDivisorPrecision` // then converts it to an UInteger for in order to calculate the GCD. NSUInteger scaledGCD = lrint([delays.firstObject floatValue] * kGreatestCommonDivisorPrecision); for (NSNumber *value in delays) { scaledGCD = gcd(lrint([value floatValue] * kGreatestCommonDivisorPrecision), scaledGCD); } // Reverse to scale to get the value back into seconds. return scaledGCD / kGreatestCommonDivisorPrecision; } static NSUInteger gcd(NSUInteger a, NSUInteger b) { // http://en.wikipedia.org/wiki/Greatest_common_divisor if (a < b) { return gcd(b, a); } else if (a == b) { return b; } while (true) { NSUInteger remainder = a % b; if (remainder == 0) { return b; } a = b; b = remainder; } } - (void)startAnimating { if (self.animatedImage) { // Lazily create the display link. if (!self.displayLink) { // It is important to note the use of a weak proxy here to avoid a retain cycle. `-displayLinkWithTarget:selector:` // will retain its target until it is invalidated. We use a weak proxy so that the image view will get deallocated // independent of the display link's lifetime. Upon image view deallocation, we invalidate the display // link which will lead to the deallocation of both the display link and the weak proxy. FLWeakProxy *weakProxy = [FLWeakProxy weakProxyForObject:self]; self.displayLink = [CADisplayLink displayLinkWithTarget:weakProxy selector:@selector(displayDidRefresh:)]; [self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:self.runLoopMode]; } // Note: The display link's `.frameInterval` value of 1 (default) means getting callbacks at the refresh rate of the display (~60Hz). // Setting it to 2 divides the frame rate by 2 and hence calls back at every other display refresh. const NSTimeInterval kDisplayRefreshRate = 60.0; // 60Hz self.displayLink.frameInterval = MAX([self frameDelayGreatestCommonDivisor] * kDisplayRefreshRate, 1); self.displayLink.paused = NO; } else { [super startAnimating]; } } - (void)setRunLoopMode:(NSString *)runLoopMode { if (![@[NSDefaultRunLoopMode, NSRunLoopCommonModes] containsObject:runLoopMode]) { NSAssert(NO, @"Invalid run loop mode: %@", runLoopMode); _runLoopMode = [[self class] defaultRunLoopMode]; } else { _runLoopMode = runLoopMode; } } - (void)stopAnimating { if (self.animatedImage) { self.displayLink.paused = YES; } else { [super stopAnimating]; } } - (BOOL)isAnimating { BOOL isAnimating = NO; if (self.animatedImage) { isAnimating = self.displayLink && !self.displayLink.isPaused; } else { isAnimating = [super isAnimating]; } return isAnimating; } #pragma mark Highlighted Image Unsupport - (void)setHighlighted:(BOOL)highlighted { // Highlighted image is unsupported for animated images, but implementing it breaks the image view when embedded in a UICollectionViewCell. if (!self.animatedImage) { [super setHighlighted:highlighted]; } } #pragma mark - Private Methods #pragma mark Animation // Don't repeatedly check our window & superview in `-displayDidRefresh:` for performance reasons. // Just update our cached value whenever the animated image or visibility (window, superview, hidden, alpha) is changed. - (void)updateShouldAnimate { BOOL isVisible = self.window && self.superview && ![self isHidden] && self.alpha > 0.0; self.shouldAnimate = self.animatedImage && isVisible; } - (void)displayDidRefresh:(CADisplayLink *)displayLink { // If for some reason a wild call makes it through when we shouldn't be animating, bail. // Early return! if (!self.shouldAnimate) { FLLog(FLLogLevelWarn, @"Trying to animate image when we shouldn't: %@", self); return; } NSNumber *delayTimeNumber = [self.animatedImage.delayTimesForIndexes objectForKey:@(self.currentFrameIndex)]; // If we don't have a frame delay (e.g. corrupt frame), don't update the view but skip the playhead to the next frame (in else-block). if (delayTimeNumber) { NSTimeInterval delayTime = [delayTimeNumber floatValue]; // If we have a nil image (e.g. waiting for frame), don't update the view nor playhead. UIImage *image = [self.animatedImage imageLazilyCachedAtIndex:self.currentFrameIndex]; if (image) { FLLog(FLLogLevelVerbose, @"Showing frame %lu for animated image: %@", (unsigned long)self.currentFrameIndex, self.animatedImage); self.currentFrame = image; if (self.needsDisplayWhenImageBecomesAvailable) { [self.layer setNeedsDisplay]; self.needsDisplayWhenImageBecomesAvailable = NO; } self.accumulator += displayLink.duration * displayLink.frameInterval; // While-loop first inspired by & good Karma to: https://github.com/ondalabs/OLImageView/blob/master/OLImageView.m while (self.accumulator >= delayTime) { self.accumulator -= delayTime; self.currentFrameIndex++; if (self.currentFrameIndex >= self.animatedImage.frameCount) { // If we've looped the number of times that this animated image describes, stop looping. self.loopCountdown--; if (self.loopCompletionBlock) { self.loopCompletionBlock(self.loopCountdown); } if (self.loopCountdown == 0) { [self stopAnimating]; return; } self.currentFrameIndex = 0; } // Calling `-setNeedsDisplay` will just paint the current frame, not the new frame that we may have moved to. // Instead, set `needsDisplayWhenImageBecomesAvailable` to `YES` -- this will paint the new image once loaded. self.needsDisplayWhenImageBecomesAvailable = YES; } } else { FLLog(FLLogLevelDebug, @"Waiting for frame %lu for animated image: %@", (unsigned long)self.currentFrameIndex, self.animatedImage); #if defined(DEBUG) && DEBUG if ([self.debug_delegate respondsToSelector:@selector(debug_animatedImageView:waitingForFrame:duration:)]) { [self.debug_delegate debug_animatedImageView:self waitingForFrame:self.currentFrameIndex duration:(NSTimeInterval)displayLink.duration * displayLink.frameInterval]; } #endif } } else { self.currentFrameIndex++; } } + (NSString *)defaultRunLoopMode { // Key off `activeProcessorCount` (as opposed to `processorCount`) since the system could shut down cores in certain situations. return [NSProcessInfo processInfo].activeProcessorCount > 1 ? NSRunLoopCommonModes : NSDefaultRunLoopMode; } #pragma mark - CALayerDelegate (Informal) #pragma mark Providing the Layer's Content - (void)displayLayer:(CALayer *)layer { layer.contents = (__bridge id)self.image.CGImage; } @end