iOS App小组件(Widget)显示LottieFiles动画和GIF图片

目前, iOS小组件中无法直接显示LottieFiles动画,如果要实现iOS小组件的动画效果,必须通过一些黑科技将LottieFiles动画转换为GIF图片帧,通过一些自定义字体或者时钟旋转等方式,实现iOS小组件播放动画的功能。

Lottie动画依赖 Core Animation + CADisplayLink 来实时渲染帧动画。

但 WidgetKit 的设计理念是「静态快照」:

Widget 并不是实时渲染的 view,而是定期刷新生成的快照(snapshot)。

它运行在后台 extension 中,不允许持续的动画循环或渲染。

小组件不支持LottieFIles

即使引入 Lottie 库,也无法使用 AnimationView.play() 这种方法------因为 widget 不支持 RunLoop 或连续帧刷新。

Xcode运行时输出报错信息,表示SwiftUI 无法序列化(encode)一个自定义 UIViewRepresentable(或 NSViewRepresentable)类型:LottieView。

makefile 复制代码
PotentialCrashError: BankletWidgetExtension may have crashed  
ArchivingError: failedToEncode(types: [SwiftUI.(unknown context).PlatformViewRepresentableAdaptor<BankletWidgetExtension.LottieView>])

iOS系统显示的小组件也会因为无法序列化,显示一个黄底红色的禁止小组件(下图左侧样式)。

因此,在 widget 中「播放」Lottie 动画是不被允许的。

Lottie转换为GIF图片

需要将现有的Lottie文件转换为GIF图片格式,这样才可以在小组件中显示Lottie动画。

建议使用LottieFiles的格式转换页面:lottiefiles.com/tools/lotti...

此外还有其他两种LottieFiles格式转换方法,具体请见《Lottie动画转GIF图片》文章进行了解。

小组件显示GIF图片~~~~

1、Xcode导入并配置第三方库

打开Xcode项目,点击左侧的项目名称 -- PROJECT -- 项目名 -- Package Dependencies,点击"添加"按钮,引入GitHub第三方库ClockHandRotationKitgithub.com/octree/Cloc...)。

导入ClockHandRotationKit框架后,主应用和小组件都必须配置这一框架。

点击左侧的项目名称 -- TARGETS,分别检查主应用和小组件的General -- Frameworks, Libraries, and Embedded Content,是否包含ClockHandRotationKit框架。

2、导入Gif图片

在Xcode小组件项目中,创建一个Gif组,将Gif图片拖入到这个组中。

在拖入Gif组时,Targets选择主应用和小组件,如果不勾选,后面可能无法从Bundle.main.path查找。

Xcode中Gif组:

Gif图片放入Xcode项目的原因:因为需要通过Bundle.main.path访问Gif图片。

如果将Gif放到Assets资源文件夹中,就无法在文件系统里查找真实文件路径,Bundle.main.path会返回nil。

3、创建UIImage扩展和弧形视图

1、创建一个UIImage扩展文件

因为扩展代码过长,具体请见文章底部的扩展代码部分,或者参考GitHub代码(github.com/fangjunyu1/...)。

swift 复制代码
import UIKit

extension UIImage {
    static func fromBundle(_ bundle: Bundle? = nil, forName name: String?, ofType ext: String?) -> UIImage? {
        guard let path = (bundle ?? Bundle.main).path(forResource: name, ofType: ext) else {
            return nil
        }
        return UIImage(contentsOfFile: path)
    }
}

...

2、创建一个弧形视图文件。

弧形视图文件代码:

less 复制代码
import SwiftUI

struct ArcView: Shape {
    var arcStartAngle: Double
    var arcEndAngle: Double
    var arcRadius: Double
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.addArc(center: CGPoint(x: rect.midX, y: rect.midY),
                    radius: arcRadius,
                    startAngle: .degrees(arcStartAngle),
                    endAngle: .degrees(arcEndAngle),
                    clockwise: false)
        return path
    }
}

这个文件的三个变量,分别控制弧形的起始角度、结束角度和弧形半径。

4、创建Gif图片视图

创建一个SwiftUI自定义视图,将GIF动画拆分成帧,在每一帧图片上面添加一个圆弧遮罩,然后旋转所有圆弧遮罩,通过旋转效果组合成一个动态的弧形GIF展示效果。

less 复制代码
import SwiftUI
import ClockHandRotationKit

struct GifImageView: View {
    var gifName: String // Bundle中 gif图片的名称
    
    func getGif(_ name: String) -> UIImage.GifResult? { 
        guard let path = Bundle.main.path(forResource: "gif_(name)", ofType: "gif"),
              let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else {
            print("未找到该数据")
            return nil
        }
        return UIImage.decodeGIF(data)
    }
    
    var body: some View {
        if let gif = getGif(gifName) {
            GeometryReader { proxy in
                let width = proxy.size.width
                let height = proxy.size.height
                
                let arcWidth = max(width, height)
                let arcRadius = arcWidth * arcWidth
                let angle = 360.0 / Double(gif.images.count)
                
                ZStack {
                    ForEach(1...gif.images.count, id: .self) { index in
                        Image(uiImage: gif.images[(gif.images.count - 1) - (index - 1)])
                            .resizable()
                            .scaledToFill()
                            .mask(
                                ArcView(arcStartAngle: angle * Double(index - 1),
                                        arcEndAngle: angle * Double(index),
                                        arcRadius: arcRadius)
                                .stroke(style: .init(lineWidth: arcWidth, lineCap: .square, lineJoin: .miter))
                                .clockHandRotationEffect(period: .custom(gif.duration))
                                .offset(y: arcRadius)
                            )
                    }
                }
                .frame(width: width, height: height)
            }
        } else {
            // 如果没有图片,显示空白占位符
            Image("png_Home0")
                .resizable()
        }
    }
}

这个代码可以理解为,首先通过getGif方法获取Gif图片的每一帧以及运行时间。

通过ZStack排列所有的图片帧,因为ZStack视图是从后往前,在ForEach循环时,也是从后往前的顺序遍历。

假设某个Gif图片共20帧,每帧在ZStack中显示的排序为:

在每一个Gif帧上设置一个mask遮罩层,Gif帧只会显示mask的视图部分,非mask的视图不显示。mask遮罩层显示一个弧形。

弧形的开始角度、结束角度都是根据Gif帧数平均计算。

每个Gif帧的mask遮罩层都会对应到弧形上,通过设置弧形的边框,让遮罩层可以更好的显示每一个Gif帧。

设置描边可以让遮罩层覆盖到视图,弧形向下偏移半径的长度。

当对应黄色区域逆时针旋转,与蓝色的View视图区域重合时,对应的Gif图片帧就会显示出来。

因为每个Gif图片帧的黄色区域不同,所以当弧形蒙版不断旋转,就可以实现Gif图片的效果。

5、创建Gif图片小组件

css 复制代码
import WidgetKit
import SwiftUI
import ClockHandRotationKit

struct GifView : View {
    var entry: GifWidgetEntry

    var body: some View {
        VStack {
            GifImageView(gifName: "(entry.loopAnimation)")
        }
    }
}

struct GifAnimateWidget: Widget {
    let kind: String = "GifWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: GifWidgetProvider()) { entry in
            GifView(entry: entry)
                .containerBackground(.clear, for: .widget)
        }
        .configurationDisplayName("Animation Widget")
        .description("Play animation on the desktop in a loop.")
        .supportedFamilies([.systemSmall])
    }
}

#Preview(as: .systemSmall) {
    GifAnimateWidget()
} timeline: {
    GifWidgetEntry(date: Date(), loopAnimation: "Home33")
}

在GifView视图中,显示GifImage视图。

­­­注意事项

1、主应用和小组件都需要导入框架

Xcode导入ClockHandRotationKit框架时,必须考虑导入到主应用和小组件中。

如果没有导入主应用,就会存在Xcode调试真机时,报错并断开连接的情况。

可能是主应用也需要编译小组件的代码,当缺少小组件代码运行的框架时,就会报错。

2、Xcode项目导入Gif图片

这里使用Group或者Folder等形式,管理Gif图片。在导入图片时,需要在Target Membership中勾选主应用和小组件。

可能只需要勾选主应用,因为Bundle.main.path通过主应用的包进行查询。如果这里没有勾选主应用,就会存在查不到的情况。

3、透明背景

透明背景需要使用私有API,具体请见《iOS App小组件(Widget)设置透明背景

4、控制GIF播放速度

默认按照GIF动画时间进行播放,如果想要调整GIF播放速度,可以在GifImageView视图代码中,调整mask蒙版的代码:

less 复制代码
.mask(
    ArcView(arcStartAngle: angle * Double(index - 1),
            arcEndAngle: angle * Double(index),
            arcRadius: arcRadius)
    .stroke(style: .init(lineWidth: arcWidth, lineCap: .square, lineJoin: .miter))
    .clockHandRotationEffect(period: .custom(gif.duration * 2)) // 控制 GIF 动画速度,选择 *2 或者 * 3
    .offset(y: arcRadius)
)

5、小组件播放卡顿或空白

因为iOS 小组件内容比较低,如果Gif图片过大,帧数过多,就可能导致超过30MB内存并无法运行小组件。

目前实际测试发现5MB以内的Gif图片,小组件显示存在压力。超过5MB的Gif图片可能会直接显示空白。

建议压缩Gif图片大小,将Gif图片尽量控制在1MB以内,这样可以正常的显示/切换Gif动画。否则Gif图片越大,小组件在显示/切换的过程中,就会出现卡顿或空白的情况。

总结

本文尽量通过简单的描述,来讲解iOS实现GIF图片的效果,通过蒙版和旋转弧形实现GIF图片动画。

除此之外,还可以通过字体实现GIF动画效果,具体请见GitHub仓库WidgetAnimation(github.com/brycebostwi...)。

我除了这两种方式外,还尝试使用倒计时显示每一帧图片,但实际上并不能实现GIF动画效果,我猜测原因是小组件不支持Image动态显示。

swift 复制代码
struct GIFPlayerView: View {
    let gif: UIImage.GifResult
    @State private var currentFrameIndex = 0
    @State private var timer: Timer? = nil
    
    var body: some View {
        GeometryReader { proxy in
            let width = proxy.size.width
            let height = proxy.size.height
            
            Image(uiImage: gif.images[currentFrameIndex])
                .resizable()
                .scaledToFill()
                .frame(width: width, height: height)
                .clipped()

当使用计时器调整数组索引显示Gif图片时,小组件不会让Image动态切换,也就无法通过计时器实现GIF动画效果。

参考文章

1、Display lottie animation in iOS WidgetKit:stackoverflow.com/questions/7...

2、How to animate WidgetKit Widgets like other apps do it?

stackoverflow.com/questions/7...

3、Lottie动画转GIF图片:fangjunyu.com/2025/10/12/...

4、GitHub WidgetAnimation:github.com/brycebostwi...

5、ClockHandRotationKit:github.com/octree/Cloc...

6、WidgetsWall:github.com/MisterZhouZ...

7、【iOS】GIF小组件的巧妙实现:juejin.cn/post/739998...

8、【iOS小组件实战】gif动态小组件:juejin.cn/post/742669...

9、GitHub UIImage扩展代码:github.com/fangjunyu1/...

10、SwiftUI控制视图透明度可见区域的mask:fangjunyu.com/2025/03/20/...

11、iOS App小组件(Widget)设置透明背景:fangjunyu.com/2025/10/08/...

扩展代码

1、UIImage扩展代码

swift 复制代码
import UIKit

extension UIImage {
    static func fromBundle(_ bundle: Bundle? = nil, forName name: String?, ofType ext: String?) -> UIImage? {
        guard let path = (bundle ?? Bundle.main).path(forResource: name, ofType: ext) else {
            return nil
        }
        return UIImage(contentsOfFile: path)
    }
}

extension UIImage {
    struct GifResult {
        let images: [UIImage]
        let duration: TimeInterval
    }
    
    static func decodeBundleGIF(_ bundle: Bundle? = nil, forName name: String) async -> GifResult? {
        guard let path = (bundle ?? Bundle.main).path(forResource: name, ofType: "gif") else {
            return nil
        }
        return await decodeLocalGIF(URL(fileURLWithPath: path))
    }
    
    static func decodeLocalGIF(_  url: URL) async -> GifResult? {
        guard let data = try? Data(contentsOf: url) else {
            return nil
        }
        return decodeGIF(data)
    }
    
    static func decodeGIF(_  data: Data) -> GifResult? {
        guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil) else {
            return nil
        }
        
        let count = CGImageSourceGetCount(imageSource)
        
        var images: [UIImage] = []
        var duration: TimeInterval = 0
        
        for i in 0 ..< count {
            guard let cgImg = CGImageSourceCreateImageAtIndex(imageSource, i, nil) else { continue }
            
            let img = UIImage(cgImage: cgImg)
            images.append(img)
            
            guard let proertyDic = CGImageSourceCopyPropertiesAtIndex(imageSource, i, nil) else {
                duration += 0.1
                continue
            }
            
            guard let gifDicValue = CFDictionaryGetValue(proertyDic, Unmanaged.passRetained(kCGImagePropertyGIFDictionary).autorelease().toOpaque()) else {
                duration += 0.1
                continue
            }
            
            let gifDic = Unmanaged<CFDictionary>.fromOpaque(gifDicValue).takeUnretainedValue()
            
            guard let delayValue = CFDictionaryGetValue(gifDic, Unmanaged.passRetained(kCGImagePropertyGIFUnclampedDelayTime).autorelease().toOpaque()) else {
                duration += 0.1
                continue
            }
            
            var delayNum = Unmanaged<NSNumber>.fromOpaque(delayValue).takeUnretainedValue()
            var delay = delayNum.doubleValue
            
            if delay <= Double.ulpOfOne {
                if let delayValue2 = CFDictionaryGetValue(gifDic, Unmanaged.passRetained(kCGImagePropertyGIFDelayTime).autorelease().toOpaque()) {
                    delayNum = Unmanaged<NSNumber>.fromOpaque(delayValue2).takeUnretainedValue()
                    delay = delayNum.doubleValue
                }
            }
            
            if delay < 0.02 {
                delay = 0.1
            }
            
            duration += delay
        }
        
        guard images.count > 0 else {
            return nil
        }
        
        return GifResult(images: images, duration: duration)
    }
}
相关推荐
Digitally4 小时前
如何将 iPhone 联系人同步到 Mac
macos·ios·iphone
Digitally4 小时前
无需 iCloud 在 iPhone 之间传输文本消息
ios·iphone·icloud
2501_9159214312 小时前
iOS 是开源的吗?苹果系统的封闭与开放边界全解析(含开发与开心上架(Appuploader)实战)
android·ios·小程序·uni-app·开源·iphone·webview
2501_9159090615 小时前
原生 iOS 开发全流程实战,Swift 技术栈、工程结构、自动化上传与上架发布指南
android·ios·小程序·uni-app·自动化·iphone·swift
2501_9151063215 小时前
Comodo HTTPS 在工程中的部署与排查实战(证书链、兼容性与真机抓包策略)
网络协议·http·ios·小程序·https·uni-app·iphone
2501_9159090615 小时前
苹果软件混淆与 iOS 代码加固趋势,IPA 加密、应用防反编译与无源码保护的工程化演进
android·ios·小程序·https·uni-app·iphone·webview
2501_9160074716 小时前
苹果软件混淆与 iOS 应用加固实录,从被逆向到 IPA 文件防反编译与无源码混淆解决方案
android·ios·小程序·https·uni-app·iphone·webview
Zender Han21 小时前
Flutter 实现人脸检测 — 使用 google_mlkit_face_detection
android·flutter·ios
2501_916008891 天前
iOS 26 性能分析深度指南 包含帧率、渲染、资源瓶颈与 KeyMob 协助策略
android·macos·ios·小程序·uni-app·cocoa·iphone