Skip to content

Commit f07a5b3

Browse files
author
Developer
committed
添加 GIF 动画附件支持:新增 BSTextAnimatedImageAttachment 类,支持 URL 和 Data 两种方式加载 GIF
1 parent 084b8e9 commit f07a5b3

1 file changed

Lines changed: 149 additions & 1 deletion

File tree

BSText/Sources/Attachment/BSTextAttachment.swift

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import UIKit
2+
import ImageIO
3+
import MobileCoreServices
24

35
@objcMembers
46
open class BSTextAttachment: NSTextAttachment {
@@ -7,7 +9,7 @@ open class BSTextAttachment: NSTextAttachment {
79

810
public var displaySize: CGSize = CGSize(width: 0, height: 0)
911

10-
public private(set) var state: State = .placeholder
12+
public var state: State = .placeholder
1113

1214
public var cacheKey: String?
1315

@@ -139,6 +141,127 @@ open class BSTextAttachment: NSTextAttachment {
139141
}
140142
}
141143

144+
@objcMembers
145+
open class BSTextAnimatedImageAttachment: BSTextAttachment {
146+
147+
public private(set) var animatedFrames: [UIImage] = []
148+
public private(set) var frameDurations: [TimeInterval] = []
149+
public private(set) var totalDuration: TimeInterval = 0
150+
151+
private var displayLink: CADisplayLink?
152+
private var currentFrameIndex: Int = 0
153+
private var elapsedTime: TimeInterval = 0
154+
155+
public override init(type: BSTextAttachmentType) {
156+
super.init(type: type)
157+
if type == .animatedImage {
158+
displaySize = CGSize(width: 200, height: 200)
159+
}
160+
}
161+
162+
public required init?(coder: NSCoder) {
163+
super.init(coder: coder)
164+
}
165+
166+
public func loadAnimatedImage(from data: Data) {
167+
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
168+
state = .failed
169+
delegate?.attachmentDidFailLoading?(self)
170+
return
171+
}
172+
173+
let frameCount = CGImageSourceGetCount(source)
174+
guard frameCount > 0 else {
175+
state = .failed
176+
delegate?.attachmentDidFailLoading?(self)
177+
return
178+
}
179+
180+
var frames: [UIImage] = []
181+
var durations: [TimeInterval] = []
182+
var totalDuration: TimeInterval = 0
183+
184+
for i in 0..<frameCount {
185+
guard let cgImage = CGImageSourceCreateImageAtIndex(source, i, nil) else {
186+
continue
187+
}
188+
189+
let image = UIImage(cgImage: cgImage)
190+
frames.append(image)
191+
192+
var duration: TimeInterval = 0.1
193+
194+
if let properties = CGImageSourceCopyPropertiesAtIndex(source, i, nil) as? [String: Any],
195+
let gifProperties = properties[kCGImagePropertyGIFDictionary as String] as? [String: Any] {
196+
if let unclampedDelayTime = gifProperties[kCGImagePropertyGIFUnclampedDelayTime as String] as? TimeInterval {
197+
duration = unclampedDelayTime
198+
} else if let delayTime = gifProperties[kCGImagePropertyGIFDelayTime as String] as? TimeInterval {
199+
duration = delayTime
200+
}
201+
}
202+
203+
if duration < 0.02 {
204+
duration = 0.1
205+
}
206+
207+
durations.append(duration)
208+
totalDuration += duration
209+
}
210+
211+
self.animatedFrames = frames
212+
self.frameDurations = durations
213+
self.totalDuration = totalDuration
214+
self.state = .loaded
215+
216+
if let firstFrame = frames.first {
217+
self.image = firstFrame
218+
}
219+
220+
delegate?.attachmentDidFinishLoading?(self)
221+
}
222+
223+
public func startAnimating() {
224+
guard displayLink == nil, animatedFrames.count > 1 else { return }
225+
226+
displayLink = CADisplayLink(target: self, selector: #selector(updateFrame))
227+
displayLink?.add(to: .main, forMode: .common)
228+
}
229+
230+
public func stopAnimating() {
231+
displayLink?.invalidate()
232+
displayLink = nil
233+
currentFrameIndex = 0
234+
elapsedTime = 0
235+
}
236+
237+
@objc private func updateFrame(_ displayLink: CADisplayLink) {
238+
guard frameDurations.count > currentFrameIndex else {
239+
currentFrameIndex = 0
240+
return
241+
}
242+
243+
let frameDuration = frameDurations[currentFrameIndex]
244+
elapsedTime += displayLink.duration
245+
246+
if elapsedTime >= frameDuration {
247+
elapsedTime = 0
248+
currentFrameIndex = (currentFrameIndex + 1) % animatedFrames.count
249+
self.image = animatedFrames[currentFrameIndex]
250+
}
251+
}
252+
253+
open override func image(forBounds imageBounds: CGRect, textContainer: NSTextContainer?, characterIndex charIndex: Int) -> UIImage? {
254+
if state == .loaded, let firstFrame = animatedFrames.first {
255+
return firstFrame
256+
}
257+
return super.image(forBounds: imageBounds, textContainer: textContainer, characterIndex: charIndex)
258+
}
259+
260+
deinit {
261+
stopAnimating()
262+
}
263+
}
264+
142265
@objc public protocol BSTextAttachmentDelegate: NSObjectProtocol {
143266
@objc optional func attachmentDidStartLoading(_ attachment: BSTextAttachment)
144267
@objc optional func attachmentDidFinishLoading(_ attachment: BSTextAttachment)
@@ -154,6 +277,31 @@ public extension BSTextAttachment {
154277
return attachment
155278
}
156279

280+
static func animatedImageAttachment(url: URL, displaySize: CGSize? = nil) -> BSTextAnimatedImageAttachment {
281+
let attachment = BSTextAnimatedImageAttachment(type: .animatedImage)
282+
attachment.url = url
283+
attachment.displaySize = displaySize ?? CGSize(width: 200, height: 200)
284+
285+
Task {
286+
do {
287+
let (data, _) = try await URLSession.shared.data(from: url)
288+
attachment.loadAnimatedImage(from: data)
289+
attachment.startAnimating()
290+
} catch {
291+
attachment.state = .failed
292+
}
293+
}
294+
295+
return attachment
296+
}
297+
298+
static func animatedImageAttachment(data: Data, displaySize: CGSize? = nil) -> BSTextAnimatedImageAttachment {
299+
let attachment = BSTextAnimatedImageAttachment(type: .animatedImage)
300+
attachment.displaySize = displaySize ?? CGSize(width: 200, height: 200)
301+
attachment.loadAnimatedImage(from: data)
302+
return attachment
303+
}
304+
157305
static func emojiAttachment(emoji: String) -> BSTextAttachment {
158306
let attachment = BSTextAttachment(type: .image)
159307
let font = UIFont.systemFont(ofSize: 32)

0 commit comments

Comments
 (0)