SwiftUI 播放GIF的实现方案

1,062 阅读2分钟

突然发现SwiftUI的Image貌似不支持播放GIF,那就只能自己尝试实现一把。

effect.gif

Demo:Github地址

实现方案

1. SwiftUI中使用UIKit - UIViewRepresentable

SwiftUIImageAsyncImage目前发现并不支持播放GIF,既然如此,最简单的实现就是将UIKitUIImageView应用到SwiftUI中。

SwiftUI中使用UIKit控件,就得用到UIViewRepresentable协议去实现了:

import SwiftUI
import UIKit

struct GifImage: UIViewRepresentable {
    // GIF模型
    var resource: GifResource?
    // UIKit的内容显示模式
    var contentMode: UIView.ContentMode = .scaleAspectFill
    // 用于控制GIF的播放/暂停
    @Binding var isAnimating: Bool
    
    func makeUIView(context: Context) -> MyView { MyView() }
    
    func updateUIView(_ uiView: MyView, context: Context) {
        uiView.contentMode = contentMode
        uiView.updateGifResource(resource, isAnimating)
    }
    
    ......
}
  • GifResource是提供GIF的图片、时长的模型类;
  • func makeUIView(context: Context) -> MyViewfunc updateUIView(_ uiView: MyView, context: Context)UIViewRepresentable协议必须实现的两个函数,前者是创建你想用的UIView,后者是用来刷新该UIView,系统自己会调用,例如给resource属性赋值就会调起,所以我们应该在这个函数中设置内容。

其中MyView是我自己自定义的一个UIView,上面放着一个UIImageView专门播放GIF:

class MyView: UIView {
    private let imageView = UIImageView()
    private var resource: GifResource?
        
    init() {
        super.init(frame: .zero)
        clipsToBounds = true
        
        imageView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(imageView)
        NSLayoutConstraint.activate([
            imageView.widthAnchor.constraint(equalTo: widthAnchor),
            imageView.heightAnchor.constraint(equalTo: heightAnchor),
        ])
    }
        
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
        
    override var contentMode: UIView.ContentMode {
        set { imageView.contentMode = newValue }
        get { imageView.contentMode }
    }
        
    ......
}
  • 为什么不直接使用UIImageView?如果直接使用UIImageView,整个视图的尺寸在SwiftUI将不受控制(图片多大视图就多大),这个目前我也不知道为什么,但神奇的是在其上面放入UIImageView并添加约束即可限制大小。
  • 其实这里使用第三方的GIF加载方式(SDWebImageKingFisher)应该会更好,本文只是介绍实现方案,所以用最简单的方式实现。

2. 解码GIF文件

GIF的解码过程我写在了UIImage的分类中,并且使用了async/await适配SwiftUI,方便调起:

import UIKit

extension UIImage {
    static func decodeGif(fromBundle name: String) async throws -> GifResource {
        ......
    }
    
    static func decodeGif(withUrl url: URL?) async throws -> GifResource {
        ......
    }
    
    static func decodeGif(withData data: Data) async throws -> GifResource {
        ......
    }
}
  • 具体实现可以查看Demo,都是参考YYKit的做法然后“翻译”成Swift语言(Maybe会有问题,目前还没发现任何问题,凑合着用)。

3. 用起来

struct ContentView: View {
    @State var resource: GifResource? = nil
    
    var body: some View {
        VStack {
            GifImage(resource: resource, 
                     contentMode: .scaleAspectFit, 
                     isAnimating: .constant(true))
                .frame(width: 150, height: 150)
                .background(.ultraThinMaterial)
                .mask(RoundedRectangle(cornerRadius: 10, style: .continuous))
                .shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 10)
        }
        .task {
            resource = try? await UIImage.decodeGif(fromBundle: "Cat2")
        }
    }
}

4. AsyncGifImage - 异步加载远程/本地GIF

基于GifImage的扩展,一个可异步加载远程/本地GIF的View

/// 仿照`AsyncImage`
AsyncGifImage(url: url,
              contentMode: .scaleAspectFit,
              transaction: Transaction(animation: .easeInOut),
              isAnimating: $isAnimating,
              isReLoad: $isReload) { phase in
    switch phase {
        // 请求中
        case .loading: ProgressView()
        // 请求成功
        case let .success(image): image // image为GifImage
        // 请求失败
        case .failure: Text("Failure").font(.body.weight(.bold))
    }
}
  • 让使用者根据phase返回不同状态,自定义去提供不同时期的View
  • transaction:根据phase切换不同的View的过渡效果;
  • isAnimating:控制GIF的播放/暂停;
  • isReload:重载GIF。

最终效果

effect.gif

OK, done.

Demo:Github地址