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

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

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

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

- Swift 4.2
- watchOS 5

# コード

解説は下にまとめます。

## 使用方法

**GIF** / **APNG** ファイルをプロジェクトに追加した上で。
自動でアニメーションがスタートする仕様です。

```swift
@IBOutlet weak var interfaceImage: WKInterfaceImage!
interfaceImage.setAnimationImage(name: "image", extension: "png")
```

## 用意したextension達

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

```swift
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 を表示させる秒数です。

```swift
@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` を取得することにより、細かい情報にアクセスしやすくなります。

```swift
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`](https://developer.apple.com/documentation/imageio/1465029-cgimagesourcegetcount) で取得できます。

## 4. 各フレームの画像を UIImage で取得
[`func CGImageSourceCreateImageAtIndex(_ isrc: CGImageSource, _ index: Int, _ options: CFDictionary?) -> CGImage?`](https://developer.apple.com/documentation/imageio/1465011-cgimagesourcecreateimageatindex) で各フレームの CGImage を取得できます。

[`UIImage(cgImage: CGImage)`](https://developer.apple.com/documentation/uikit/uiimage/1624090-init) で CGImage から UIImage に変換します。


## 5. 秒数を取得する

[`func CGImageSourceCopyPropertiesAtIndex(_ isrc: CGImageSource, _ index: Int, _ options: CFDictionary?) -> CFDictionary?`](https://developer.apple.com/documentation/imageio/1465363-cgimagesourcecopypropertiesatind) で取得できるデータの一例を下記に示します。

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

```swift
[
    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](#unclampeddelaytime-と-delaytime) を御覧ください。

# 問題点

## Assets.xcassets が使えない

[`NSDataAsset`](https://developer.apple.com/documentation/uikit/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](https://stackoverflow.com/questions/16964366/delaytime-or-unclampeddelaytime-for-gifs) をみた感じ、下記のように判定すればいいのかなと思います。

1. UnclampedDelayTime があればこの値を使う
2. なければ DelayTime を使う
3. 0.011 未満なら 0.1 とする

[`kCGImagePropertyAPNGDelayTime`](https://developer.apple.com/documentation/imageio/kcgimagepropertyapngdelaytime), [`kCGImagePropertyAPNGUnclampedDelayTime`](https://developer.apple.com/documentation/imageio/kcgimagepropertyapngunclampeddelaytime), [`kCGImagePropertyGIFDelayTime`](https://developer.apple.com/documentation/imageio/kcgimagepropertygifdelaytime), [`kCGImagePropertyGIFUnclampedDelayTime `](https://developer.apple.com/documentation/imageio/kcgimagepropertygifunclampeddelaytime) などの定数が用意されていますが使うメリットを感じなかったので今回は使用していません。



# 参考


- WatchKit でのアニメーション<br>https://qiita.com/shu223/items/e704f70abc50da32ba38
- iOS で GIF アニメーション<br>https://qiita.com/ushisantoasobu/items/8c9f8815e8b8c99e94ad
- UnclampedDelayTime と DelayTime<br>
  https://stackoverflow.com/questions/16964366/delaytime-or-unclampeddelaytime-for-gifs

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