diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 937cac7..96c91e7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,14 +9,33 @@ on: - master jobs: - build: - runs-on: macOS-13 + # Device list: https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md + build-xcode-15: + runs-on: macos-latest env: - DEVELOPER_DIR: /Applications/Xcode_15.0.1.app/Contents/Developer + DEVELOPER_DIR: /Applications/Xcode_15.4.app/Contents/Developer steps: - uses: actions/checkout@v1 + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "15.4.0" - name: Run iOS tests - run: make test-ios + run: make test-ios-17 + - name: Run tvOS tests + run: make test-tvos + + build-xcode-16: + runs-on: macos-latest + env: + DEVELOPER_DIR: /Applications/Xcode_16.1.app/Contents/Developer + + steps: + - uses: actions/checkout@v1 + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "16.1.0" + - name: Run iOS tests + run: make test-ios-18 - name: Run tvOS tests run: make test-tvos diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index b6f71af..968c87a 100755 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 46; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -217,8 +217,9 @@ 9D98822F19BC69CA00B790C6 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1120; - LastUpgradeCheck = 1200; + LastUpgradeCheck = 1620; ORGANIZATIONNAME = "Kaishin & Co"; TargetAttributes = { 0007E16523809E9C000FED9F = { @@ -338,7 +339,10 @@ GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = "$(SRCROOT)/Demo-tvOS/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Gifu; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.redalemeden.Gifu-tvOSDemo"; @@ -347,7 +351,7 @@ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 3; - TVOS_DEPLOYMENT_TARGET = 13.2; + TVOS_DEPLOYMENT_TARGET = 18.0; }; name = Debug; }; @@ -368,15 +372,19 @@ GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = "$(SRCROOT)/Demo-tvOS/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Gifu; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.redalemeden.Gifu-tvOSDemo"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = appletvos; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 3; - TVOS_DEPLOYMENT_TARGET = 13.2; + TVOS_DEPLOYMENT_TARGET = 18.0; }; name = Release; }; @@ -413,6 +421,7 @@ COPY_PHASE_STRIP = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -428,7 +437,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -469,6 +478,7 @@ COPY_PHASE_STRIP = YES; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -477,7 +487,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; VALIDATE_PRODUCT = YES; @@ -493,8 +503,11 @@ FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "$(SRCROOT)/Demo-iOS/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Gifu; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = net.4rays.Gifu.Demo; PRODUCT_NAME = "Gifu-iOSDemo"; @@ -513,13 +526,17 @@ FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "$(SRCROOT)/Demo-iOS/Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = Gifu; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); OTHER_SWIFT_FLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = net.4rays.Gifu.Demo; PRODUCT_NAME = "Gifu-iOSDemo"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo-iOS.xcscheme b/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo-iOS.xcscheme index 9749901..33f0f25 100644 --- a/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo-iOS.xcscheme +++ b/Demo/Demo.xcodeproj/xcshareddata/xcschemes/Demo-iOS.xcscheme @@ -1,6 +1,6 @@ "MIT", :file => "LICENSE" } s.author = { "Reda Lemeden" => "git@redalemeden.com" } s.source = { :git => "https://github.com/kaishin/Gifu.git", :tag => "v#{s.version}", :submodules => true } - s.platform = :ios, "9.0" - s.platform = :tvos, "9.0" - s.swift_versions = ["5.0", "5.1", "5.2", "5.3", "5.4"] + s.platform = :ios, "14.0" + s.platform = :tvos, "14.0" + s.swift_versions = ["5.0", "5.1", "5.2", "5.3", "5.4", "6.0", "6.1", "6.2"] s.ios.source_files = "Sources/**/*.{h,swift}" s.tvos.source_files = "Sources/**/*.{h,swift}" - s.ios.deployment_target = "9.0" - s.tvos.deployment_target = "9.0" + s.ios.deployment_target = "14.0" + s.tvos.deployment_target = "14.0" end diff --git a/Gifu.xcodeproj/project.pbxproj b/Gifu.xcodeproj/project.pbxproj index 960bee3..7a0d47b 100644 --- a/Gifu.xcodeproj/project.pbxproj +++ b/Gifu.xcodeproj/project.pbxproj @@ -233,7 +233,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1540; + LastUpgradeCheck = 1620; ORGANIZATIONNAME = "Kaishin & Co"; TargetAttributes = { 009BD1351BBC7F6500FC982B = { @@ -335,7 +335,7 @@ DEVELOPMENT_TEAM = 5G38N4D8G2; GCC_NO_COMMON_BLOCKS = YES; INFOPLIST_FILE = Tests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -363,7 +363,7 @@ DEVELOPMENT_TEAM = 5G38N4D8G2; GCC_NO_COMMON_BLOCKS = YES; INFOPLIST_FILE = Tests/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -386,6 +386,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -447,6 +448,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -511,7 +513,7 @@ FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "$(SRCROOT)/Supporting Files/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -527,6 +529,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3"; + TVOS_DEPLOYMENT_TARGET = 15.6; }; name = Debug; }; @@ -545,7 +548,7 @@ FRAMEWORK_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = "$(SRCROOT)/Supporting Files/Info.plist"; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -561,6 +564,7 @@ SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,3"; + TVOS_DEPLOYMENT_TARGET = 15.6; }; name = Release; }; diff --git a/Gifu.xcodeproj/xcshareddata/xcschemes/Gifu.xcscheme b/Gifu.xcodeproj/xcshareddata/xcschemes/Gifu.xcscheme index 3201252..69f2581 100644 --- a/Gifu.xcodeproj/xcshareddata/xcschemes/Gifu.xcscheme +++ b/Gifu.xcodeproj/xcshareddata/xcschemes/Gifu.xcscheme @@ -1,6 +1,6 @@ Void)? = nil @@ -31,6 +32,7 @@ public class Animator { /// Responsible for starting and stopping the animation. private lazy var displayLink: CADisplayLink = { [unowned self] in self.displayLinkInitialized = true + let display = CADisplayLink( target: DisplayLinkProxy(target: self), selector: #selector(DisplayLinkProxy.onScreenUpdate) @@ -71,7 +73,7 @@ public class Animator { store.shouldChangeFrame(with: displayLink.duration) { if $0 { - delegate.animatorHasNewFrame() + delegate?.animatorHasNewFrame() if store.isLoopFinished, let loopBlock { loopBlock() } @@ -88,8 +90,12 @@ public class Animator { /// - parameter loopCount: Desired number of loops, <= 0 for infinite loop. /// - parameter completionHandler: Completion callback function func prepareForAnimation( - withGIFNamed imageName: String, inBundle bundle: Bundle = .main, size: CGSize, - contentMode: UIView.ContentMode, loopCount: Int = 0, completionHandler: (() -> Void)? = nil + withGIFNamed imageName: String, + inBundle bundle: Bundle = .main, + size: CGSize, + contentMode: UIView.ContentMode, + loopCount: Int = 0, + completionHandler: (@Sendable () -> Void)? = nil ) { guard let extensionRemoved = imageName.components(separatedBy: ".")[safe: 0], let imagePath = bundle.url(forResource: extensionRemoved, withExtension: "gif"), @@ -101,7 +107,8 @@ public class Animator { size: size, contentMode: contentMode, loopCount: loopCount, - completionHandler: completionHandler) + completionHandler: completionHandler + ) } /// Prepares the animator instance for animation. @@ -116,7 +123,7 @@ public class Animator { size: CGSize, contentMode: UIView.ContentMode, loopCount: Int = 0, - completionHandler: (() -> Void)? = nil + completionHandler: (@Sendable () -> Void)? = nil ) { frameStore = FrameStore( data: imageData, @@ -136,12 +143,18 @@ public class Animator { displayLink.add(to: .main, forMode: RunLoop.Mode.common) } - deinit { - if displayLinkInitialized { + private func invalidateDisplayLink() { + Task { [displayLink] in displayLink.invalidate() } } + deinit { + MainActor.assumeIsolated { + invalidateDisplayLink() + } + } + /// Start animating. func startAnimating() { if frameStore?.isAnimatable ?? false { @@ -164,9 +177,13 @@ public class Animator { /// - parameter animationBlock: Callback for when all the loops of the animation are done (never called for infinite loops) /// - parameter loopBlock: Callback for when a loop is done (at the end of each loop) func animate( - withGIFNamed imageName: String, size: CGSize, contentMode: UIView.ContentMode, - loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil, - loopBlock: (() -> Void)? = nil + withGIFNamed imageName: String, + size: CGSize, + contentMode: UIView.ContentMode, + loopCount: Int = 0, + preparationBlock: (@Sendable () -> Void)? = nil, + animationBlock: (@Sendable () -> Void)? = nil, + loopBlock: (@Sendable () -> Void)? = nil ) { self.animationBlock = animationBlock self.loopBlock = loopBlock @@ -175,7 +192,8 @@ public class Animator { size: size, contentMode: contentMode, loopCount: loopCount, - completionHandler: preparationBlock) + completionHandler: preparationBlock + ) startAnimating() } @@ -189,9 +207,13 @@ public class Animator { /// - parameter animationBlock: Callback for when all the loops of the animation are done (never called for infinite loops) /// - parameter loopBlock: Callback for when a loop is done (at the end of each loop) func animate( - withGIFData imageData: Data, size: CGSize, contentMode: UIView.ContentMode, loopCount: Int = 0, - preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil, - loopBlock: (() -> Void)? = nil + withGIFData imageData: Data, + size: CGSize, + contentMode: UIView.ContentMode, + loopCount: Int = 0, + preparationBlock: (@Sendable () -> Void)? = nil, + animationBlock: (@Sendable () -> Void)? = nil, + loopBlock: (@Sendable () -> Void)? = nil ) { self.animationBlock = animationBlock self.loopBlock = loopBlock @@ -200,7 +222,8 @@ public class Animator { size: size, contentMode: contentMode, loopCount: loopCount, - completionHandler: preparationBlock) + completionHandler: preparationBlock + ) startAnimating() } @@ -232,5 +255,5 @@ private class DisplayLinkProxy { init(target: Animator) { self.target = target } /// Lets the target update the frame if needed. - @objc func onScreenUpdate() { target?.updateFrameIfNeeded() } + @MainActor @objc func onScreenUpdate() { target?.updateFrameIfNeeded() } } diff --git a/Sources/Gifu/Classes/FrameStore.swift b/Sources/Gifu/Classes/FrameStore.swift index bfe3900..1d5a16e 100644 --- a/Sources/Gifu/Classes/FrameStore.swift +++ b/Sources/Gifu/Classes/FrameStore.swift @@ -2,9 +2,9 @@ import ImageIO import UIKit /// Responsible for storing and updating the frames of a single GIF. -class FrameStore { +final class FrameStore: @unchecked Sendable { /// The strategy to use for frame cache. - enum FrameCachingStrategy: Equatable { + enum FrameCachingStrategy: Equatable, Sendable { // Cache only a given number of upcoming frames. case cacheUpcoming(Int) @@ -152,7 +152,7 @@ class FrameStore { // MARK: - Frames /// Loads the frames from an image source, resizes them, then caches them in `animatedFrames`. - func prepareFrames(_ completionHandler: (() -> Void)? = nil) { + func prepareFrames(_ completionHandler: (@Sendable () -> Void)? = nil) { frameCount = Int(CGImageSourceGetCount(imageSource)) lock.lock() animatedFrames.reserveCapacity(frameCount) @@ -228,8 +228,7 @@ extension FrameStore { /// Updates the frames by preloading new ones and replacing the previous frame with a placeholder. private func updateFrameCache() { if case let .cacheUpcoming(size) = cachingStrategy, - size < frameCount - 1 - { + size < frameCount - 1 { deleteCachedFrame(at: previousFrameIndex) } diff --git a/Sources/Gifu/Classes/GIFAnimatable.swift b/Sources/Gifu/Classes/GIFAnimatable.swift index 3426cd1..162383d 100644 --- a/Sources/Gifu/Classes/GIFAnimatable.swift +++ b/Sources/Gifu/Classes/GIFAnimatable.swift @@ -2,7 +2,8 @@ import Foundation import UIKit /// The protocol that view classes need to conform to to enable animated GIF support. -public protocol GIFAnimatable: AnyObject { +@MainActor +public protocol GIFAnimatable: AnyObject, Sendable { /// Responsible for managing the animation frames. var animator: Animator? { get set } @@ -16,11 +17,10 @@ public protocol GIFAnimatable: AnyObject { var contentMode: UIView.ContentMode { get set } } - /// A single-property protocol that animatable classes can optionally conform to. public protocol ImageContainer { /// Used for displaying the animation frames. - var image: UIImage? { get set } + @MainActor var image: UIImage? { get set } } extension GIFAnimatable where Self: ImageContainer { @@ -58,14 +58,22 @@ extension GIFAnimatable { /// - parameter preparationBlock: Callback for when preparation is done /// - parameter animationBlock: Callback for when all the loops of the animation are done (never called for infinite loops) /// - parameter loopBlock: Callback for when a loop is done (at the end of each loop) - public func animate(withGIFNamed imageName: String, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil, loopBlock: (() -> Void)? = nil) { - animator?.animate(withGIFNamed: imageName, - size: frame.size, - contentMode: contentMode, - loopCount: loopCount, - preparationBlock: preparationBlock, - animationBlock: animationBlock, - loopBlock: loopBlock) + public func animate( + withGIFNamed imageName: String, + loopCount: Int = 0, + preparationBlock: (@Sendable () -> Void)? = nil, + animationBlock: (@Sendable () -> Void)? = nil, + loopBlock: (@Sendable () -> Void)? = nil + ) { + animator?.animate( + withGIFNamed: imageName, + size: frame.size, + contentMode: contentMode, + loopCount: loopCount, + preparationBlock: preparationBlock, + animationBlock: animationBlock, + loopBlock: loopBlock + ) } /// Prepare for animation and start animating immediately. @@ -75,14 +83,22 @@ extension GIFAnimatable { /// - parameter preparationBlock: Callback for when preparation is done /// - parameter animationBlock: Callback for when all the loops of the animation are done (never called for infinite loops) /// - parameter loopBlock: Callback for when a loop is done (at the end of each loop) - public func animate(withGIFData imageData: Data, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil, loopBlock: (() -> Void)? = nil) { - animator?.animate(withGIFData: imageData, - size: frame.size, - contentMode: contentMode, - loopCount: loopCount, - preparationBlock: preparationBlock, - animationBlock: animationBlock, - loopBlock: loopBlock) + public func animate( + withGIFData imageData: Data, + loopCount: Int = 0, + preparationBlock: (@Sendable () -> Void)? = nil, + animationBlock: (@Sendable () -> Void)? = nil, + loopBlock: (@Sendable () -> Void)? = nil + ) { + animator?.animate( + withGIFData: imageData, + size: frame.size, + contentMode: contentMode, + loopCount: loopCount, + preparationBlock: preparationBlock, + animationBlock: animationBlock, + loopBlock: loopBlock + ) } /// Prepare for animation and start animating immediately. @@ -92,16 +108,29 @@ extension GIFAnimatable { /// - parameter preparationBlock: Callback for when preparation is done /// - parameter animationBlock: Callback for when all the loops of the animation are done (never called for infinite loops) /// - parameter loopBlock: Callback for when a loop is done (at the end of each loop) - public func animate(withGIFURL imageURL: URL, loopCount: Int = 0, preparationBlock: (() -> Void)? = nil, animationBlock: (() -> Void)? = nil, loopBlock: (() -> Void)? = nil) { + public func animate( + withGIFURL imageURL: URL, + loopCount: Int = 0, + preparationBlock: (@Sendable () -> Void)? = nil, + animationBlock: (@Sendable () -> Void)? = nil, + loopBlock: (@Sendable () -> Void)? = nil + ) { let session = URLSession.shared let task = session.dataTask(with: imageURL) { (data, response, error) in switch (data, response, error) { case (.none, _, let error?): - print("Error downloading gif:", error.localizedDescription, "at url:", imageURL.absoluteString) + print( + "Error downloading gif:", error.localizedDescription, "at url:", imageURL.absoluteString) case (let data?, _, _): DispatchQueue.main.async { - self.animate(withGIFData: data, loopCount: loopCount, preparationBlock: preparationBlock, animationBlock: animationBlock, loopBlock: loopBlock) + self.animate( + withGIFData: data, + loopCount: loopCount, + preparationBlock: preparationBlock, + animationBlock: animationBlock, + loopBlock: loopBlock + ) } default: () } @@ -115,14 +144,18 @@ extension GIFAnimatable { /// - parameter imageName: The file name of the GIF in the main bundle. /// - parameter loopCount: Desired number of loops, <= 0 for infinite loop. /// - parameter completionHandler: Callback for when preparation is done - public func prepareForAnimation(withGIFNamed imageName: String, - loopCount: Int = 0, - completionHandler: (() -> Void)? = nil) { - animator?.prepareForAnimation(withGIFNamed: imageName, - size: frame.size, - contentMode: contentMode, - loopCount: loopCount, - completionHandler: completionHandler) + public func prepareForAnimation( + withGIFNamed imageName: String, + loopCount: Int = 0, + completionHandler: (@Sendable () -> Void)? = nil + ) { + animator?.prepareForAnimation( + withGIFNamed: imageName, + size: frame.size, + contentMode: contentMode, + loopCount: loopCount, + completionHandler: completionHandler + ) } /// Prepares the animator instance for animation. @@ -130,18 +163,22 @@ extension GIFAnimatable { /// - parameter imageData: GIF image data. /// - parameter loopCount: Desired number of loops, <= 0 for infinite loop. /// - parameter completionHandler: Callback for when preparation is done - public func prepareForAnimation(withGIFData imageData: Data, - loopCount: Int = 0, - completionHandler: (() -> Void)? = nil) { - if var imageContainer = self as? any ImageContainer { + public func prepareForAnimation( + withGIFData imageData: Data, + loopCount: Int = 0, + completionHandler: (@Sendable () -> Void)? = nil + ) { + if var imageContainer = self as? (any ImageContainer) { imageContainer.image = UIImage(data: imageData) } - animator?.prepareForAnimation(withGIFData: imageData, - size: frame.size, - contentMode: contentMode, - loopCount: loopCount, - completionHandler: completionHandler) + animator?.prepareForAnimation( + withGIFData: imageData, + size: frame.size, + contentMode: contentMode, + loopCount: loopCount, + completionHandler: completionHandler + ) } /// Prepares the animator instance for animation. @@ -149,19 +186,24 @@ extension GIFAnimatable { /// - parameter imageURL: GIF image url. /// - parameter loopCount: Desired number of loops, <= 0 for infinite loop. /// - parameter completionHandler: Callback for when preparation is done - public func prepareForAnimation(withGIFURL imageURL: URL, - loopCount: Int = 0, - completionHandler: (() -> Void)? = nil) { + public func prepareForAnimation( + withGIFURL imageURL: URL, + loopCount: Int = 0, + completionHandler: (@Sendable () -> Void)? = nil + ) { let session = URLSession.shared let task = session.dataTask(with: imageURL) { (data, response, error) in switch (data, response, error) { case (.none, _, let error?): - print("Error downloading gif:", error.localizedDescription, "at url:", imageURL.absoluteString) + print( + "Error downloading gif:", error.localizedDescription, "at url:", imageURL.absoluteString) case (let data?, _, _): DispatchQueue.main.async { - self.prepareForAnimation(withGIFData: data, - loopCount: loopCount, - completionHandler: completionHandler) + self.prepareForAnimation( + withGIFData: data, + loopCount: loopCount, + completionHandler: completionHandler + ) } default: () } @@ -209,7 +251,7 @@ extension GIFAnimatable { /// Updates the image with a new frame if necessary. public func updateImageIfNeeded() { - if var imageContainer = self as? any ImageContainer { + if var imageContainer = self as? (any ImageContainer) { let container = imageContainer imageContainer.image = activeFrame ?? container.image } else { diff --git a/Sources/Gifu/Classes/GIFImageView.swift b/Sources/Gifu/Classes/GIFImageView.swift index 683b702..bf09926 100644 --- a/Sources/Gifu/Classes/GIFImageView.swift +++ b/Sources/Gifu/Classes/GIFImageView.swift @@ -4,7 +4,7 @@ import UIKit public class GIFImageView: UIImageView, GIFAnimatable { /// A lazy animator. public lazy var animator: Animator? = { - return Animator(withDelegate: self) + Animator(withDelegate: self) }() /// Layer delegate method called periodically by the layer. **Should not** be called manually. diff --git a/Sources/Gifu/Helpers/ImageSourceHelpers.swift b/Sources/Gifu/Helpers/ImageSourceHelpers.swift index 6c9b0e1..5f79637 100755 --- a/Sources/Gifu/Helpers/ImageSourceHelpers.swift +++ b/Sources/Gifu/Helpers/ImageSourceHelpers.swift @@ -1,6 +1,7 @@ import ImageIO import MobileCoreServices import UIKit +import UniformTypeIdentifiers typealias GIFProperties = [String: Double] @@ -16,7 +17,7 @@ private let defaultFrameDuration: Double = 1 / defaultFrameRate /// Threshold used in `capDuration` for a FrameDuration private let capDurationThreshold: Double = 0.02 - Double.ulpOfOne -/// Frameduration used, if a frame-duration is below `capDurationThreshold` +/// Frame duration used, if a frame-duration is below `capDurationThreshold` private let minFrameDuration: Double = 0.1 /// Returns the duration of a frame at a specific index using an image source (an `CGImageSource` instance). @@ -69,7 +70,8 @@ extension CGImageSource { /// /// - returns: A boolean value that is `true` if the image source contains animated GIF data. var isAnimatedGIF: Bool { - let isTypeGIF = UTTypeConformsTo(CGImageSourceGetType(self) ?? "" as CFString, kUTTypeGIF) + let type = (CGImageSourceGetType(self) as? String) ?? "" + let isTypeGIF = UTType(type)?.conforms(to: .gif) let imageCount = CGImageSourceGetCount(self) return isTypeGIF != false && imageCount > 1 } diff --git a/Tests/GifuTests.swift b/Tests/GifuTests.swift index 8355d04..4e85e31 100644 --- a/Tests/GifuTests.swift +++ b/Tests/GifuTests.swift @@ -1,157 +1,157 @@ -#if os(iOS) - import XCTest - import ImageIO - @testable import Gifu - - private let imageData = testImageDataNamed("mugen.gif") - private let staticImage = UIImage(data: imageData)! - private let preloadFrameCount = 20 - - class DummyAnimatable: GIFAnimatable { - init() {} - var animator: Animator? = nil - var image: UIImage? = nil - var layer = CALayer() - var frame: CGRect = .zero - var contentMode: UIView.ContentMode = .scaleToFill - func animatorHasNewFrame() {} +import ImageIO +import XCTest + +@testable import Gifu + +private let imageData = testImageDataNamed("mugen.gif") +private let staticImage = UIImage(data: imageData)! +private let preloadFrameCount = 20 + +class DummyAnimatable: GIFAnimatable { + init() {} + var animator: Animator? = nil + var image: UIImage? = nil + var layer = CALayer() + var frame: CGRect = .zero + var contentMode: UIView.ContentMode = .scaleToFill + func animatorHasNewFrame() {} +} + +@MainActor +class GifuTests: XCTestCase { + var animator: Animator! + var originalFrameCount: Int! + let delegate = DummyAnimatable() + + override func setUp() { + super.setUp() + animator = Animator(withDelegate: delegate) + animator.prepareForAnimation( + withGIFData: imageData, size: staticImage.size, contentMode: .scaleToFill) + originalFrameCount = 44 } - class GifuTests: XCTestCase { - var animator: Animator! - var originalFrameCount: Int! - let delegate = DummyAnimatable() - - override func setUp() { - super.setUp() - animator = Animator(withDelegate: delegate) - animator.prepareForAnimation( - withGIFData: imageData, size: staticImage.size, contentMode: .scaleToFill) - originalFrameCount = 44 - } - - func testIsAnimatable() { - XCTAssertNotNil(animator.frameStore) - guard let store = animator.frameStore else { return } - XCTAssertTrue(store.isAnimatable) - } + func testIsAnimatable() { + XCTAssertNotNil(animator.frameStore) + guard let store = animator.frameStore else { return } + XCTAssertTrue(store.isAnimatable) + } - func testCurrentFrame() { - XCTAssertNotNil(animator.frameStore) - guard let store = animator.frameStore else { return } - XCTAssertEqual(store.currentFrameIndex, 0) - } + func testCurrentFrame() { + XCTAssertNotNil(animator.frameStore) + guard let store = animator.frameStore else { return } + XCTAssertEqual(store.currentFrameIndex, 0) + } - func testFramePreload() { - XCTAssertNotNil(animator.frameStore) - guard let store = animator.frameStore else { return } + func testFramePreload() { + XCTAssertNotNil(animator.frameStore) + guard let store = animator.frameStore else { return } - let expectation = self.expectation(description: "frameDuration") + let expectation = self.expectation(description: "frameDuration") - store.prepareFrames { - let animatedFrameCount = store.animatedFrames.count - XCTAssertEqual(animatedFrameCount, self.originalFrameCount) - XCTAssertNotNil(store.frame(at: preloadFrameCount - 1)) - XCTAssertNil(store.frame(at: preloadFrameCount + 1)?.images) - XCTAssertEqual(store.currentFrameIndex, 0) + store.prepareFrames { [originalFrameCount] in + let animatedFrameCount = store.animatedFrames.count + XCTAssertEqual(animatedFrameCount, originalFrameCount) + XCTAssertNotNil(store.frame(at: preloadFrameCount - 1)) + XCTAssertNil(store.frame(at: preloadFrameCount + 1)?.images) + XCTAssertEqual(store.currentFrameIndex, 0) - store.shouldChangeFrame(with: 1.0) { hasNewFrame in - XCTAssertTrue(hasNewFrame) - XCTAssertEqual(store.currentFrameIndex, 1) - expectation.fulfill() - } + store.shouldChangeFrame(with: 1.0) { hasNewFrame in + XCTAssertTrue(hasNewFrame) + XCTAssertEqual(store.currentFrameIndex, 1) + expectation.fulfill() } + } - waitForExpectations(timeout: 1.0) { error in - if let error = error { - print("Error: \(error.localizedDescription)") - } + waitForExpectations(timeout: 1.0) { error in + if let error = error { + print("Error: \(error.localizedDescription)") } } + } - func testFrameInfo() { - XCTAssertNotNil(animator.frameStore) - guard let store = animator.frameStore else { return } + func testFrameInfo() { + XCTAssertNotNil(animator.frameStore) + guard let store = animator.frameStore else { return } - let expectation = self.expectation(description: "testFrameInfoIsAccurate") + let expectation = self.expectation(description: "testFrameInfoIsAccurate") - store.prepareFrames { - let frameDuration = store.frame(at: 5)?.duration ?? 0 - XCTAssertTrue((frameDuration - 0.05) < 0.00001) + store.prepareFrames { + let frameDuration = store.frame(at: 5)?.duration ?? 0 + XCTAssertTrue((frameDuration - 0.05) < 0.00001) - let imageSize = store.frame(at: 5)?.size ?? CGSize.zero - XCTAssertEqual(imageSize, staticImage.size) + let imageSize = store.frame(at: 5)?.size ?? CGSize.zero + XCTAssertEqual(imageSize, staticImage.size) - expectation.fulfill() - } + expectation.fulfill() + } - waitForExpectations(timeout: 1.0) { error in - if let error = error { - print("Error: \(error.localizedDescription)") - } + waitForExpectations(timeout: 1.0) { error in + if let error = error { + print("Error: \(error.localizedDescription)") } } + } - func testFinishedStates() { - animator = Animator(withDelegate: delegate) - animator.prepareForAnimation( - withGIFData: imageData, size: staticImage.size, contentMode: .scaleToFill, loopCount: 2) - - XCTAssertNotNil(animator.frameStore) - guard let store = animator.frameStore else { return } + func testFinishedStates() { + animator = Animator(withDelegate: delegate) + animator.prepareForAnimation( + withGIFData: imageData, size: staticImage.size, contentMode: .scaleToFill, loopCount: 2) - let expectation = self.expectation(description: "testFinishedStatesAreSetCorrectly") + XCTAssertNotNil(animator.frameStore) + guard let store = animator.frameStore else { return } - store.prepareFrames { - let animatedFrameCount = store.animatedFrames.count - XCTAssertEqual(store.currentFrameIndex, 0) + let expectation = self.expectation(description: "testFinishedStatesAreSetCorrectly") - // Animate through all the frames (first loop) - for frame in 1.. Data { - let testBundle = Bundle(for: GifuTests.self) - let imagePath = testBundle.bundleURL.appendingPathComponent(name) - return (try! Data(contentsOf: imagePath)) - } -#endif +private func testImageDataNamed(_ name: String) -> Data { + let testBundle = Bundle(for: GifuTests.self) + let imagePath = testBundle.bundleURL.appendingPathComponent(name) + return (try! Data(contentsOf: imagePath)) +}