Skip to content

Commit 40b6fb8

Browse files
authored
Smilie Picker (#1213)
* Implemented new smilie picker * Update CI to use Xcode 16.0 on macOS 15 - Match local development environment - Update to actions/checkout@v4 with recursive submodules - Use iPhone 16 simulator with iOS 18.0 * Add SmiliePicker files to Awful target Added SmiliePickerView, SmilieData, SmilieGridItem, SmilieSearchViewModel, and AnimatedImageView to the Awful target to fix CI compilation errors * Add Swift concurrency flag to CI tests Set SWIFT_STRICT_CONCURRENCY=minimal to match local development environment * Revert test.yml changes to match main branch * Addressing PR feedbach * Replace fake ellipsis with real one * remove premature smilie optimization * Fix bug where smiley picker wouldn't reload the theme * Remove *.backup from .gitignore * Replace manual theme setting with .themed() modifier to enable automatic theme switching * Removed SmilieData wrapper and refactored to use Smilie directly.
1 parent a314925 commit 40b6fb8

File tree

12 files changed

+1068
-8
lines changed

12 files changed

+1068
-8
lines changed

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
.DS_Store
1414
xcuserdata
1515
/Awful.xcworkspace/xcshareddata/Awful.xccheckout
16+
/DerivedData/
1617

1718
# Optional CSS compiler possible location
1819
/node_modules/
20+
21+
# Swift Package Manager
22+
/.build/
23+
/.swiftpm/
24+
/Package.resolved

App/Composition/ShowSmilieKeyboardCommand.swift

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44

55
import os
66
import Smilies
7+
import SwiftUI
78
import UIKit
9+
import AwfulSettings
10+
import AwfulTheming
811

912
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ShowSmilieKeyboardCommand")
1013

1114
final class ShowSmilieKeyboardCommand: NSObject {
1215
fileprivate let textView: UITextView
16+
private weak var presentingViewController: UIViewController?
1317

1418
init(textView: UITextView) {
1519
self.textView = textView
@@ -26,6 +30,14 @@ final class ShowSmilieKeyboardCommand: NSObject {
2630
fileprivate var showingSmilieKeyboard: Bool = false
2731

2832
func execute() {
33+
if UserDefaults.standard.defaultingValue(for: Settings.useNewSmiliePicker) {
34+
showNewSmiliePicker()
35+
} else {
36+
showLegacySmilieKeyboard()
37+
}
38+
}
39+
40+
private func showLegacySmilieKeyboard() {
2941
showingSmilieKeyboard = !showingSmilieKeyboard
3042

3143
if showingSmilieKeyboard && textView.inputView == nil {
@@ -41,6 +53,69 @@ final class ShowSmilieKeyboardCommand: NSObject {
4153
}
4254
}
4355

56+
private func showNewSmiliePicker() {
57+
guard var viewController = textView.window?.rootViewController else { return }
58+
59+
// Find the topmost presented view controller
60+
while let presented = viewController.presentedViewController {
61+
viewController = presented
62+
}
63+
64+
// Check if smilie picker is already being presented
65+
if viewController is UIHostingController<SmiliePickerView> {
66+
return
67+
}
68+
69+
// Dismiss keyboard before showing smilie picker
70+
textView.resignFirstResponder()
71+
72+
weak var weakTextView = textView
73+
let pickerView = SmiliePickerView(dataStore: smilieKeyboard.dataStore) { [weak self] smilie in
74+
self?.insertSmilie(smilie)
75+
// Delay keyboard reactivation to ensure smooth animation after sheet dismissal
76+
// Without this delay, the keyboard animation can conflict with sheet dismissal
77+
DispatchQueue.main.async {
78+
weakTextView?.becomeFirstResponder()
79+
}
80+
}
81+
.onDisappear {
82+
// Delay keyboard reactivation when view disappears (handles Done button case)
83+
// This ensures the sheet dismissal animation completes before keyboard appears
84+
DispatchQueue.main.async {
85+
weakTextView?.becomeFirstResponder()
86+
}
87+
}
88+
.themed()
89+
90+
let hostingController = UIHostingController(rootView: pickerView)
91+
hostingController.modalPresentationStyle = .pageSheet
92+
93+
if let sheet = hostingController.sheetPresentationController {
94+
sheet.detents = [.medium(), .large()]
95+
sheet.prefersGrabberVisible = true
96+
sheet.preferredCornerRadius = 20
97+
sheet.delegate = self
98+
}
99+
100+
presentingViewController = viewController
101+
viewController.present(hostingController, animated: true)
102+
}
103+
104+
private func insertSmilie(_ smilie: Smilie) {
105+
textView.insertText(smilie.text)
106+
justInsertedSmilieText = smilie.text
107+
108+
smilie.managedObjectContext?.perform {
109+
smilie.metadata.lastUsedDate = Date()
110+
do {
111+
try smilie.managedObjectContext!.save()
112+
}
113+
catch {
114+
logger.error("error saving: \(error)")
115+
}
116+
}
117+
}
118+
44119
fileprivate var justInsertedSmilieText: String?
45120
}
46121

@@ -84,3 +159,10 @@ extension ShowSmilieKeyboardCommand: SmilieKeyboardDelegate {
84159
textView.insertText(numberOrDecimal)
85160
}
86161
}
162+
163+
extension ShowSmilieKeyboardCommand: UISheetPresentationControllerDelegate {
164+
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
165+
// Reactivate keyboard after sheet dismissal (swipe down or Done button)
166+
textView.becomeFirstResponder()
167+
}
168+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// AnimatedImageView.swift
2+
//
3+
// Copyright 2025 Awful Contributors. CC BY-NC-SA 3.0 US https://github.com/Awful/Awful.app
4+
5+
import SwiftUI
6+
import FLAnimatedImage
7+
8+
// Helper class to store weak references
9+
private class Weak<T: AnyObject> {
10+
weak var value: T?
11+
init(_ value: T) {
12+
self.value = value
13+
}
14+
}
15+
16+
// Simple LRU cache for animated images
17+
private class AnimatedImageCache {
18+
static let shared = AnimatedImageCache()
19+
20+
private var cache = NSCache<NSString, FLAnimatedImage>()
21+
22+
init() {
23+
cache.countLimit = 50 // Cache up to 50 images
24+
cache.totalCostLimit = 50 * 1024 * 1024 // ~50MB
25+
}
26+
27+
func image(for key: String) -> FLAnimatedImage? {
28+
cache.object(forKey: key as NSString)
29+
}
30+
31+
func setImage(_ image: FLAnimatedImage, for key: String) {
32+
let cost = image.data?.count ?? 0
33+
cache.setObject(image, forKey: key as NSString, cost: cost)
34+
}
35+
}
36+
37+
/// SwiftUI wrapper for FLAnimatedImageView to display animated GIFs
38+
struct AnimatedImageView: UIViewRepresentable {
39+
let data: Data
40+
let imageID: String // Unique identifier for this image
41+
42+
class Coordinator {
43+
var currentTask: Task<Void, Never>?
44+
var currentImageID: String?
45+
46+
func cancelCurrentTask() {
47+
currentTask?.cancel()
48+
currentTask = nil
49+
}
50+
}
51+
52+
func makeCoordinator() -> Coordinator {
53+
Coordinator()
54+
}
55+
56+
func makeUIView(context: Context) -> FLAnimatedImageView {
57+
let imageView = FLAnimatedImageView()
58+
imageView.contentMode = .scaleAspectFit
59+
// Use nearest neighbor scaling to preserve pixelated aesthetic
60+
imageView.layer.magnificationFilter = .nearest
61+
imageView.layer.minificationFilter = .nearest
62+
return imageView
63+
}
64+
65+
func updateUIView(_ uiView: FLAnimatedImageView, context: Context) {
66+
// Check if we need to load this image (either first time or different image)
67+
let needsLoad = context.coordinator.currentImageID != imageID ||
68+
(uiView.animatedImage == nil && uiView.image == nil && context.coordinator.currentTask == nil)
69+
70+
if needsLoad {
71+
// Cancel any existing task if loading a different image
72+
if context.coordinator.currentImageID != imageID {
73+
context.coordinator.cancelCurrentTask()
74+
}
75+
context.coordinator.currentImageID = imageID
76+
77+
// Clear the current images immediately
78+
uiView.animatedImage = nil
79+
uiView.image = nil
80+
81+
// Store weak reference to avoid retain cycles
82+
let weakView = Weak(uiView)
83+
84+
// Load the new animated image asynchronously
85+
let task = Task {
86+
// Check cache first
87+
var animatedImage = AnimatedImageCache.shared.image(for: imageID)
88+
89+
// If not in cache, load it
90+
if animatedImage == nil {
91+
animatedImage = await Task.detached(priority: .userInitiated) {
92+
if let image = FLAnimatedImage(animatedGIFData: data) {
93+
AnimatedImageCache.shared.setImage(image, for: imageID)
94+
return image
95+
}
96+
return nil
97+
}.value
98+
}
99+
100+
// Only update if this task hasn't been cancelled and we're still showing the same image
101+
if !Task.isCancelled && context.coordinator.currentImageID == imageID {
102+
await MainActor.run {
103+
// Double-check we're still showing the same image after switching to main actor
104+
guard context.coordinator.currentImageID == imageID else {
105+
return
106+
}
107+
108+
// Ensure the view is still valid
109+
guard let strongView = weakView.value else {
110+
return
111+
}
112+
113+
if let animatedImage = animatedImage {
114+
let frameCount = animatedImage.frameCount
115+
strongView.animatedImage = animatedImage
116+
117+
// For single-frame GIFs, FLAnimatedImageView might not display properly
118+
// So we also set the static image
119+
if frameCount == 1, let staticImage = UIImage(data: data) {
120+
strongView.image = staticImage
121+
} else {
122+
strongView.image = nil
123+
// Start animating if needed
124+
if !strongView.isAnimating && frameCount > 1 {
125+
strongView.startAnimating()
126+
}
127+
}
128+
129+
// Force layout update
130+
strongView.setNeedsLayout()
131+
strongView.setNeedsDisplay()
132+
} else {
133+
// Log failure but keep the view empty rather than showing an error
134+
print("AnimatedImageView: Failed to create FLAnimatedImage for \(imageID)")
135+
}
136+
}
137+
}
138+
}
139+
context.coordinator.currentTask = task
140+
}
141+
}
142+
143+
static func dismantleUIView(_ uiView: FLAnimatedImageView, coordinator: Coordinator) {
144+
coordinator.cancelCurrentTask()
145+
uiView.stopAnimating()
146+
uiView.animatedImage = nil
147+
uiView.image = nil
148+
}
149+
}

0 commit comments

Comments
 (0)