SwiftUIとJetpackComposeで、同セグメントの機能を両フレームワークでつくろうとした時に
- きれいに対応関係がまとまっている
- コピペで動く
- 最もシンプルな実装
そんな記事があったらいいなと思い、備忘録も兼ねて自分が実装してみた範囲でまとめていきたいと思います。初心者ですので、より良い実装をご存知の方がいらっしゃいましたら、ご教示ください。
今回はGIF編です。
なお、見た目をSwiftUI寄りにしがちです(そっちしか知らないだけ...)
対応関係
【SwiftUI】①SwiftyGIFなどのライブラリを使う ②自前で実装 (③URLから読み込む(QLPreviewView) -> 未検証)
【JetpackCompose】Coilというライブラリを使う
Jetpack Compose
より楽に実装できるのはJetpack Composeでした。こちらでDr BigKitkatさんが非常に端的に使い方を紹介してくださっており、参考にさせていただきました。公式の説明もすごくわかりやすいです。
Coilを入れる
Coilは、画像の読み込みを行うライブラリだそうです。
20221001時点での最新版は、2.2.1でした。以下をapp/build.gradleに追加し、Syncします。
implementation "io.coil-kt:coil-compose:2.2.1"
implementation "io.coil-kt:coil-gif:2.2.1"
GIFの表示
以下の汎用的なComposableを作ります。
コードはこちら
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,
)
}
具体例
Giphyから「Example.gif」という名前でダウンロードしたGIFを、Resourcesに入れて表示してみます。
@Composable
fun GIFImageExample() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
GIFImage(gifImage = R.drawable.example) // <- GIF画像を指定して表示
}
}
実行結果
SwiftUI
SwiftUIでは、決定的なものは見当たりませんでした。調べてみた限り、3通りありそうでした。
1. SwiftyGIFを使う
GIFを扱うためによく利用されているライブラリのようです。Cocoapods, Carthage, Swift Package Manager(SPM)のどれでも導入可能になっていました。自分はSPMを使って導入しました。
SPMでの導入の詳細はこちらに大変わかりやすくご紹介くださっていたので、ここでは省略します。ただ、一つ注意点として、「SwiftGIF-Dynamic」のチェックボックスは外して入れた方がよさそうです。自分の場合、入れたらビルドの時にエラーになってしまいました(Dynamicのディレクトリはないよ、とXcodeに怒られる)。
GIFの表示
SwiftyGIFを使って、汎用的なビューを作成します。こちらの記事を参考にさせていただきました。
SwiftyGIFは、UIKitのUIViewをベースにしているので、まずUIViewをつくります。
コードはこちら
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")
}
}
}
続いて、UIViewをUIViewRepresentableでラップして、SwiftUIで使えるビューに変換します。
コードはこちら
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()
}
}
}
具体例
あとは普通にビューとして呼び出せます。GIF画像は、Assetsの中に「Example.gif」という名前で入れています。注意点としては、Assetsから呼び出すにはname引数に名前を渡すのではダメで、NSDataAssetとして呼び出してdata引数に入れるという点です。
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)
}
}
}
実行結果
2. 自前で実装
なんとライブラリを使わなくてもGIFを表示できてしまうようです。
作り方
こちらの記事を参考に、実装していきます。
まず、UIImageのExtensionをつくります。
コードはこちら
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)
}
}
続いて、UIViewとしてGIFを表示します。
コードはこちら
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
}
}
続いて、UIViewをUIViewRepresentableでラップして、SwiftUIで使えるビューに変換します。
コードはこちら
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 ?? "")
}
}
}
具体例
あとは普通にビューとして呼び出せます。GIF画像は、Assetsの中に「Example.gif」という名前で入れています。ここでも同じく注意点として、Assetsから呼び出すにはname引数に名前を渡すのではなく、NSDataAssetとして呼び出してdata引数に入れます。
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の作成の有無が主でした。
3. URLから読み込む(QLPreviewView) -> 未検証
調べたところでは、「最も簡単で早い方法」として紹介されていました。しかし試してみようとしたら、import Quartz
ができないよ、とXcodeに怒られてしまったので、また時間のある時に検証してみたいと思います。
まとめ
GIF
対応関係を再掲します。
【SwiftUI】①SwiftyGIFなどのライブラリを使う ②自前で実装 (③URLから読み込む(QLPreviewView) -> 未検証)
【JetpackCompose】Coilというライブラリを使う
GIFの表示についてはJetpack Composeの方がベストプラクティスが確立しているように思いました。SwiftではあまりGIFの使用は推奨してないのかよくわかりませんが、ここにも思想性の違いを感じました。