Edited at

Apple Watch で GIF/APNG を使う (ライブラリ不使用)

Apple Watch でローディング表示をしたくなり、パラパラマンガ形式で各画像を用意して表示する通常の方法でやっていたのですが、GIF や APNG などファイル一つでスッキリ完結させたいなと思い、今回の記事に至りました。

APNG を扱える Swift のライブラリ APNGKit は現在(2019/02/10) watchOS に対応していません。

もともと APNG を使うだけだったんですが、GIF にも適用できそうだったので GIF にも対応するように書きました。


  • Swift 4.2

  • watchOS 5


コード

解説は下にまとめます。


使用方法

GIF / APNG ファイルをプロジェクトに追加した上で。

自動でアニメーションがスタートする仕様です。

@IBOutlet weak var interfaceImage: WKInterfaceImage!

interfaceImage.setAnimationImage(name: "image", extension: "png")


用意したextension達

解説番号は下記解説にリンクしています。

import ImageIO

extension WKInterfaceImage {

// 解説 1, 2
func setAnimationImage(name: String, extension ext: String) {
guard
let url = Bundle.main.url(forResource: name, withExtension: ext),
let data = try? Data(contentsOf: url) as CFData,
let imageSource = CGImageSourceCreateWithData(data, nil)
else { return }

let images = imageSource.images
let image = UIImage.animatedImage(with: images, duration: imageSource.duration)
self.setImage(image)
self.startAnimating()
}
}

extension CGImageSource {

// 解説 3
var count: Int {
return CGImageSourceGetCount(self)
}

// 解説 4
var images: [UIImage] {
return (0..<count).compactMap({ CGImageSourceCreateImageAtIndex(self, $0, nil) }).map({ UIImage(cgImage: $0) })
}

// 解説 5
var duration: Double {
let delayTimes = (0..<count)
.compactMap({ CGImageSourceCopyPropertiesAtIndex(self, $0, nil) as? [String: Any] })
.compactMap({ $0["{PNG}"] as? [String: Any] ?? $0["{GIF}"] as? [String: Any] })
.map({ $0["UnclampedDelayTime"] as? Double ?? $0["DelayTime"] as? Double ?? 0.1 })
let d = delayTimes
.map({ $0 < 0.011 ? 0.1 : $0 })
.reduce(0, +)
return d
}
}


解説


1. WKInterfaceImage でのアニメーション

最小構成で表現すると下記のようになります。

引数 duration はすべての UIImage を表示させる秒数です。

@IBOutlet weak var interfaceImage: WKInterfaceImage!

let images = [UIImage(named: "image_1")!, UIImage(named: "image_2")!]
let image = UIImage.animatedImage(with: images, duration: 3)
interfaceImage.setImage(image)
interfaceImage.startAnimating()

つまり GIF / APNG ファイルから 各フレームの画像表示秒数 を取得できれば再生できます。


2. CGImageSource の取得

CGImageSource を取得することにより、細かい情報にアクセスしやすくなります。

let url = Bundle.main.url(forResource: name, withExtension: ext)!

let data = try! Data(contentsOf: url) as CFData
let imageSource = CGImageSourceCreateWithData(data, nil)!


3. APNG のフレーム数を取得

func CGImageSourceGetCount(_ isrc: CGImageSource) -> Int で取得できます。


4. 各フレームの画像を UIImage で取得

func CGImageSourceCreateImageAtIndex(_ isrc: CGImageSource, _ index: Int, _ options: CFDictionary?) -> CGImage? で各フレームの CGImage を取得できます。

UIImage(cgImage: CGImage) で CGImage から UIImage に変換します。


5. 秒数を取得する

func CGImageSourceCopyPropertiesAtIndex(_ isrc: CGImageSource, _ index: Int, _ options: CFDictionary?) -> CFDictionary? で取得できるデータの一例を下記に示します。

戻り値の CFDictionary[String: Any] にキャストできます。

[

ColorModel: RGB,
Depth: 8,
HasAlpha: 1,
PixelHeight: 100,
PixelWidth: 100,
{PNG}: [
DelayTime: "0.09",
InterlaceType: 0,
Software: "",
UnclampedDelayTime: "0.09",
],
{TIFF}: [
Software: ""
]
]

今回のコードでは、各フレームの UnclampedDelayTime の総計を duration の秒数としています。

詳しくは UnclampedDelayTime と DelayTime を御覧ください。


問題点


Assets.xcassets が使えない

NSDataAsset が watchOS では使えないので、Assets.xcassets は使用できない。

また、 UIImage(named: String) があるので、これで Assets.xcassets 内のAPNGファイルを取得してから、 pngData() -> Data? で Data を取得し、これを元に CGImageSource を取得することを試みたのですが、フレーム数など正常に取得できませんでした。


スケール問題

@2x とかのあれです。

上記の通り xcassets を利用できないので、簡単に複数スケールに対応できません。

WKInterfaceDevice.current().screenScale でスケールを取得し、UIImageを 1/スケール に縮小するなどの対応策が考えられます。

ただ、記述時にある全ての端末 ( Series 1~4 ) のスケールは 2.0 のみです。


UnclampedDelayTime と DelayTime

stackoverflow をみた感じ、下記のように判定すればいいのかなと思います。


  1. UnclampedDelayTime があればこの値を使う

  2. なければ DelayTime を使う

  3. 0.011 未満なら 0.1 とする

kCGImagePropertyAPNGDelayTime, kCGImagePropertyAPNGUnclampedDelayTime, kCGImagePropertyGIFDelayTime, kCGImagePropertyGIFUnclampedDelayTime などの定数が用意されていますが使うメリットを感じなかったので今回は使用していません。


参考

間違いの指摘、より簡単な方法の提案などお待ちしております!