From 9768097488a7e8ca3b5db14135cdbe5d3367322b Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 13 Nov 2021 14:52:02 -0500 Subject: [PATCH] Match gif playback progress through animation Closes #8 --- Tusker/ImageGrayscalifier.swift | 1 + .../AttachmentPreviewViewController.swift | 14 ++-- .../GalleryViewController.swift | 8 -- .../Large Image/LargeImageContentView.swift | 42 ++++++---- .../LargeImageViewController.swift | 1 - .../LoadingLargeImageViewController.swift | 33 +++++--- .../LargeImageExpandAnimationController.swift | 5 +- .../LargeImageShrinkAnimationController.swift | 6 +- Tusker/Views/Attachments/AttachmentView.swift | 31 ++++---- Tusker/Views/GIFImageView.swift | 77 ++++++++++++++++--- 10 files changed, 149 insertions(+), 69 deletions(-) diff --git a/Tusker/ImageGrayscalifier.swift b/Tusker/ImageGrayscalifier.swift index 63e01ca3ce..659cebf2e8 100644 --- a/Tusker/ImageGrayscalifier.swift +++ b/Tusker/ImageGrayscalifier.swift @@ -17,6 +17,7 @@ struct ImageGrayscalifier { static func convertIfNecessary(url: URL?, image: UIImage) -> UIImage? { if Preferences.shared.grayscaleImages, let source = image.cgImage { + // todo: should this return the original image if conversion fails? return convert(url: url, cgImage: source) } else { return image diff --git a/Tusker/Screens/Attachment Gallery/AttachmentPreviewViewController.swift b/Tusker/Screens/Attachment Gallery/AttachmentPreviewViewController.swift index 3f351c3685..bf0f7cc23b 100644 --- a/Tusker/Screens/Attachment Gallery/AttachmentPreviewViewController.swift +++ b/Tusker/Screens/Attachment Gallery/AttachmentPreviewViewController.swift @@ -11,10 +11,12 @@ import Pachyderm class AttachmentPreviewViewController: UIViewController { - let attachment: Attachment - - init(attachment: Attachment) { - self.attachment = attachment + private let attachment: Attachment + private let sourceView: AttachmentView + + init(sourceView: AttachmentView) { + self.attachment = sourceView.attachment + self.sourceView = sourceView super.init(nibName: nil, bundle: nil) } @@ -29,7 +31,9 @@ class AttachmentPreviewViewController: UIViewController { let imageView: UIImageView if attachment.url.pathExtension == "gif" { let gifView = GIFImageView(image: image) - gifView.animate(withGIFData: data) + let controller = sourceView.gifController ?? GIFController(gifData: data) + controller.attach(to: gifView) + controller.startAnimating() imageView = gifView } else { imageView = UIImageView(image: image) diff --git a/Tusker/Screens/Attachment Gallery/GalleryViewController.swift b/Tusker/Screens/Attachment Gallery/GalleryViewController.swift index 82672831d9..92406f19dd 100644 --- a/Tusker/Screens/Attachment Gallery/GalleryViewController.swift +++ b/Tusker/Screens/Attachment Gallery/GalleryViewController.swift @@ -41,14 +41,6 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc return animationSourceView?.image } } - var animationGifData: Data? { - let attachment = attachments[currentIndex] - if attachment.url.pathExtension == "gif" { - return ImageCache.attachments.getData(attachment.url) - } else { - return nil - } - } var dismissInteractionController: LargeImageInteractionController? var isInteractivelyAnimatingDismissal: Bool = false { diff --git a/Tusker/Screens/Large Image/LargeImageContentView.swift b/Tusker/Screens/Large Image/LargeImageContentView.swift index 7ac9492269..5f7e335409 100644 --- a/Tusker/Screens/Large Image/LargeImageContentView.swift +++ b/Tusker/Screens/Large Image/LargeImageContentView.swift @@ -12,7 +12,6 @@ import AVFoundation protocol LargeImageContentView: UIView { var animationImage: UIImage? { get } - var animationGifData: Data? { get } var activityItemsForSharing: [Any] { get } func grayscaleStateChanged() } @@ -20,7 +19,6 @@ protocol LargeImageContentView: UIView { class LargeImageImageContentView: GIFImageView, LargeImageContentView { var animationImage: UIImage? { image! } - let animationGifData: Data? var activityItemsForSharing: [Any] { [image!] @@ -28,22 +26,12 @@ class LargeImageImageContentView: GIFImageView, LargeImageContentView { private var sourceData: Data? - convenience init(sourceData data: Data, isGif: Bool) { - self.init(image: UIImage(data: data)!, gifData: isGif ? data : nil) - - self.sourceData = data - } - init(image: UIImage, gifData: Data?) { - self.animationGifData = gifData - + init(image: UIImage) { super.init(image: image) contentMode = .scaleAspectFit - if let data = gifData { - self.animate(withGIFData: data) - } } required init?(coder: NSCoder) { @@ -68,9 +56,35 @@ class LargeImageImageContentView: GIFImageView, LargeImageContentView { } } +class LargeImageGifContentView: GIFImageView, LargeImageContentView { + var animationImage: UIImage? { image } + + var activityItemsForSharing: [Any] { + // todo: should gifs share the data? + [image].compactMap { $0 } + } + + init(gifController: GIFController) { + super.init(image: gifController.lastFrame?.image) + + contentMode = .scaleAspectFit + + gifController.attach(to: self) + // todo: doing this in the init feels wrong + gifController.startAnimating() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func grayscaleStateChanged() { + // todo + } +} + class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView { private(set) var animationImage: UIImage? - var animationGifData: Data? { nil } var activityItemsForSharing: [Any] { // todo: what should we share for gifvs? // some SO posts indicate that just sharing a URL to the video should work, but that may need to be a local URL? diff --git a/Tusker/Screens/Large Image/LargeImageViewController.swift b/Tusker/Screens/Large Image/LargeImageViewController.swift index 143996106f..2dcb5e9a53 100644 --- a/Tusker/Screens/Large Image/LargeImageViewController.swift +++ b/Tusker/Screens/Large Image/LargeImageViewController.swift @@ -13,7 +13,6 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma weak var animationSourceView: UIImageView? var largeImageController: LargeImageViewController? { self } var animationImage: UIImage? { contentView.animationImage } - var animationGifData: Data? { contentView.animationGifData } var dismissInteractionController: LargeImageInteractionController? @IBOutlet weak var scrollView: UIScrollView! diff --git a/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift b/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift index a958225e52..487889b66b 100644 --- a/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift +++ b/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift @@ -40,7 +40,6 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie weak var animationSourceView: UIImageView? var largeImageController: LargeImageViewController? { largeImageVC } var animationImage: UIImage? { largeImageVC?.animationImage ?? animationSourceView?.image } - var animationGifData: Data? { largeImageVC?.animationGifData } var dismissInteractionController: LargeImageInteractionController? var isInteractivelyAnimatingDismissal: Bool = false { @@ -93,6 +92,8 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie // always load full resolution from disk for large image, in case the cache is scaled if let entry = cache.get(url, loadOriginal: true) { + // todo: if load original is true, is there any way entry.data could be nil? + // feels like the data param of createLargeImage shouldn't be optional createLargeImage(data: entry.data, image: entry.image, url: url) } else { createPreview() @@ -126,19 +127,31 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie guard !loaded else { return } loaded = true - if let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) { - let gifData = url.pathExtension == "gif" ? data : nil - createLargeImage(image: transformedImage, gifData: gifData) + let content: LargeImageContentView + + // todo: p sure grayscaling gifs has never worked + if url.pathExtension == "gif", let data = data { + // todo: pulling the gif controller out of the source view feels icky + // is it possible for the source view's gif controller to have different data than we just got? + // should this be a property set by the animation controller instead? + let gifController = (animationSourceView as? GIFImageView)?.gifController ?? GIFController(gifData: data) + content = LargeImageGifContentView(gifController: gifController) + } else { + if let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) { + content = LargeImageImageContentView(image: transformedImage) + } else { + content = LargeImageImageContentView(image: image) + } } + + setContent(content) } - private func createLargeImage(image: UIImage, gifData: Data?) { - let imageView = LargeImageImageContentView(image: image, gifData: gifData) - + private func setContent(_ content: LargeImageContentView) { if let existing = largeImageVC { - existing.contentView = imageView + existing.contentView = content } else { - largeImageVC = LargeImageViewController(contentView: imageView, description: imageDescription, sourceView: animationSourceView) + largeImageVC = LargeImageViewController(contentView: content, description: imageDescription, sourceView: animationSourceView) largeImageVC!.initialControlsVisible = initialControlsVisible largeImageVC!.shrinkGestureEnabled = false embedChild(largeImageVC!) @@ -154,7 +167,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: source) { image = grayscale } - self.createLargeImage(image: image, gifData: nil) + setContent(LargeImageImageContentView(image: image)) } } diff --git a/Tusker/Screens/Large Image/Transitions/LargeImageExpandAnimationController.swift b/Tusker/Screens/Large Image/Transitions/LargeImageExpandAnimationController.swift index bb6eea694b..f59689c819 100644 --- a/Tusker/Screens/Large Image/Transitions/LargeImageExpandAnimationController.swift +++ b/Tusker/Screens/Large Image/Transitions/LargeImageExpandAnimationController.swift @@ -12,7 +12,6 @@ protocol LargeImageAnimatableViewController: UIViewController { var animationSourceView: UIImageView? { get } var largeImageController: LargeImageViewController? { get } var animationImage: UIImage? { get } - var animationGifData: Data? { get } var dismissInteractionController: LargeImageInteractionController? { get } var isInteractivelyAnimatingDismissal: Bool { get set } } @@ -86,8 +85,8 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra let imageView = GIFImageView(frame: sourceFrame) imageView.image = image - if let gifData = toVC.animationGifData { - imageView.animate(withGIFData: gifData) + if let gifController = (sourceView as? GIFImageView)?.gifController { + gifController.attach(to: imageView) } imageView.contentMode = .scaleAspectFill imageView.layer.cornerRadius = sourceView.layer.cornerRadius diff --git a/Tusker/Screens/Large Image/Transitions/LargeImageShrinkAnimationController.swift b/Tusker/Screens/Large Image/Transitions/LargeImageShrinkAnimationController.swift index acd1ba39b7..dab8829023 100644 --- a/Tusker/Screens/Large Image/Transitions/LargeImageShrinkAnimationController.swift +++ b/Tusker/Screens/Large Image/Transitions/LargeImageShrinkAnimationController.swift @@ -56,8 +56,10 @@ class LargeImageShrinkAnimationController: NSObject, UIViewControllerAnimatedTra let imageView = GIFImageView(frame: originalFrame) imageView.image = image - if let gifData = fromVC.animationGifData { - imageView.animate(withGIFData: gifData) + if let gifController = (sourceView as? GIFImageView)?.gifController { + gifController.attach(to: imageView) + // todo: this might not be necessary, the large image content view should have started it + gifController.startAnimating() } imageView.contentMode = .scaleAspectFill imageView.layer.cornerRadius = 0 diff --git a/Tusker/Views/Attachments/AttachmentView.swift b/Tusker/Views/Attachments/AttachmentView.swift index 837387610f..158d2bebde 100644 --- a/Tusker/Views/Attachments/AttachmentView.swift +++ b/Tusker/Views/Attachments/AttachmentView.swift @@ -29,14 +29,6 @@ class AttachmentView: GIFImageView { private var attachmentRequest: ImageCache.Request? private var source: Source? - var gifData: Data? { - switch source { - case let .gifData(_, data): - return data - default: - return nil - } - } private var autoplayGifs: Bool { Preferences.shared.automaticallyPlayGifs && !ProcessInfo.processInfo.isLowPowerModeEnabled } @@ -91,11 +83,13 @@ class AttachmentView: GIFImageView { // NSProcessInfoPowerStateDidChange is sometimes fired on a background thread DispatchQueue.main.async { if self.attachment.kind == .image, - let gifData = self.gifData { + let gifController = self.gifController { if self.autoplayGifs && !self.isAnimatingGIF { - self.animate(withGIFData: gifData) + gifController.attach(to: self) + gifController.startAnimating() } else if !self.autoplayGifs && self.isAnimatingGIF { - self.stopAnimatingGIF() + // detach instead of stopping so that any other attached gif views keep animating + self.detachGIFController() } } else if self.attachment.kind == .gifv, let gifvView = self.gifvView { @@ -166,13 +160,18 @@ class AttachmentView: GIFImageView { } if self.attachment.url.pathExtension == "gif" { self.source = .gifData(attachmentURL, data) - if self.autoplayGifs { - DispatchQueue.main.async { - self.animate(withGIFData: data) + let controller = GIFController(gifData: data) + DispatchQueue.main.async { + controller.attach(to: self) + if self.autoplayGifs { + controller.startAnimating() } - } else { + } + + if !self.autoplayGifs { self.displayImage() } + } else { self.source = .imageData(attachmentURL, data) self.displayImage() @@ -322,7 +321,7 @@ extension AttachmentView: UIContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { return UIContextMenuConfiguration(identifier: nil, previewProvider: { () -> UIViewController? in if self.attachment.kind == .image { - return AttachmentPreviewViewController(attachment: self.attachment) + return AttachmentPreviewViewController(sourceView: self) } else if self.attachment.kind == .gifv { let vc = GifvAttachmentViewController(attachment: self.attachment) vc.preferredContentSize = self.image?.size ?? .zero diff --git a/Tusker/Views/GIFImageView.swift b/Tusker/Views/GIFImageView.swift index b01f60bba3..2b43f8163b 100644 --- a/Tusker/Views/GIFImageView.swift +++ b/Tusker/Views/GIFImageView.swift @@ -10,25 +10,82 @@ import UIKit class GIFImageView: UIImageView { - private(set) var isAnimatingGIF: Bool = false - private var shouldStopAnimatingGIF = false + fileprivate(set) var gifController: GIFController? = nil + var isAnimatingGIF: Bool { gifController?.state == .playing } + /// Detaches the current GIF controller from this view. + /// If this view is the GIF controller's only one, it will stop itself. + func detachGIFController() { + gifController?.detach(from: self) + } - func animate(withGIFData data: Data) { - CGAnimateImageDataWithBlock(data as CFData, nil) { [weak self] (frameIndex, frame, stop) in +} + +/// A `GIFController` controls the animation of one or more `GIFImageView`s. +class GIFController { + + // GIFImageView strongly holds the controller so that when the last view detaches, the controller is freed + private var imageViews = WeakArray() + + private(set) var gifData: Data + private(set) var state: State = .stopped + private(set) var lastFrame: (image: UIImage, index: Int)? = nil + + init(gifData: Data) { + self.gifData = gifData + } + + /// Attaches another view to this controller, letting it play back alongside the others. + /// Immediately brings it into sync with the others, setting the last frame if there was one. + func attach(to view: GIFImageView) { + imageViews.append(view) + view.gifController = self + + if let lastFrame = lastFrame { + view.image = lastFrame.image + } + } + + /// Detaches the given view from this controller. + /// If no views attached views remain, the last strong reference to this controller is nilled out + /// and image animation will stop at the next CGAnimateImageDataWithBlock callback. + func detach(from view: GIFImageView) { + // todo: does === work the way i want here + imageViews.removeAll(where: { $0 === view }) + view.gifController = nil + } + + func startAnimating() { + guard state.shouldStop else { return } + + state = .playing + + CGAnimateImageDataWithBlock(gifData as CFData, nil) { [weak self] (frameIndex, cgImage, stop) in guard let self = self else { stop.pointee = true return } - self.image = UIImage(cgImage: frame) - stop.pointee = self.shouldStopAnimatingGIF + let image = UIImage(cgImage: cgImage) + self.lastFrame = (image, frameIndex) + for case let .some(view) in self.imageViews { + view.image = image + } + stop.pointee = self.state.shouldStop } - isAnimatingGIF = true } - func stopAnimatingGIF() { - shouldStopAnimatingGIF = true - isAnimatingGIF = false + func stopAnimating() { + guard state == .playing else { return } + + state = .stopping + } + + enum State: Equatable { + case stopped, playing, stopping + + var shouldStop: Bool { + self == .stopped || self == .stopping + } } }