FLAnimatedImageView.m 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. //
  2. // FLAnimatedImageView.h
  3. // Flipboard
  4. //
  5. // Created by Raphael Schaad on 7/8/13.
  6. // Copyright (c) 2013-2015 Flipboard. All rights reserved.
  7. //
  8. #import "FLAnimatedImageView.h"
  9. #import "FLAnimatedImage.h"
  10. #import <QuartzCore/QuartzCore.h>
  11. #if defined(DEBUG) && DEBUG
  12. @protocol FLAnimatedImageViewDebugDelegate <NSObject>
  13. @optional
  14. - (void)debug_animatedImageView:(FLAnimatedImageView *)animatedImageView waitingForFrame:(NSUInteger)index duration:(NSTimeInterval)duration;
  15. @end
  16. #endif
  17. @interface FLAnimatedImageView ()
  18. // Override of public `readonly` properties as private `readwrite`
  19. @property (nonatomic, strong, readwrite) UIImage *currentFrame;
  20. @property (nonatomic, assign, readwrite) NSUInteger currentFrameIndex;
  21. @property (nonatomic, assign) NSUInteger loopCountdown;
  22. @property (nonatomic, assign) NSTimeInterval accumulator;
  23. @property (nonatomic, strong) CADisplayLink *displayLink;
  24. @property (nonatomic, assign) BOOL shouldAnimate; // Before checking this value, call `-updateShouldAnimate` whenever the animated image or visibility (window, superview, hidden, alpha) has changed.
  25. @property (nonatomic, assign) BOOL needsDisplayWhenImageBecomesAvailable;
  26. #if defined(DEBUG) && DEBUG
  27. @property (nonatomic, weak) id<FLAnimatedImageViewDebugDelegate> debug_delegate;
  28. #endif
  29. @end
  30. @implementation FLAnimatedImageView
  31. @synthesize runLoopMode = _runLoopMode;
  32. #pragma mark - Initializers
  33. // -initWithImage: isn't documented as a designated initializer of UIImageView, but it actually seems to be.
  34. // Using -initWithImage: doesn't call any of the other designated initializers.
  35. - (instancetype)initWithImage:(UIImage *)image
  36. {
  37. self = [super initWithImage:image];
  38. if (self) {
  39. [self commonInit];
  40. }
  41. return self;
  42. }
  43. // -initWithImage:highlightedImage: also isn't documented as a designated initializer of UIImageView, but it doesn't call any other designated initializers.
  44. - (instancetype)initWithImage:(UIImage *)image highlightedImage:(UIImage *)highlightedImage
  45. {
  46. self = [super initWithImage:image highlightedImage:highlightedImage];
  47. if (self) {
  48. [self commonInit];
  49. }
  50. return self;
  51. }
  52. - (instancetype)initWithFrame:(CGRect)frame
  53. {
  54. self = [super initWithFrame:frame];
  55. if (self) {
  56. [self commonInit];
  57. }
  58. return self;
  59. }
  60. - (instancetype)initWithCoder:(NSCoder *)aDecoder
  61. {
  62. self = [super initWithCoder:aDecoder];
  63. if (self) {
  64. [self commonInit];
  65. }
  66. return self;
  67. }
  68. - (void)commonInit
  69. {
  70. self.runLoopMode = [[self class] defaultRunLoopMode];
  71. if (@available(iOS 11.0, *)) {
  72. self.accessibilityIgnoresInvertColors = YES;
  73. }
  74. }
  75. #pragma mark - Accessors
  76. #pragma mark Public
  77. - (void)setAnimatedImage:(FLAnimatedImage *)animatedImage
  78. {
  79. if (![_animatedImage isEqual:animatedImage]) {
  80. if (animatedImage) {
  81. // Clear out the image.
  82. super.image = nil;
  83. // Ensure disabled highlighting; it's not supported (see `-setHighlighted:`).
  84. super.highlighted = NO;
  85. // UIImageView seems to bypass some accessors when calculating its intrinsic content size, so this ensures its intrinsic content size comes from the animated image.
  86. [self invalidateIntrinsicContentSize];
  87. } else {
  88. // Stop animating before the animated image gets cleared out.
  89. [self stopAnimating];
  90. }
  91. _animatedImage = animatedImage;
  92. self.currentFrame = animatedImage.posterImage;
  93. self.currentFrameIndex = 0;
  94. if (animatedImage.loopCount > 0) {
  95. self.loopCountdown = animatedImage.loopCount;
  96. } else {
  97. self.loopCountdown = NSUIntegerMax;
  98. }
  99. self.accumulator = 0.0;
  100. // Start animating after the new animated image has been set.
  101. [self updateShouldAnimate];
  102. if (self.shouldAnimate) {
  103. [self startAnimating];
  104. }
  105. [self.layer setNeedsDisplay];
  106. }
  107. }
  108. #pragma mark - Life Cycle
  109. - (void)dealloc
  110. {
  111. // Removes the display link from all run loop modes.
  112. [_displayLink invalidate];
  113. }
  114. #pragma mark - UIView Method Overrides
  115. #pragma mark Observing View-Related Changes
  116. - (void)didMoveToSuperview
  117. {
  118. [super didMoveToSuperview];
  119. [self updateShouldAnimate];
  120. if (self.shouldAnimate) {
  121. [self startAnimating];
  122. } else {
  123. [self stopAnimating];
  124. }
  125. }
  126. - (void)didMoveToWindow
  127. {
  128. [super didMoveToWindow];
  129. [self updateShouldAnimate];
  130. if (self.shouldAnimate) {
  131. [self startAnimating];
  132. } else {
  133. [self stopAnimating];
  134. }
  135. }
  136. - (void)setAlpha:(CGFloat)alpha
  137. {
  138. [super setAlpha:alpha];
  139. [self updateShouldAnimate];
  140. if (self.shouldAnimate) {
  141. [self startAnimating];
  142. } else {
  143. [self stopAnimating];
  144. }
  145. }
  146. - (void)setHidden:(BOOL)hidden
  147. {
  148. [super setHidden:hidden];
  149. [self updateShouldAnimate];
  150. if (self.shouldAnimate) {
  151. [self startAnimating];
  152. } else {
  153. [self stopAnimating];
  154. }
  155. }
  156. #pragma mark Auto Layout
  157. - (CGSize)intrinsicContentSize
  158. {
  159. // Default to let UIImageView handle the sizing of its image, and anything else it might consider.
  160. CGSize intrinsicContentSize = [super intrinsicContentSize];
  161. // If we have have an animated image, use its image size.
  162. // 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.
  163. // (Perhaps UIImageView bypasses its `-image` getter in its implementation of `-intrinsicContentSize`, as `-image` is not called after calling `-invalidateIntrinsicContentSize`.)
  164. if (self.animatedImage) {
  165. intrinsicContentSize = self.image.size;
  166. }
  167. return intrinsicContentSize;
  168. }
  169. #pragma mark Smart Invert Colors
  170. #pragma mark - UIImageView Method Overrides
  171. #pragma mark Image Data
  172. - (UIImage *)image
  173. {
  174. UIImage *image = nil;
  175. if (self.animatedImage) {
  176. // Initially set to the poster image.
  177. image = self.currentFrame;
  178. } else {
  179. image = super.image;
  180. }
  181. return image;
  182. }
  183. - (void)setImage:(UIImage *)image
  184. {
  185. if (image) {
  186. // Clear out the animated image and implicitly pause animation playback.
  187. self.animatedImage = nil;
  188. }
  189. super.image = image;
  190. }
  191. #pragma mark Animating Images
  192. - (NSTimeInterval)frameDelayGreatestCommonDivisor
  193. {
  194. // Presision is set to half of the `kFLAnimatedImageDelayTimeIntervalMinimum` in order to minimize frame dropping.
  195. const NSTimeInterval kGreatestCommonDivisorPrecision = 2.0 / kFLAnimatedImageDelayTimeIntervalMinimum;
  196. NSArray *delays = self.animatedImage.delayTimesForIndexes.allValues;
  197. // Scales the frame delays by `kGreatestCommonDivisorPrecision`
  198. // then converts it to an UInteger for in order to calculate the GCD.
  199. NSUInteger scaledGCD = lrint([delays.firstObject floatValue] * kGreatestCommonDivisorPrecision);
  200. for (NSNumber *value in delays) {
  201. scaledGCD = gcd(lrint([value floatValue] * kGreatestCommonDivisorPrecision), scaledGCD);
  202. }
  203. // Reverse to scale to get the value back into seconds.
  204. return scaledGCD / kGreatestCommonDivisorPrecision;
  205. }
  206. static NSUInteger gcd(NSUInteger a, NSUInteger b)
  207. {
  208. // http://en.wikipedia.org/wiki/Greatest_common_divisor
  209. if (a < b) {
  210. return gcd(b, a);
  211. } else if (a == b) {
  212. return b;
  213. }
  214. while (true) {
  215. NSUInteger remainder = a % b;
  216. if (remainder == 0) {
  217. return b;
  218. }
  219. a = b;
  220. b = remainder;
  221. }
  222. }
  223. - (void)startAnimating
  224. {
  225. if (self.animatedImage) {
  226. // Lazily create the display link.
  227. if (!self.displayLink) {
  228. // It is important to note the use of a weak proxy here to avoid a retain cycle. `-displayLinkWithTarget:selector:`
  229. // will retain its target until it is invalidated. We use a weak proxy so that the image view will get deallocated
  230. // independent of the display link's lifetime. Upon image view deallocation, we invalidate the display
  231. // link which will lead to the deallocation of both the display link and the weak proxy.
  232. FLWeakProxy *weakProxy = [FLWeakProxy weakProxyForObject:self];
  233. self.displayLink = [CADisplayLink displayLinkWithTarget:weakProxy selector:@selector(displayDidRefresh:)];
  234. [self.displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:self.runLoopMode];
  235. }
  236. // Note: The display link's `.frameInterval` value of 1 (default) means getting callbacks at the refresh rate of the display (~60Hz).
  237. // Setting it to 2 divides the frame rate by 2 and hence calls back at every other display refresh.
  238. const NSTimeInterval kDisplayRefreshRate = 60.0; // 60Hz
  239. self.displayLink.frameInterval = MAX([self frameDelayGreatestCommonDivisor] * kDisplayRefreshRate, 1);
  240. self.displayLink.paused = NO;
  241. } else {
  242. [super startAnimating];
  243. }
  244. }
  245. - (void)setRunLoopMode:(NSString *)runLoopMode
  246. {
  247. if (![@[NSDefaultRunLoopMode, NSRunLoopCommonModes] containsObject:runLoopMode]) {
  248. NSAssert(NO, @"Invalid run loop mode: %@", runLoopMode);
  249. _runLoopMode = [[self class] defaultRunLoopMode];
  250. } else {
  251. _runLoopMode = runLoopMode;
  252. }
  253. }
  254. - (void)stopAnimating
  255. {
  256. if (self.animatedImage) {
  257. self.displayLink.paused = YES;
  258. } else {
  259. [super stopAnimating];
  260. }
  261. }
  262. - (BOOL)isAnimating
  263. {
  264. BOOL isAnimating = NO;
  265. if (self.animatedImage) {
  266. isAnimating = self.displayLink && !self.displayLink.isPaused;
  267. } else {
  268. isAnimating = [super isAnimating];
  269. }
  270. return isAnimating;
  271. }
  272. #pragma mark Highlighted Image Unsupport
  273. - (void)setHighlighted:(BOOL)highlighted
  274. {
  275. // Highlighted image is unsupported for animated images, but implementing it breaks the image view when embedded in a UICollectionViewCell.
  276. if (!self.animatedImage) {
  277. [super setHighlighted:highlighted];
  278. }
  279. }
  280. #pragma mark - Private Methods
  281. #pragma mark Animation
  282. // Don't repeatedly check our window & superview in `-displayDidRefresh:` for performance reasons.
  283. // Just update our cached value whenever the animated image or visibility (window, superview, hidden, alpha) is changed.
  284. - (void)updateShouldAnimate
  285. {
  286. BOOL isVisible = self.window && self.superview && ![self isHidden] && self.alpha > 0.0;
  287. self.shouldAnimate = self.animatedImage && isVisible;
  288. }
  289. - (void)displayDidRefresh:(CADisplayLink *)displayLink
  290. {
  291. // If for some reason a wild call makes it through when we shouldn't be animating, bail.
  292. // Early return!
  293. if (!self.shouldAnimate) {
  294. FLLog(FLLogLevelWarn, @"Trying to animate image when we shouldn't: %@", self);
  295. return;
  296. }
  297. NSNumber *delayTimeNumber = [self.animatedImage.delayTimesForIndexes objectForKey:@(self.currentFrameIndex)];
  298. // 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).
  299. if (delayTimeNumber) {
  300. NSTimeInterval delayTime = [delayTimeNumber floatValue];
  301. // If we have a nil image (e.g. waiting for frame), don't update the view nor playhead.
  302. UIImage *image = [self.animatedImage imageLazilyCachedAtIndex:self.currentFrameIndex];
  303. if (image) {
  304. FLLog(FLLogLevelVerbose, @"Showing frame %lu for animated image: %@", (unsigned long)self.currentFrameIndex, self.animatedImage);
  305. self.currentFrame = image;
  306. if (self.needsDisplayWhenImageBecomesAvailable) {
  307. [self.layer setNeedsDisplay];
  308. self.needsDisplayWhenImageBecomesAvailable = NO;
  309. }
  310. self.accumulator += displayLink.duration * displayLink.frameInterval;
  311. // While-loop first inspired by & good Karma to: https://github.com/ondalabs/OLImageView/blob/master/OLImageView.m
  312. while (self.accumulator >= delayTime) {
  313. self.accumulator -= delayTime;
  314. self.currentFrameIndex++;
  315. if (self.currentFrameIndex >= self.animatedImage.frameCount) {
  316. // If we've looped the number of times that this animated image describes, stop looping.
  317. self.loopCountdown--;
  318. if (self.loopCompletionBlock) {
  319. self.loopCompletionBlock(self.loopCountdown);
  320. }
  321. if (self.loopCountdown == 0) {
  322. [self stopAnimating];
  323. return;
  324. }
  325. self.currentFrameIndex = 0;
  326. }
  327. // Calling `-setNeedsDisplay` will just paint the current frame, not the new frame that we may have moved to.
  328. // Instead, set `needsDisplayWhenImageBecomesAvailable` to `YES` -- this will paint the new image once loaded.
  329. self.needsDisplayWhenImageBecomesAvailable = YES;
  330. }
  331. } else {
  332. FLLog(FLLogLevelDebug, @"Waiting for frame %lu for animated image: %@", (unsigned long)self.currentFrameIndex, self.animatedImage);
  333. #if defined(DEBUG) && DEBUG
  334. if ([self.debug_delegate respondsToSelector:@selector(debug_animatedImageView:waitingForFrame:duration:)]) {
  335. [self.debug_delegate debug_animatedImageView:self waitingForFrame:self.currentFrameIndex duration:(NSTimeInterval)displayLink.duration * displayLink.frameInterval];
  336. }
  337. #endif
  338. }
  339. } else {
  340. self.currentFrameIndex++;
  341. }
  342. }
  343. + (NSString *)defaultRunLoopMode
  344. {
  345. // Key off `activeProcessorCount` (as opposed to `processorCount`) since the system could shut down cores in certain situations.
  346. return [NSProcessInfo processInfo].activeProcessorCount > 1 ? NSRunLoopCommonModes : NSDefaultRunLoopMode;
  347. }
  348. #pragma mark - CALayerDelegate (Informal)
  349. #pragma mark Providing the Layer's Content
  350. - (void)displayLayer:(CALayer *)layer
  351. {
  352. layer.contents = (__bridge id)self.image.CGImage;
  353. }
  354. @end