Compare commits

...

3 Commits

16 changed files with 183 additions and 100 deletions

3
.gitmodules vendored
View File

@ -1,6 +1,3 @@
[submodule "Gifu"]
path = Gifu
url = git://github.com/kaishin/Gifu.git
[submodule "Embassy"]
path = Embassy
url = https://github.com/envoy/Embassy.git

1
Gifu

@ -1 +0,0 @@
Subproject commit 9b1a6461aa3b5f66cb0ed3a50c5523db0b4fb007

View File

@ -13,12 +13,12 @@ public struct CharacterCounter {
static let linkDetector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
static let mention = try! NSRegularExpression(pattern: "(@[a-z0-9_]+)(?:@[a-z0-9\\-\\.]+[a-z0-9]+)?", options: .caseInsensitive)
public static func count(text: String) -> Int {
public static func count(text: String, for instance: Instance? = nil) -> Int {
let mentionsRemoved = removeMentions(in: text)
var count = mentionsRemoved.count
for match in linkDetector.matches(in: mentionsRemoved, options: [], range: NSRange(location: 0, length: mentionsRemoved.utf16.count)) {
count -= match.range.length
count += 23 // Mastodon link length
count += instance?.configuration?.statuses.charactersReservedPerURL ?? 23 // default Mastodon link length
}
return count
}

View File

@ -274,8 +274,6 @@
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */; };
D6B81F442560390300F6E31D /* MenuController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B81F432560390300F6E31D /* MenuController.swift */; };
D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; };
D6BC874521961F73006163F1 /* Gifu.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BC874421961F73006163F1 /* Gifu.framework */; };
D6BC874621961F73006163F1 /* Gifu.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D6BC874421961F73006163F1 /* Gifu.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
D6BC8748219738E1006163F1 /* EnhancedTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */; };
D6BC9DB1232C61BC002CA326 /* NotificationsPageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */; };
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */; };
@ -309,6 +307,7 @@
D6D4DDE5212518A200E1C4BB /* TuskerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDE4212518A200E1C4BB /* TuskerTests.swift */; };
D6D4DDF0212518A200E1C4BB /* TuskerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */; };
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */; };
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD2A44273D6C5700386A6C /* GIFImageView.swift */; };
D6DD353D22F28CD000A9563A /* ContentWarningCopyMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */; };
D6DD353F22F502EC00A9563A /* Preferences+Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */; };
D6DEA0DE268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DEA0DC268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift */; };
@ -406,7 +405,6 @@
dstSubfolderSpec = 10;
files = (
D61099C12144B0CC00432DC2 /* Pachyderm.framework in Embed Frameworks */,
D6BC874621961F73006163F1 /* Gifu.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
@ -681,7 +679,6 @@
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableViewController.swift; sourceTree = "<group>"; };
D6B81F432560390300F6E31D /* MenuController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuController.swift; sourceTree = "<group>"; };
D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = "<group>"; };
D6BC874421961F73006163F1 /* Gifu.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Gifu.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D6BC8747219738E1006163F1 /* EnhancedTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnhancedTableViewController.swift; sourceTree = "<group>"; };
D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPageViewController.swift; sourceTree = "<group>"; };
D6BC9DB2232D4C07002CA326 /* WellnessPrefsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WellnessPrefsView.swift; sourceTree = "<group>"; };
@ -721,6 +718,7 @@
D6D4DDEF212518A200E1C4BB /* TuskerUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerUITests.swift; sourceTree = "<group>"; };
D6D4DDF1212518A200E1C4BB /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D6DD2A3E273C1F4900386A6C /* ComposeAttachmentImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeAttachmentImage.swift; sourceTree = "<group>"; };
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GIFImageView.swift; sourceTree = "<group>"; };
D6DD353C22F28CD000A9563A /* ContentWarningCopyMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningCopyMode.swift; sourceTree = "<group>"; };
D6DD353E22F502EC00A9563A /* Preferences+Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Preferences+Notification.swift"; sourceTree = "<group>"; };
D6DEA0DC268400C300FE896A /* ConfirmLoadMoreTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmLoadMoreTableViewCell.swift; sourceTree = "<group>"; };
@ -780,7 +778,6 @@
D61099C02144B0CC00432DC2 /* Pachyderm.framework in Frameworks */,
D6B0539F23BD2BA300A066FA /* SheetController in Frameworks */,
D69CCBBF249E6EFD000AF167 /* CrashReporter in Frameworks */,
D6BC874521961F73006163F1 /* Gifu.framework in Frameworks */,
D60CFFDB24A290BA00D00083 /* SwiftSoup in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1480,6 +1477,7 @@
D6969E9F240C8384002843CE /* EmojiLabel.swift */,
D67C1794266D57D10070F250 /* FastAccountSwitcherIndicatorView.swift */,
D6EAE0DA2550CC8A002DB0AC /* FocusableTextField.swift */,
D6DD2A44273D6C5700386A6C /* GIFImageView.swift */,
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */,
D620483323D3801D008A63EF /* LinkTextView.swift */,
D68E6F58253C9969001A1B4C /* MultiSourceEmojiLabel.swift */,
@ -1546,7 +1544,6 @@
D6D4DDC3212518A000E1C4BB = {
isa = PBXGroup;
children = (
D6BC874421961F73006163F1 /* Gifu.framework */,
D61099AC2144B0CC00432DC2 /* Pachyderm */,
D61099B92144B0CC00432DC2 /* PachydermTests */,
D6D4DDCE212518A000E1C4BB /* Tusker */,
@ -2191,6 +2188,7 @@
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */,
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */,
D6DD2A3F273C1F4900386A6C /* ComposeAttachmentImage.swift in Sources */,
D6DD2A45273D6C5700386A6C /* GIFImageView.swift in Sources */,
0427033822B30F5F000D31B6 /* BehaviorPrefsView.swift in Sources */,
D627943923A553B600D38C68 /* UnbookmarkStatusActivity.swift in Sources */,
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */,

View File

@ -7,9 +7,6 @@
<FileRef
location = "group:BlankSlate.xcappdata">
</FileRef>
<FileRef
location = "group:Gifu/Gifu.xcodeproj">
</FileRef>
<FileRef
location = "group:Embassy/Embassy.xcodeproj">
</FileRef>

View File

@ -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

View File

@ -8,14 +8,15 @@
import UIKit
import Pachyderm
import Gifu
class AttachmentPreviewViewController: UIViewController {
let attachment: Attachment
private let attachment: Attachment
private let sourceView: AttachmentView
init(attachment: Attachment) {
self.attachment = attachment
init(sourceView: AttachmentView) {
self.attachment = sourceView.attachment
self.sourceView = sourceView
super.init(nibName: nil, bundle: nil)
}
@ -30,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)

View File

@ -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 {

View File

@ -30,7 +30,7 @@ struct ComposeView: View {
var charactersRemaining: Int {
let limit = mastodonController.instance?.maxStatusCharacters ?? 500
let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
return limit - (cwCount + CharacterCounter.count(text: draft.text))
return limit - (cwCount + CharacterCounter.count(text: draft.text, for: mastodonController.instance))
}
var requiresAttachmentDescriptions: Bool {

View File

@ -7,24 +7,18 @@
//
import UIKit
import Gifu
import Pachyderm
import AVFoundation
protocol LargeImageContentView: UIView {
var animationImage: UIImage? { get }
var animationGifData: Data? { get }
var activityItemsForSharing: [Any] { get }
func grayscaleStateChanged()
}
class LargeImageImageContentView: UIImageView, GIFAnimatable, LargeImageContentView {
lazy var animator: Animator? = {
return Animator(withDelegate: self)
}()
class LargeImageImageContentView: GIFImageView, LargeImageContentView {
var animationImage: UIImage? { image! }
let animationGifData: Data?
var activityItemsForSharing: [Any] {
[image!]
@ -32,34 +26,18 @@ class LargeImageImageContentView: UIImageView, GIFAnimatable, LargeImageContentV
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) {
fatalError("init(coder:) has not been implemented")
}
override public func display(_ layer: CALayer) {
super.display(layer)
updateImageIfNeeded()
}
func grayscaleStateChanged() {
guard let data = sourceData else {
return
@ -78,9 +56,35 @@ class LargeImageImageContentView: UIImageView, GIFAnimatable, LargeImageContentV
}
}
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?

View File

@ -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!

View File

@ -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))
}
}

View File

@ -7,13 +7,11 @@
//
import UIKit
import Gifu
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 }
}
@ -87,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

View File

@ -7,7 +7,6 @@
//
import UIKit
import Gifu
class LargeImageShrinkAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
@ -57,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

View File

@ -8,7 +8,6 @@
import UIKit
import Pachyderm
import Gifu
import AVFoundation
protocol AttachmentViewDelegate: AnyObject {
@ -16,7 +15,7 @@ protocol AttachmentViewDelegate: AnyObject {
func attachmentViewPresent(_ vc: UIViewController, animated: Bool)
}
class AttachmentView: UIImageView, GIFAnimatable {
class AttachmentView: GIFImageView {
weak var delegate: AttachmentViewDelegate?
@ -30,22 +29,12 @@ class AttachmentView: UIImageView, GIFAnimatable {
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
}
private var isGrayscale = false
public lazy var animator: Animator? = Animator(withDelegate: self)
init(attachment: Attachment, index: Int, expectedSize: CGSize) {
super.init(image: nil)
commonInit()
@ -94,11 +83,13 @@ class AttachmentView: UIImageView, GIFAnimatable {
// 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 {
@ -169,13 +160,18 @@ class AttachmentView: UIImageView, GIFAnimatable {
}
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()
@ -293,12 +289,6 @@ class AttachmentView: UIImageView, GIFAnimatable {
}
}
override func display(_ layer: CALayer) {
super.display(layer)
updateImageIfNeeded()
}
func showGallery() {
if let delegate = delegate,
let gallery = delegate.attachmentViewGallery(startingAt: index) {
@ -331,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

View File

@ -0,0 +1,91 @@
//
// GIFImageView.swift
// Tusker
//
// Created by Shadowfacts on 11/11/21.
// Copyright © 2021 Shadowfacts. All rights reserved.
//
import UIKit
class GIFImageView: UIImageView {
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)
}
}
/// 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 {
stop.pointee = true
return
}
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
}
}
func stopAnimating() {
guard state == .playing else { return }
state = .stopping
}
enum State: Equatable {
case stopped, playing, stopping
var shouldStop: Bool {
self == .stopped || self == .stopping
}
}
}