Swift 玩轉(zhuǎn)gif

gif study

眾所周知,iOS默認是不支持gif類型圖片的顯示的,但是我們項目中常常是需要顯示gif為動態(tài)圖片。那腫么辦?第三方庫?是的 ,很多第三方都支持gif , 如果一直只停留在用第三方上,技術(shù)難有提高。上版本的 Kingfisher 也支持gif ,研究了一番,也在網(wǎng)上搜索了一番,稍微了解了下iOS實現(xiàn)gif的顯示,在此略做記錄。

本篇文章要實現(xiàn)的效果如圖:

gif顯示效果

可以開始和暫停gif的播放,滑動時停止播放,這個簡書也是這么做得,好多app為了滑動時順暢,停止了gif。

下面要進入正文啦!

期待...

分解gif幀進行顯示

我們一般從網(wǎng)絡(luò)上下載的gif圖片其實是將很多幀靜態(tài)圖片循環(huán)播放產(chǎn)生的動態(tài)效果,那么在iOS中,如果我們想要顯示動態(tài)圖,同樣需要先把gif資源解析為一陣一陣的UIImage然后設(shè)定間隔時長,不斷播放即可。思路是不是很簡單呢?那么看看如何實現(xiàn)。

分幾個步驟:

  1. 將gif圖片轉(zhuǎn)為NSData。
  2. 根據(jù)NSData獲取CGImageSource對象
  3. 獲取幀數(shù)
  4. 根據(jù)幀數(shù)獲取每一幀對應(yīng)的UIImage對象和時間間隔
  5. 循環(huán)播放

首先我們需要引入import ImageIO , 提供了很多對圖片操作的函數(shù)。

這里我們從網(wǎng)上down了一個gif的圖片,其實下載也是一樣的 ,我們需要的是NSData類型的數(shù)據(jù),用NSURLSession下載也可以得到NSData類型的數(shù)據(jù),這里下載的數(shù)據(jù)如何判斷是否為gif呢?

Kingfisher 庫中給出了解決方案,每種格式的圖片前面幾位都是固定的。所以只需要對比就能判斷出類型,這里給出Kingfisher判斷類型的代碼。

private let pngHeader: [UInt8] = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
private let jpgHeaderSOI: [UInt8] = [0xFF, 0xD8]
private let jpgHeaderIF: [UInt8] = [0xFF]
private let gifHeader: [UInt8] = [0x47, 0x49, 0x46]

enum ImageFormat {
    case Unknown, PNG, JPEG, GIF
}

extension NSData {
    var kf_imageFormat: ImageFormat {
        var buffer = [UInt8](count: 8, repeatedValue: 0)
        self.getBytes(&buffer, length: 8)
        if buffer == pngHeader {
            return .PNG
        } else if buffer[0] == jpgHeaderSOI[0] &&
            buffer[1] == jpgHeaderSOI[1] &&
            buffer[2] == jpgHeaderIF[0]
        {
            return .JPEG
        } else if buffer[0] == gifHeader[0] &&
            buffer[1] == gifHeader[1] &&
            buffer[2] == gifHeader[2]
        {
            return .GIF
        }
        
        return .Unknown
    }
}

有了這個擴展判斷起來就方便很多了。

為了使demo簡單,我們直接將gif放在本地沙盒。下載好直接拖進項目就OK了。

這樣就可以很容易的得到NSData類型的數(shù)據(jù)

let path = NSBundle.mainBundle().pathForResource("xxx", ofType: "gif")
let data = NSData(contentsOfFile: path!)

第一步已經(jīng)完成啦。

然后通過CGImageSourceCreateWithData 方法創(chuàng)建一個CGImageSource 對象 。

// kCGImageSourceShouldCache : 表示是否在存儲的時候就解碼
// kCGImageSourceTypeIdentifierHint : 指明source type
let options: NSDictionary = [kCGImageSourceShouldCache as String: NSNumber(bool: true), kCGImageSourceTypeIdentifierHint as String: kUTTypeGIF]
guard let imageSource = CGImageSourceCreateWithData(data, options) else {
            return
        }

這里的options是為了顯示優(yōu)化。提前解碼,指定類型。

拿到CGImageSource 對象就可以為所欲為了。

// 獲取gif幀數(shù)
let frameCount = CGImageSourceGetCount(imageSource)
var images = [UIImage]()

var gifDuration = 0.0

for i in 0 ..< frameCount {
    // 獲取對應(yīng)幀的 CGImage
    guard let imageRef = CGImageSourceCreateImageAtIndex(imageSource, i, options) else {
        return
    }
    if frameCount == 1 {
        // 單幀
        gifDuration = Double.infinity
    } else{
        // gif 動畫
        // 獲取到 gif每幀時間間隔
        guard let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, i, nil) , gifInfo = (properties as NSDictionary)[kCGImagePropertyGIFDictionary as String] as? NSDictionary,
            frameDuration = (gifInfo[kCGImagePropertyGIFDelayTime as String] as? NSNumber) else
        {
            return
        }
//                print(frameDuration)
        gifDuration += frameDuration.doubleValue
        // 獲取幀的img
        let  image = UIImage(CGImage: imageRef , scale: UIScreen.mainScreen().scale , orientation: UIImageOrientation.Up)
        // 添加到數(shù)組
        images.append(image)
    }
}

先獲取幀數(shù),然后循環(huán)根據(jù)幀數(shù)獲取對應(yīng)的圖片,然后獲取沒幀間隔時間。累加時間間隔得到總共的時間,把圖片存在一個圖片數(shù)組中。

有了這些參數(shù),我們就可以播放gif了。

界面上隨便拖出來一個 UIImageView 然后給以下屬性賦值即可。

imgV.contentMode = .ScaleAspectFit
imgV.animationImages = images
imgV.animationDuration = gifDuration
imgV.animationRepeatCount = 0 // 無限循環(huán)
imgV.startAnimating()

運行項目,發(fā)現(xiàn)gif動起來了。

happy...

原來gif也沒那么難,哈哈... ...

但是這樣你添加一個開始和暫停的按鈕

@IBAction func start(sender: AnyObject) {
    if !imgV.isAnimating() {
        imgV.startAnimating()
    }
}


@IBAction func stop(sender: AnyObject) {
    if imgV.isAnimating() {
        imgV.stopAnimating()
    }
}

你會發(fā)現(xiàn),暫停時白板,什么圖都沒有,而且滾動的時候也不會暫停。。。

吐血...

這只是個開始,后面的路還很長,坐好繼續(xù)。

處理gif的暫停、播放 滑動暫停等


以下部分基本上算是對Kingfisher 的一個理解,我們繼續(xù)。

簡單說下思路,要實現(xiàn)暫停在某幀,滑動暫停某幀這個就不能用UIImageViewstartAnimating直接操作了,需要我們自己處理幀和動畫,動畫在Kingfisher中使用CADisplayLink處理的,寫了一個UIImageView的子類AnimatedImageView,重寫了startAnimating 、 stopAnimating 等方法。關(guān)于CADisplayLink不熟悉的,看這篇文章 - CADisplayLink , 需要滑動暫停就把 CADisplayLink 加到 NSDefaultRunLoopMode模式的runloop下。 關(guān)于對幀的處理單獨寫了一個Animator . 下面來看看具體實現(xiàn)。

Animator 類處理幀

首先定義一個結(jié)構(gòu)體,里面就有兩個屬性UIImage 圖像 和 NSTimeInterval 幀之間時間間隔。

struct AnimatedFrame {
    var image: UIImage?
    let duration: NSTimeInterval
    
    static func null() -> AnimatedFrame {
        return AnimatedFrame(image: .None, duration: 0.0)
    }
}

接著就可以創(chuàng)建一個 Animator 并定義一些需要用的屬性

class Animator{
    private let maxFrameCount: Int = 100    // 最大幀數(shù)
    private var imageSource:CGImageSource!  // imageSource 處理幀相關(guān)操作
    private var animatedFrames = [AnimatedFrame]()  //
    private var frameCount = 0  // 幀的數(shù)量
    private var currentFrameIndex = 0   // 當(dāng)前幀下標(biāo)
    private var currentPreloadIndex = 0 // 當(dāng)前預(yù)緩存幀的下標(biāo)
    private var timeSinceLastFrameChange: NSTimeInterval = 0.0  // 距離上一幀改變的時間
    /// 循環(huán)次數(shù)
    private var loopCount = 0
    /// 做大間隔
    private let maxTimeStep: NSTimeInterval = 1.0
}

然后是一個隊數(shù)據(jù)操作的方法,因為Kingfiher是處理網(wǎng)絡(luò)圖片的,所以我這邊處理方式略不同

/**
 根據(jù)data創(chuàng)建 CGImageSource
 
 - parameter data: gif data
 */
func createImageSource(data:NSData){
    let options: NSDictionary = [kCGImageSourceShouldCache as String: NSNumber(bool: true), kCGImageSourceTypeIdentifierHint as String: kUTTypeGIF]
    imageSource = CGImageSourceCreateWithData(data, options)
}

這個方法就是前面的根據(jù)NSData 獲取 CGImageSource 對象,以備后用。

然后寫一個將每一幀轉(zhuǎn)換為我們剛定義的結(jié)構(gòu)體 AnimatedFrame 對象

 /// 準備某幀 的 frame
func prepareFrame(index: Int) -> AnimatedFrame {
    // 獲取對應(yīng)幀的 CGImage
    guard let imageRef = CGImageSourceCreateImageAtIndex(imageSource, index , nil) else {
        return AnimatedFrame.null()
    }
    // 獲取到 gif每幀時間間隔
    guard let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, index , nil) , gifInfo = (properties as NSDictionary)[kCGImagePropertyGIFDictionary as String] as? NSDictionary,
        frameDuration = (gifInfo[kCGImagePropertyGIFDelayTime as String] as? NSNumber) else
    {
        return AnimatedFrame.null()
    }
    
    let image = UIImage(CGImage: imageRef , scale: UIScreen.mainScreen().scale , orientation: UIImageOrientation.Up)
    return AnimatedFrame(image: image, duration: Double(frameDuration) ?? 0.0)
}

就是根據(jù)imageSource獲取CGImage再轉(zhuǎn)為UIImage , 然后獲取幀間隔時間,構(gòu)建結(jié)構(gòu)體。 很easy 。沒啥說的。

下面還需要一個預(yù)備所有幀的方法

/**
預(yù)備所有frames
*/
func prepareFrames() {
frameCount = CGImageSourceGetCount(imageSource)

if let properties = CGImageSourceCopyProperties(imageSource, nil),
    gifInfo = (properties as NSDictionary)[kCGImagePropertyGIFDictionary as String] as? NSDictionary,
    loopCount = gifInfo[kCGImagePropertyGIFLoopCount as String] as? Int {
    self.loopCount = loopCount
}

// 總共幀數(shù)
let frameToProcess = min(frameCount, maxFrameCount)

animatedFrames.reserveCapacity(frameToProcess)

// 相當(dāng)于累加
animatedFrames = (0..<frameToProcess).reduce([]) { $0 + pure(prepareFrame($1))}

// 上面相當(dāng)于這個
//        for i in 0..<frameToProcess {
//            animatedFrames.append(prepareFrame(i))
//        }

}

這里其實就是得到總幀數(shù)然后給animatedFrames賦值,Kingfisher這里使用了readuce,累加的方式pure 方法是將一個值轉(zhuǎn)成一個單值數(shù)組。

private func pure<T>(value: T) -> [T] {
    return [value]
}

根據(jù)下表取幀

/**
 根據(jù)下標(biāo)獲取幀
 */
func frameAtIndex(index: Int) -> UIImage? {
    return animatedFrames[index].image
}

當(dāng)前幀和contentMode屬性

var currentFrame: UIImage? {
    return frameAtIndex(currentFrameIndex)
}

var contentMode: UIViewContentMode = .ScaleToFill
AnimatedImageView-可以播放gif的ImageView

基本成型,還差一個更新當(dāng)前幀的方法,暫時不處理,先看去用實現(xiàn)一個繼承自UIImageViewAnimatedImageView 并聲明幾個屬性。

public class AnimatedImageView : UIImageView {
    /// 是否自動播放
    public var autoPlayAnimatedImage = true
    
    /// `Animator` 對象 將幀和指定圖片存儲內(nèi)存中
    private var animator: Animator?
    
    /// displayLink 為懶加載 避免還沒有加載好的時候使用了 造成異常
    private var displayLinkInitialized: Bool = false

}

這里利用 CADisplayLink 不斷執(zhí)行某個方法,等達到幀之間的間隔時間的時候就去更新UIImageViewlayercontens 屬性。這個屬性需要一個CGImage的對象。

為了防止AnimatedImageViewCADisplayLink 之間的循環(huán)引用,Kingfisher在AnimatedImageView 內(nèi)部寫了一個代理類。

 /// 防止循環(huán)引用
class TargetProxy {
    private weak var target: AnimatedImageView?
    
    init(target: AnimatedImageView) {
        self.target = target
    }
    
    @objc func onScreenUpdate() {
        target?.updateFrame()
    }
}

就是通過TargetProxy 來調(diào)用 AnimatedImageView 中的 updateFrame 方法,大家可以先寫一個空方法。

然后創(chuàng)建一個CADisplayLink對象,這里使用懶加載。

private lazy var displayLink: CADisplayLink = {
    self.displayLinkInitialized = true
    let displayLink = CADisplayLink(target: TargetProxy(target: self), selector: #selector(TargetProxy.onScreenUpdate))
    displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: self.runLoopMode)
    displayLink.paused = true
    return displayLink
}()

用這個self.displayLinkInitialized 標(biāo)志 CADisplayLink 已經(jīng)加載,然后用代理就調(diào)用自己的 updateFrame()方法

在添加個指定RunLoopMode的屬性

// NSRunLoopCommonModes
public var runLoopMode = NSDefaultRunLoopMode {
    willSet {
        if runLoopMode == newValue {
            return
        } else {
            stopAnimating()
            displayLink.removeFromRunLoop(NSRunLoop.mainRunLoop(), forMode: runLoopMode)
            displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: newValue)
            startAnimating()
        }
    }
}

Kingfisher 默認是NSRunLoopCommonModes 滑動不暫停,我這邊換成NSDefaultRunLoopMode 滑動暫停 。

NSRunLoopCommonModes 包含兩個模式 UITrackingRunLoopModeNSDefaultRunLoopMode , 其中UITrackingRunLoopMode 是滑動時候的模式
,如果只在 NSDefaultRunLoopMode 模式下,那滑動模式就不會執(zhí)行CADisplayLink 的方法, NSTimer 也可以指定 模式。非本篇重點 ,這里就不細說了

kingfisher 是重寫了 image 屬性進行Animator的初始化和重置的 , 這里為了demo的easy 我們給 AnimatedImageView 新增一個屬性,叫 gifData.

public var gifData:NSData?{
    didSet{
        if let gifData = gifData {
            animator = nil
            animator = Animator()
            animator?.createImageSource(gifData)
            animator?.prepareFrames()

            didMove()
            setNeedsDisplay()
            layer.setNeedsDisplay()
        }
    }
}

創(chuàng)建Animator對象 ,緩存幀。 這里didMove() 方法是處理自動播放的

private func didMove() {
    if autoPlayAnimatedImage && animator != nil {
        if let _ = superview, _ = window {
            startAnimating()
        } else {
            stopAnimating()
        }
    }
}

后面會重寫startAnimatingstopAnimating .

先來看 CADisplayLink 每次調(diào)用的方法updateFrame() , 這里默認是每秒60次 , 根據(jù)屏幕刷新頻率。

要實現(xiàn)updateFrame() 放法首先要在,Animator 中添加一個更新當(dāng)前幀的方法。上面提到的,現(xiàn)在可以來寫了。

func updateCurrentFrame(duration: CFTimeInterval) -> Bool {
    // 計算距離上一幀 改變的時間 每次進來都累加 直到frameDuration  <= timeSinceLastFrameChange 時候才繼續(xù)走下去
    timeSinceLastFrameChange += min(maxTimeStep, duration)
    guard let frameDuration = animatedFrames[safe: currentFrameIndex]?.duration where frameDuration <= timeSinceLastFrameChange else {
        return false
    }
    // 減掉 我們每幀間隔時間
    timeSinceLastFrameChange -= frameDuration
    let lastFrameIndex = currentFrameIndex
    currentFrameIndex += 1 // 一直累加
    // 這里取了余數(shù)
    currentFrameIndex = currentFrameIndex % animatedFrames.count
    
    if animatedFrames.count < frameCount {
        animatedFrames[lastFrameIndex] = prepareFrame(currentPreloadIndex)
        currentPreloadIndex += 1
        currentPreloadIndex = currentPreloadIndex % frameCount
    }
    return true
}

傳入的durationdisplayLink.duration 默認是 1/60 秒,這里先對每次的duration進行累加,直到我們的幀間隔時間小于等于它了 才去獲取當(dāng)前幀和增加下標(biāo),返回true , 否則一直返回false

然后AnimatedImageView中的 updateFrame 方法就是調(diào)用那個方法,直到它返回true才進行處理,這里就是調(diào)用了layer.setNeedsDisplay()

private func updateFrame() {
    if animator?.updateCurrentFrame(displayLink.duration) ?? false {
        // 此方法會觸發(fā) displayLayer
        layer.setNeedsDisplay()
    }
}

layer.setNeedsDisplay() 會觸發(fā) displayLayer 方法,我們只要重寫這個方法,就能處理每幀的顯示了。

override public func displayLayer(layer: CALayer) {
    if let currentFrame = animator?.currentFrame {
        layer.contents = currentFrame.CGImage
    } else {
        layer.contents = image?.CGImage
    }
}

搞了這么多,終于到顯示了,不容易呀。。。

這里重寫了幾個方法,都去調(diào)用了didMove

override public func didMoveToWindow() {
    super.didMoveToWindow()
    didMove()
}

override public func didMoveToSuperview() {
    super.didMoveToSuperview()
    didMove()
}

這里gif的暫停是利用了CADisplayLinkpaused屬性控制的

 override public func isAnimating() -> Bool {
    if displayLinkInitialized {
        return !displayLink.paused
    } else {
        return super.isAnimating()
    }
}

/// Starts the animation.
override public func startAnimating() {
    if self.isAnimating() {
        return
    } else {
        displayLink.paused = false
    }
}

/// Stops the animation.
override public func stopAnimating() {
    super.stopAnimating()
    if displayLinkInitialized {
        displayLink.paused = true
    }
}

這里displayLinkInitialized 判斷CADisplayLink是否加載好了。

最后記得在對象銷毀的時候吧displaylink也停掉

deinit {
    if displayLinkInitialized {
        displayLink.invalidate()
    }
}

至此,所有基本功能已經(jīng)全部OK了,使用也很簡單。

let path = NSBundle.mainBundle().pathForResource("xxx", ofType: "gif")
let data = NSData(contentsOfFile: path!)
imgV.gifData = data

默認是自動播放,可以手動設(shè)置。

文章比較長,可能描述的不是很到位,有啥不清楚可以留言交流。

github地址:https://github.com/smalldu/ImageDemo

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 發(fā)現(xiàn) 關(guān)注 消息 iOS 第三方庫、插件、知名博客總結(jié) 作者大灰狼的小綿羊哥哥關(guān)注 2017.06.26 09:4...
    肇東周閱讀 15,696評論 4 61
  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 179,351評論 25 708
  • 最近《我的前半生》特別的火,于是我也湊熱鬧看了幾集,正常來說以我的心性我是不會看這種讓人絕望的,無事生非的電視。生...
    不小不閱讀 472評論 0 0
  • 如果你問一個地地道道的武夷山人:“你們武夷山有什么好玩好吃的?”不論男女老少,是何職業(yè)身份都會熱情地回答你: “景...
    孝文家茶tea閱讀 786評論 0 1
  • 我的心如寂寂死灰,沉在無邊的黑暗里,你瀟灑的一轉(zhuǎn)身,就已將我與你的情隔在了萬丈紅塵外。 人說十年磨一劍,而這十年卻...
    紅塵紫陌閱讀 361評論 2 3

友情鏈接更多精彩內(nèi)容