Match gif playback progress through animation

Closes #8
This commit is contained in:
Shadowfacts 2021-11-13 14:52:02 -05:00
parent f5e9f71586
commit 9768097488
10 changed files with 149 additions and 69 deletions

View File

@ -17,6 +17,7 @@ struct ImageGrayscalifier {
static func convertIfNecessary(url: URL?, image: UIImage) -> UIImage? { static func convertIfNecessary(url: URL?, image: UIImage) -> UIImage? {
if Preferences.shared.grayscaleImages, if Preferences.shared.grayscaleImages,
let source = image.cgImage { let source = image.cgImage {
// todo: should this return the original image if conversion fails?
return convert(url: url, cgImage: source) return convert(url: url, cgImage: source)
} else { } else {
return image return image

View File

@ -11,10 +11,12 @@ import Pachyderm
class AttachmentPreviewViewController: UIViewController { class AttachmentPreviewViewController: UIViewController {
let attachment: Attachment private let attachment: Attachment
private let sourceView: AttachmentView
init(attachment: Attachment) { init(sourceView: AttachmentView) {
self.attachment = attachment self.attachment = sourceView.attachment
self.sourceView = sourceView
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
@ -29,7 +31,9 @@ class AttachmentPreviewViewController: UIViewController {
let imageView: UIImageView let imageView: UIImageView
if attachment.url.pathExtension == "gif" { if attachment.url.pathExtension == "gif" {
let gifView = GIFImageView(image: image) let gifView = GIFImageView(image: image)
gifView.animate(withGIFData: data) let controller = sourceView.gifController ?? GIFController(gifData: data)
controller.attach(to: gifView)
controller.startAnimating()
imageView = gifView imageView = gifView
} else { } else {
imageView = UIImageView(image: image) imageView = UIImageView(image: image)

View File

@ -41,14 +41,6 @@ class GalleryViewController: UIPageViewController, UIPageViewControllerDataSourc
return animationSourceView?.image 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 dismissInteractionController: LargeImageInteractionController?
var isInteractivelyAnimatingDismissal: Bool = false { var isInteractivelyAnimatingDismissal: Bool = false {

View File

@ -12,7 +12,6 @@ import AVFoundation
protocol LargeImageContentView: UIView { protocol LargeImageContentView: UIView {
var animationImage: UIImage? { get } var animationImage: UIImage? { get }
var animationGifData: Data? { get }
var activityItemsForSharing: [Any] { get } var activityItemsForSharing: [Any] { get }
func grayscaleStateChanged() func grayscaleStateChanged()
} }
@ -20,7 +19,6 @@ protocol LargeImageContentView: UIView {
class LargeImageImageContentView: GIFImageView, LargeImageContentView { class LargeImageImageContentView: GIFImageView, LargeImageContentView {
var animationImage: UIImage? { image! } var animationImage: UIImage? { image! }
let animationGifData: Data?
var activityItemsForSharing: [Any] { var activityItemsForSharing: [Any] {
[image!] [image!]
@ -28,22 +26,12 @@ class LargeImageImageContentView: GIFImageView, LargeImageContentView {
private var sourceData: Data? 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) super.init(image: image)
contentMode = .scaleAspectFit contentMode = .scaleAspectFit
if let data = gifData {
self.animate(withGIFData: data)
}
} }
required init?(coder: NSCoder) { 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 { class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView {
private(set) var animationImage: UIImage? private(set) var animationImage: UIImage?
var animationGifData: Data? { nil }
var activityItemsForSharing: [Any] { var activityItemsForSharing: [Any] {
// todo: what should we share for gifvs? // 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? // some SO posts indicate that just sharing a URL to the video should work, but that may need to be a local URL?

View File

@ -13,7 +13,6 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma
weak var animationSourceView: UIImageView? weak var animationSourceView: UIImageView?
var largeImageController: LargeImageViewController? { self } var largeImageController: LargeImageViewController? { self }
var animationImage: UIImage? { contentView.animationImage } var animationImage: UIImage? { contentView.animationImage }
var animationGifData: Data? { contentView.animationGifData }
var dismissInteractionController: LargeImageInteractionController? var dismissInteractionController: LargeImageInteractionController?
@IBOutlet weak var scrollView: UIScrollView! @IBOutlet weak var scrollView: UIScrollView!

View File

@ -40,7 +40,6 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
weak var animationSourceView: UIImageView? weak var animationSourceView: UIImageView?
var largeImageController: LargeImageViewController? { largeImageVC } var largeImageController: LargeImageViewController? { largeImageVC }
var animationImage: UIImage? { largeImageVC?.animationImage ?? animationSourceView?.image } var animationImage: UIImage? { largeImageVC?.animationImage ?? animationSourceView?.image }
var animationGifData: Data? { largeImageVC?.animationGifData }
var dismissInteractionController: LargeImageInteractionController? var dismissInteractionController: LargeImageInteractionController?
var isInteractivelyAnimatingDismissal: Bool = false { 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 // always load full resolution from disk for large image, in case the cache is scaled
if let entry = cache.get(url, loadOriginal: true) { 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) createLargeImage(data: entry.data, image: entry.image, url: url)
} else { } else {
createPreview() createPreview()
@ -126,19 +127,31 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
guard !loaded else { return } guard !loaded else { return }
loaded = true loaded = true
if let transformedImage = ImageGrayscalifier.convertIfNecessary(url: url, image: image) { let content: LargeImageContentView
let gifData = url.pathExtension == "gif" ? data : nil
createLargeImage(image: transformedImage, gifData: gifData) // 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?) { private func setContent(_ content: LargeImageContentView) {
let imageView = LargeImageImageContentView(image: image, gifData: gifData)
if let existing = largeImageVC { if let existing = largeImageVC {
existing.contentView = imageView existing.contentView = content
} else { } else {
largeImageVC = LargeImageViewController(contentView: imageView, description: imageDescription, sourceView: animationSourceView) largeImageVC = LargeImageViewController(contentView: content, description: imageDescription, sourceView: animationSourceView)
largeImageVC!.initialControlsVisible = initialControlsVisible largeImageVC!.initialControlsVisible = initialControlsVisible
largeImageVC!.shrinkGestureEnabled = false largeImageVC!.shrinkGestureEnabled = false
embedChild(largeImageVC!) embedChild(largeImageVC!)
@ -154,7 +167,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie
let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: source) { let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: source) {
image = grayscale image = grayscale
} }
self.createLargeImage(image: image, gifData: nil) setContent(LargeImageImageContentView(image: image))
} }
} }

View File

@ -12,7 +12,6 @@ protocol LargeImageAnimatableViewController: UIViewController {
var animationSourceView: UIImageView? { get } var animationSourceView: UIImageView? { get }
var largeImageController: LargeImageViewController? { get } var largeImageController: LargeImageViewController? { get }
var animationImage: UIImage? { get } var animationImage: UIImage? { get }
var animationGifData: Data? { get }
var dismissInteractionController: LargeImageInteractionController? { get } var dismissInteractionController: LargeImageInteractionController? { get }
var isInteractivelyAnimatingDismissal: Bool { get set } var isInteractivelyAnimatingDismissal: Bool { get set }
} }
@ -86,8 +85,8 @@ class LargeImageExpandAnimationController: NSObject, UIViewControllerAnimatedTra
let imageView = GIFImageView(frame: sourceFrame) let imageView = GIFImageView(frame: sourceFrame)
imageView.image = image imageView.image = image
if let gifData = toVC.animationGifData { if let gifController = (sourceView as? GIFImageView)?.gifController {
imageView.animate(withGIFData: gifData) gifController.attach(to: imageView)
} }
imageView.contentMode = .scaleAspectFill imageView.contentMode = .scaleAspectFill
imageView.layer.cornerRadius = sourceView.layer.cornerRadius imageView.layer.cornerRadius = sourceView.layer.cornerRadius

View File

@ -56,8 +56,10 @@ class LargeImageShrinkAnimationController: NSObject, UIViewControllerAnimatedTra
let imageView = GIFImageView(frame: originalFrame) let imageView = GIFImageView(frame: originalFrame)
imageView.image = image imageView.image = image
if let gifData = fromVC.animationGifData { if let gifController = (sourceView as? GIFImageView)?.gifController {
imageView.animate(withGIFData: gifData) 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.contentMode = .scaleAspectFill
imageView.layer.cornerRadius = 0 imageView.layer.cornerRadius = 0

View File

@ -29,14 +29,6 @@ class AttachmentView: GIFImageView {
private var attachmentRequest: ImageCache.Request? private var attachmentRequest: ImageCache.Request?
private var source: Source? private var source: Source?
var gifData: Data? {
switch source {
case let .gifData(_, data):
return data
default:
return nil
}
}
private var autoplayGifs: Bool { private var autoplayGifs: Bool {
Preferences.shared.automaticallyPlayGifs && !ProcessInfo.processInfo.isLowPowerModeEnabled Preferences.shared.automaticallyPlayGifs && !ProcessInfo.processInfo.isLowPowerModeEnabled
} }
@ -91,11 +83,13 @@ class AttachmentView: GIFImageView {
// NSProcessInfoPowerStateDidChange is sometimes fired on a background thread // NSProcessInfoPowerStateDidChange is sometimes fired on a background thread
DispatchQueue.main.async { DispatchQueue.main.async {
if self.attachment.kind == .image, if self.attachment.kind == .image,
let gifData = self.gifData { let gifController = self.gifController {
if self.autoplayGifs && !self.isAnimatingGIF { if self.autoplayGifs && !self.isAnimatingGIF {
self.animate(withGIFData: gifData) gifController.attach(to: self)
gifController.startAnimating()
} else if !self.autoplayGifs && self.isAnimatingGIF { } 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, } else if self.attachment.kind == .gifv,
let gifvView = self.gifvView { let gifvView = self.gifvView {
@ -166,13 +160,18 @@ class AttachmentView: GIFImageView {
} }
if self.attachment.url.pathExtension == "gif" { if self.attachment.url.pathExtension == "gif" {
self.source = .gifData(attachmentURL, data) self.source = .gifData(attachmentURL, data)
if self.autoplayGifs { let controller = GIFController(gifData: data)
DispatchQueue.main.async { DispatchQueue.main.async {
self.animate(withGIFData: data) controller.attach(to: self)
if self.autoplayGifs {
controller.startAnimating()
} }
} else { }
if !self.autoplayGifs {
self.displayImage() self.displayImage()
} }
} else { } else {
self.source = .imageData(attachmentURL, data) self.source = .imageData(attachmentURL, data)
self.displayImage() self.displayImage()
@ -322,7 +321,7 @@ extension AttachmentView: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
return UIContextMenuConfiguration(identifier: nil, previewProvider: { () -> UIViewController? in return UIContextMenuConfiguration(identifier: nil, previewProvider: { () -> UIViewController? in
if self.attachment.kind == .image { if self.attachment.kind == .image {
return AttachmentPreviewViewController(attachment: self.attachment) return AttachmentPreviewViewController(sourceView: self)
} else if self.attachment.kind == .gifv { } else if self.attachment.kind == .gifv {
let vc = GifvAttachmentViewController(attachment: self.attachment) let vc = GifvAttachmentViewController(attachment: self.attachment)
vc.preferredContentSize = self.image?.size ?? .zero vc.preferredContentSize = self.image?.size ?? .zero

View File

@ -10,25 +10,82 @@ import UIKit
class GIFImageView: UIImageView { class GIFImageView: UIImageView {
private(set) var isAnimatingGIF: Bool = false fileprivate(set) var gifController: GIFController? = nil
private var shouldStopAnimatingGIF = false 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<GIFImageView>()
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 { guard let self = self else {
stop.pointee = true stop.pointee = true
return return
} }
self.image = UIImage(cgImage: frame) let image = UIImage(cgImage: cgImage)
stop.pointee = self.shouldStopAnimatingGIF self.lastFrame = (image, frameIndex)
for case let .some(view) in self.imageViews {
view.image = image
}
stop.pointee = self.state.shouldStop
} }
isAnimatingGIF = true
} }
func stopAnimatingGIF() { func stopAnimating() {
shouldStopAnimatingGIF = true guard state == .playing else { return }
isAnimatingGIF = false
state = .stopping
}
enum State: Equatable {
case stopped, playing, stopping
var shouldStop: Bool {
self == .stopped || self == .stopping
}
} }
} }