【SwiftUI/JetpackCompose】GIFを表示する　~比較で覚える宣言的モバイルUI~

SwiftUIとJetpackComposeで、同セグメントの機能を両フレームワークでつくろうとした時に
- きれいに対応関係がまとまっている
- コピペで動く
- 最もシンプルな実装

そんな記事があったらいいなと思い、備忘録も兼ねて自分が実装してみた範囲でまとめていきたいと思います。初心者ですので、より良い実装をご存知の方がいらっしゃいましたら、ご教示ください。

今回は**GIF編**です。

:::note warn
なお、見た目をSwiftUI寄りにしがちです(そっちしか知らないだけ...)
:::

# 対応関係

【SwiftUI】①SwiftyGIFなどのライブラリを使う　②自前で実装　(③URLから読み込む(QLPreviewView) -> 未検証)
【JetpackCompose】Coilというライブラリを使う

# Jetpack Compose
より楽に実装できるのはJetpack Composeでした。[こちら](https://stackoverflow.com/questions/60229555/adding-gif-into-jetpack-compose#:~:text=An%20updated%20version%20of%20Hoby%27s%20answer%20above)でDr BigKitkatさんが非常に端的に使い方を紹介してくださっており、参考にさせていただきました。[公式](https://coil-kt.github.io/coil/gifs/)の説明もすごくわかりやすいです。


## Coilを入れる
[Coil](https://coil-kt.github.io/coil/)は、画像の読み込みを行うライブラリだそうです。
20221001時点での最新版は、2.2.1でした。以下をapp/build.gradleに追加し、Syncします。
```kotlin
implementation "io.coil-kt:coil-compose:2.2.1"
implementation "io.coil-kt:coil-gif:2.2.1"
```

## GIFの表示
以下の汎用的なComposableを作ります。
<details><summary>コードはこちら</summary>

```kotlin: GIFImage.kt
import android.os.Build.VERSION.SDK_INT
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import coil.ImageLoader
import coil.compose.rememberAsyncImagePainter
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.request.ImageRequest
import coil.size.Size.Companion.ORIGINAL

@Composable
fun GIFImage(
    modifier: Modifier = Modifier,
    @DrawableRes gifImage: Int,
) {
    val context = LocalContext.current
    val imageLoader = ImageLoader.Builder(context)
        .components {
            if (SDK_INT >= 28) {
                add(ImageDecoderDecoder.Factory())
            } else {
                add(GifDecoder.Factory())
            }
        }
        .build()
    Image(
        painter = rememberAsyncImagePainter(
            ImageRequest.Builder(context).data(data = gifImage).apply(block = {
                size(ORIGINAL)
            }).build(), imageLoader = imageLoader
        ),
        contentDescription = null,
        modifier = modifier,
    )
}
```
</details>

## 具体例
Giphyから「Example.gif」という名前でダウンロードしたGIFを、Resourcesに入れて表示してみます。
```kotlin:GIFImageExample.kt
@Composable
fun GIFImageExample() {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center,
    ) {
        GIFImage(gifImage = R.drawable.example) // <- GIF画像を指定して表示
    }
}
```

## 実行結果
あっという間にGIFが表示できました。
![ezgif.com-gif-maker.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201911/db379af8-7c70-3433-cc96-15ba433da14a.gif)

# SwiftUI
SwiftUIでは、決定的なものは見当たりませんでした。調べてみた限り、3通りありそうでした。
1. [SwiftyGIF](https://github.com/kirualex/SwiftyGif)などのライブラリを使う
1. 自前で実装 -> [参考](https://swiftuirecipes.com/blog/gif-images-in-swiftui)
1. URLから読み込む(QLPreviewView) -> [参考](https://stackoverflow.com/questions/70369610/swiftui-display-gif-image)


## 1. SwiftyGIFを使う
GIFを扱うためによく利用されているライブラリのようです。Cocoapods, Carthage, Swift Package Manager(SPM)のどれでも導入可能になっていました。自分はSPMを使って導入しました。

SPMでの導入の詳細は[こちら](https://shuhey-hashimoto.com/swift/swift-package-manager%E3%81%AE%E4%BD%BF%E7%94%A8%E6%96%B9%E6%B3%95/)に大変わかりやすくご紹介くださっていたので、ここでは省略します。ただ、一つ注意点として、**「SwiftGIF-Dynamic」のチェックボックスは外して入れた方がよさそうです**。自分の場合、入れたらビルドの時にエラーになってしまいました(Dynamicのディレクトリはないよ、とXcodeに怒られる)。

### GIFの表示
SwiftyGIFを使って、汎用的なビューを作成します。[こちら](https://github.com/kirualex/SwiftyGif/issues/128)の記事を参考にさせていただきました。

SwiftyGIFは、UIKitのUIViewをベースにしているので、まず**UIView**をつくります。

<details><summary>コードはこちら</summary>

```swift:UIGIFImageView
import Foundation
import UIKit
import SwiftUI

class UIGIFImageView: UIView {
    private var image = UIImage()
    var imageView = UIImageView()
    private var data: Data?
    private var name: String?
    private var loopCount: Int?
    private var playGif: Bool?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    convenience init(name: String, loopCount: Int, playGif: Bool) {
        self.init()
        self.name = name
        self.loopCount = loopCount
        self.playGif = playGif
        self.layoutSubviews()
    }
    
    convenience init(data: Data, loopCount: Int, playGif: Bool) {
        self.init()
        self.data = data
        self.loopCount = loopCount
        self.playGif = playGif
        self.layoutSubviews()
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        imageView.frame = bounds
        self.addSubview(imageView)
    }
    
    func updateGIF(name: String, data: Data?, loopCount: Int) {
        do {
            if let data = data {
                image = try UIImage(gifData: data)
            } else {
                print(name)
                image = try UIImage(gifName: name)
            }
        } catch {
            print(error)
        }
        
        if let subview = self.subviews.first as? UIImageView {
            if image.imageData != subview.gifImage?.imageData {
                imageView = UIImageView(gifImage: image, loopCount: loopCount)
                imageView.contentMode = .scaleAspectFit
                subview.removeFromSuperview()
            }
        } else {
            print("error: no existing subview")
        }
    }
}
```
</details>

続いて、UIViewを**UIViewRepresentable**でラップして、SwiftUIで使えるビューに変換します。

<details><summary>コードはこちら</summary>

```swift:GIFImage.swift
import SwiftUI
import SwiftyGif

struct GIFImage: UIViewRepresentable {
    private let data: Data?
    private let name: String?
    private let loopCount: Int?
    @Binding var playGif: Bool
    
    init(data: Data, loopCount: Int = -1, playGif: Binding<Bool> = .constant(true)) {
        self.data = data
        self.name = nil
        self.loopCount = loopCount
        self._playGif = playGif
    }
    
    init(name: String, loopCount: Int = -1, playGif: Binding<Bool> = .constant(true)) {
        self.data = nil
        self.name = name
        self.loopCount = loopCount
        self._playGif = playGif
    }
    
    func makeUIView(context: Context) -> UIGIFImageView {
        var gifImageView: UIGIFImageView
        if let data = data {
            gifImageView = UIGIFImageView(data: data, loopCount: loopCount!, playGif: playGif)
        } else {
            gifImageView = UIGIFImageView(name: name!, loopCount: loopCount!, playGif: playGif)
        }
        return gifImageView
    }
    
    func updateUIView(_ gifImageView: UIGIFImageView, context: Context) {
        gifImageView.updateGIF(name: name ?? "", data: data, loopCount: loopCount!)
        
        if playGif {
            gifImageView.imageView.startAnimatingGif()
        } else {
            gifImageView.imageView.stopAnimatingGif()
        }
    }
}

```
</details>

### 具体例
あとは普通にビューとして呼び出せます。GIF画像は、Assetsの中に「Example.gif」という名前で入れています。注意点としては、Assetsから呼び出すにはname引数に名前を渡すのではダメで、NSDataAssetとして呼び出してdata引数に入れるという点です。
```swift: GIFImageExample.swift
import SwiftUI

struct GIFImageExample: View {
    let gifData = NSDataAsset(name:"Example")?.data
    
    var body: some View {
        if let gifData = gifData {
            GIFImage(data: gifData)
                .frame(height: 200)
        }
    }
}
```
### 実行結果
無事にGIFが表示できました。
![swift_swiftygif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201911/c1dbd2ff-f0b9-c34c-845e-c5d37b31cd60.gif)

## 2. 自前で実装
なんとライブラリを使わなくてもGIFを表示できてしまうようです。

### 作り方
[こちら](https://swiftuirecipes.com/blog/gif-images-in-swiftui)の記事を参考に、実装していきます。

まず、**UIImageのExtension**をつくります。
<details><summary>コードはこちら</summary>

```swift:UIImageExtension.swift
import Foundation
import UIKit

extension UIImage {
  class func gifImage(data: Data) -> UIImage? {
    guard let source = CGImageSourceCreateWithData(data as CFData, nil)
    else {
       return nil
    }
    let count = CGImageSourceGetCount(source)
    let delays = (0..<count).map {
      Int(delayForImage(at: $0, source: source) * 1000)
    }
    let duration = delays.reduce(0, +)
    let gcd = delays.reduce(0, gcd)

    var frames = [UIImage]()
    for i in 0..<count {
      if let cgImage = CGImageSourceCreateImageAtIndex(source, i, nil) {
        let frame = UIImage(cgImage: cgImage)
        let frameCount = delays[i] / gcd

        for _ in 0..<frameCount {
          frames.append(frame)
        }
      } else {
        return nil
      }
    }

    return UIImage.animatedImage(with: frames,
                                 duration: Double(duration) / 1000.0)
  }
}

private func gcd(_ a: Int, _ b: Int) -> Int {
  let absB = abs(b)
  let r = abs(a) % absB
  if r != 0 {
    return gcd(absB, r)
  } else {
    return absB
  }
}

private func delayForImage(at index: Int, source: CGImageSource) -> Double {
  let defaultDelay = 1.0

  let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil)
  let gifPropertiesPointer = UnsafeMutablePointer<UnsafeRawPointer?>.allocate(capacity: 0)
  defer {
    gifPropertiesPointer.deallocate()
  }
  let unsafePointer = Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque()
  if CFDictionaryGetValueIfPresent(cfProperties, unsafePointer, gifPropertiesPointer) == false {
    return defaultDelay
  }
  let gifProperties = unsafeBitCast(gifPropertiesPointer.pointee, to: CFDictionary.self)
  var delayWrapper = unsafeBitCast(CFDictionaryGetValue(gifProperties,
                                                       Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque()),
                                  to: AnyObject.self)
  if delayWrapper.doubleValue == 0 {
      delayWrapper = unsafeBitCast(CFDictionaryGetValue(gifProperties,
                                                       Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque()),
                                  to: AnyObject.self)
  }

  if let delay = delayWrapper as? Double,
     delay > 0 {
    return delay
  } else {
    return defaultDelay
  }
}

extension UIImage {
  class func gifImage(name: String) -> UIImage? {
    guard let url = Bundle.main.url(forResource: name, withExtension: "gif"),
          let data = try? Data(contentsOf: url)
    else {
      return nil
    }
    return gifImage(data: data)
  }
}

```
</details>

続いて、**UIView**としてGIFを表示します。

<details><summary>コードはこちら</summary>

```swift:NativeUIGIFImageView.swift
import UIKit

class NativeUIGIFImageView: UIView {
    private let imageView = UIImageView()
    private var data: Data?
    private var name: String?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    convenience init(name: String) {
        self.init()
        self.name = name
        initView()
    }
    
    convenience init(data: Data) {
        self.init()
        self.data = data
        initView()
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        imageView.frame = bounds
        self.addSubview(imageView)
    }
    
    func updateGIF(data: Data) {
        imageView.image = UIImage.gifImage(data: data)
    }
    
    func updateGIF(name: String) {
        imageView.image = UIImage.gifImage(name: name)
    }
    
    private func initView() {
        imageView.contentMode = .scaleAspectFit
    }
}
```
</details>

続いて、UIViewを**UIViewRepresentable**でラップして、SwiftUIで使えるビューに変換します。

<details><summary>コードはこちら</summary>

```swift:NativeGIFImage.swift
import Foundation
import SwiftUI

struct NativeGIFImage: UIViewRepresentable {
  private let data: Data?
  private let name: String?

  init(data: Data) {
    self.data = data
    self.name = nil
  }

  public init(name: String) {
    self.data = nil
    self.name = name
  }

  func makeUIView(context: Context) -> NativeUIGIFImageView {
    if let data = data {
      return NativeUIGIFImageView(data: data)
    } else {
      return NativeUIGIFImageView(name: name ?? "")
    }
  }

  func updateUIView(_ uiView: NativeUIGIFImageView, context: Context) {
    if let data = data {
       uiView.updateGIF(data: data)
    } else {
      uiView.updateGIF(name: name ?? "")
    }
  }
}
```
</details>

### 具体例
あとは普通にビューとして呼び出せます。GIF画像は、Assetsの中に「Example.gif」という名前で入れています。ここでも同じく注意点として、Assetsから呼び出すにはname引数に名前を渡すのではなく、NSDataAssetとして呼び出してdata引数に入れます。

```swift:NativeGifImageExample
import SwiftUI

struct NativeGIFImageExample: View {
    let gifData = NSDataAsset(name:"Example")?.data
    
    var body: some View {
        if let gifData = gifData {
            NativeGIFImage(data: gifData)
                    .frame(height: 200)
        }
        
    }
}
```

### 実行結果
見事、GIFが表示できました。意外とSwiftyGIFとの違いは、UIImageのExtensionの作成の有無が主でした。
![swiftui_native_gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1201911/526cb620-dbe5-277f-320b-0a220956204c.gif)


## 3. URLから読み込む（QLPreviewView） -> 未検証
調べたところでは、「[最も簡単で早い方法](https://stackoverflow.com/questions/70369610/swiftui-display-gif-image#:~:text=Easiest%20and%20fastest%20way)」として紹介されていました。しかし試してみようとしたら、```import Quartz```ができないよ、とXcodeに怒られてしまったので、また時間のある時に検証してみたいと思います。

# まとめ
**GIF**
対応関係を再掲します。
【SwiftUI】①SwiftyGIFなどのライブラリを使う　②自前で実装　(③URLから読み込む(QLPreviewView) -> 未検証)
【JetpackCompose】Coilというライブラリを使う

GIFの表示についてはJetpack Composeの方がベストプラクティスが確立しているように思いました。SwiftではあまりGIFの使用は推奨してないのかよくわかりませんが、ここにも思想性の違いを感じました。