-
Notifications
You must be signed in to change notification settings - Fork 45
Smilie Picker #1213
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Smilie Picker #1213
Changes from 10 commits
fac63df
be89ca9
e61d0ee
c0df0ff
3049059
51f0762
f4b11c3
4e5c5ae
29a4488
514adcc
0be5ede
1c8f835
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| // AnimatedImageView.swift | ||
| // | ||
| // Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app | ||
|
|
||
| import SwiftUI | ||
| import FLAnimatedImage | ||
|
|
||
| // Helper class to store weak references | ||
| private class Weak<T: AnyObject> { | ||
| weak var value: T? | ||
| init(_ value: T) { | ||
| self.value = value | ||
| } | ||
| } | ||
|
|
||
| // Simple LRU cache for animated images | ||
| private class AnimatedImageCache { | ||
| static let shared = AnimatedImageCache() | ||
|
|
||
| private var cache = NSCache<NSString, FLAnimatedImage>() | ||
|
|
||
| init() { | ||
| cache.countLimit = 50 // Cache up to 50 images | ||
| cache.totalCostLimit = 50 * 1024 * 1024 // ~50MB | ||
| } | ||
|
|
||
| func image(for key: String) -> FLAnimatedImage? { | ||
| cache.object(forKey: key as NSString) | ||
| } | ||
|
|
||
| func setImage(_ image: FLAnimatedImage, for key: String) { | ||
| let cost = image.data?.count ?? 0 | ||
| cache.setObject(image, forKey: key as NSString, cost: cost) | ||
| } | ||
| } | ||
|
|
||
| /// SwiftUI wrapper for FLAnimatedImageView to display animated GIFs | ||
| struct AnimatedImageView: UIViewRepresentable { | ||
| let data: Data | ||
| let imageID: String // Unique identifier for this image | ||
|
|
||
| class Coordinator { | ||
| var currentTask: Task<Void, Never>? | ||
| var currentImageID: String? | ||
|
|
||
| func cancelCurrentTask() { | ||
| currentTask?.cancel() | ||
| currentTask = nil | ||
| } | ||
| } | ||
|
|
||
| func makeCoordinator() -> Coordinator { | ||
| Coordinator() | ||
| } | ||
|
|
||
| func makeUIView(context: Context) -> FLAnimatedImageView { | ||
| let imageView = FLAnimatedImageView() | ||
| imageView.contentMode = .scaleAspectFit | ||
| // Use nearest neighbor scaling to preserve pixelated aesthetic | ||
| imageView.layer.magnificationFilter = .nearest | ||
| imageView.layer.minificationFilter = .nearest | ||
| return imageView | ||
| } | ||
|
|
||
| func updateUIView(_ uiView: FLAnimatedImageView, context: Context) { | ||
| // Check if we need to load this image (either first time or different image) | ||
| let needsLoad = context.coordinator.currentImageID != imageID || | ||
| (uiView.animatedImage == nil && uiView.image == nil && context.coordinator.currentTask == nil) | ||
|
|
||
| if needsLoad { | ||
| // Cancel any existing task if loading a different image | ||
| if context.coordinator.currentImageID != imageID { | ||
| context.coordinator.cancelCurrentTask() | ||
| } | ||
| context.coordinator.currentImageID = imageID | ||
|
|
||
| // Clear the current images immediately | ||
| uiView.animatedImage = nil | ||
| uiView.image = nil | ||
|
|
||
| // Store weak reference to avoid retain cycles | ||
| let weakView = Weak(uiView) | ||
|
|
||
| // Load the new animated image asynchronously | ||
| let task = Task { | ||
| // Check cache first | ||
| var animatedImage = AnimatedImageCache.shared.image(for: imageID) | ||
|
|
||
| // If not in cache, load it | ||
| if animatedImage == nil { | ||
| animatedImage = await Task.detached(priority: .userInitiated) { | ||
| if let image = FLAnimatedImage(animatedGIFData: data) { | ||
| AnimatedImageCache.shared.setImage(image, for: imageID) | ||
| return image | ||
| } | ||
| return nil | ||
| }.value | ||
| } | ||
|
|
||
| // Only update if this task hasn't been cancelled and we're still showing the same image | ||
| if !Task.isCancelled && context.coordinator.currentImageID == imageID { | ||
| await MainActor.run { | ||
| // Double-check we're still showing the same image after switching to main actor | ||
| guard context.coordinator.currentImageID == imageID else { | ||
| return | ||
| } | ||
|
|
||
| // Ensure the view is still valid | ||
| guard let strongView = weakView.value else { | ||
| return | ||
| } | ||
|
|
||
| if let animatedImage = animatedImage { | ||
| let frameCount = animatedImage.frameCount | ||
| strongView.animatedImage = animatedImage | ||
|
|
||
| // For single-frame GIFs, FLAnimatedImageView might not display properly | ||
| // So we also set the static image | ||
| if frameCount == 1, let staticImage = UIImage(data: data) { | ||
| strongView.image = staticImage | ||
| } else { | ||
| strongView.image = nil | ||
| // Start animating if needed | ||
| if !strongView.isAnimating && frameCount > 1 { | ||
| strongView.startAnimating() | ||
| } | ||
| } | ||
|
|
||
| // Force layout update | ||
| strongView.setNeedsLayout() | ||
| strongView.setNeedsDisplay() | ||
| } else { | ||
| // Log failure but keep the view empty rather than showing an error | ||
| print("AnimatedImageView: Failed to create FLAnimatedImage for \(imageID)") | ||
| } | ||
| } | ||
| } | ||
| } | ||
| context.coordinator.currentTask = task | ||
| } | ||
| } | ||
|
|
||
| static func dismantleUIView(_ uiView: FLAnimatedImageView, coordinator: Coordinator) { | ||
| coordinator.cancelCurrentTask() | ||
| uiView.stopAnimating() | ||
| uiView.animatedImage = nil | ||
| uiView.image = nil | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| // SmilieData.swift | ||
| // | ||
| // Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app | ||
|
|
||
| import Foundation | ||
| import CoreData | ||
| import Smilies | ||
|
|
||
| /// Thread-safe representation of a Smilie for use in SwiftUI views | ||
|
||
| struct SmilieData: Identifiable, Hashable { | ||
| let id: NSManagedObjectID | ||
| let text: String | ||
| let imageData: Data? | ||
| let imageUTI: String? | ||
| let section: String? | ||
| let summary: String? | ||
|
|
||
| init(from smilie: Smilie) { | ||
| self.id = smilie.objectID | ||
| self.text = smilie.text | ||
| self.imageData = smilie.imageData | ||
| self.imageUTI = smilie.imageUTI | ||
| self.section = smilie.section | ||
| self.summary = smilie.summary | ||
| } | ||
|
|
||
| func hash(into hasher: inout Hasher) { | ||
| hasher.combine(text) | ||
| } | ||
|
|
||
| static func == (lhs: SmilieData, rhs: SmilieData) -> Bool { | ||
| lhs.text == rhs.text | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.