mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Working Animations
This commit is contained in:
parent
56cb69d856
commit
b7959b78fc
6 changed files with 126 additions and 69 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -81,4 +81,5 @@ PrivateWorks
|
||||||
*.py
|
*.py
|
||||||
*.sh
|
*.sh
|
||||||
comparison_report.json
|
comparison_report.json
|
||||||
*.txt
|
*.txt
|
||||||
|
ANIMATION_DEBUG_GUIDE.md
|
||||||
|
|
@ -13,12 +13,13 @@ import Defaults
|
||||||
|
|
||||||
struct IdleAnimationView: View {
|
struct IdleAnimationView: View {
|
||||||
@Default(.selectedIdleAnimation) var selectedAnimation
|
@Default(.selectedIdleAnimation) var selectedAnimation
|
||||||
|
@Default(.animationTransformOverrides) var overrides
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if let animation = selectedAnimation {
|
if let animation = selectedAnimation {
|
||||||
AnimationContentView(animation: animation)
|
AnimationContentView(animation: animation)
|
||||||
.id("\(animation.id)-\(animation.hashValue)") // Force complete recreation when ANY property changes
|
.id("\(animation.id)-\(overrides[animation.id.uuidString]?.hashValue ?? 0)") // Force recreation when override changes
|
||||||
} else {
|
} else {
|
||||||
// Fallback to original face if nothing selected
|
// Fallback to original face if nothing selected
|
||||||
MinimalFaceFeatures(height: 20, width: 30)
|
MinimalFaceFeatures(height: 20, width: 30)
|
||||||
|
|
@ -32,7 +33,11 @@ private struct AnimationContentView: View {
|
||||||
let animation: CustomIdleAnimation
|
let animation: CustomIdleAnimation
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let config = animation.transformConfig
|
let config = animation.getTransformConfig()
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
let _ = print("🎨 [IdleAnimationView] Rendering animation: \(animation.name)")
|
||||||
|
let _ = print("🎨 [IdleAnimationView] Config: scale=\(config.scale), offset=(\(config.offsetX), \(config.offsetY)), opacity=\(config.opacity)")
|
||||||
|
|
||||||
switch animation.source {
|
switch animation.source {
|
||||||
case .lottieFile(let url):
|
case .lottieFile(let url):
|
||||||
|
|
@ -72,7 +77,18 @@ private struct AnimationContentView: View {
|
||||||
.clipped()
|
.clipped()
|
||||||
|
|
||||||
case .builtInFace:
|
case .builtInFace:
|
||||||
|
// Apply transforms to built-in face too!
|
||||||
MinimalFaceFeatures(height: 20, width: 30)
|
MinimalFaceFeatures(height: 20, width: 30)
|
||||||
|
.frame(
|
||||||
|
width: config.cropWidth * config.scale,
|
||||||
|
height: config.cropHeight * config.scale
|
||||||
|
)
|
||||||
|
.offset(x: config.offsetX, y: config.offsetY)
|
||||||
|
.rotationEffect(.degrees(config.rotation))
|
||||||
|
.opacity(config.opacity)
|
||||||
|
.padding(.bottom, config.paddingBottom)
|
||||||
|
.frame(width: config.expandWithAnimation ? nil : 30, height: 20)
|
||||||
|
.clipped()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,9 @@ struct AnimationEditorView: View {
|
||||||
if let existing = existingAnimation {
|
if let existing = existingAnimation {
|
||||||
_name = State(initialValue: existing.name)
|
_name = State(initialValue: existing.name)
|
||||||
_speed = State(initialValue: existing.speed)
|
_speed = State(initialValue: existing.speed)
|
||||||
let config = existing.transformConfig
|
|
||||||
|
// Load from overrides if they exist
|
||||||
|
let config = Defaults[.animationTransformOverrides][existing.id.uuidString] ?? .default
|
||||||
_scale = State(initialValue: config.scale)
|
_scale = State(initialValue: config.scale)
|
||||||
_offsetX = State(initialValue: config.offsetX)
|
_offsetX = State(initialValue: config.offsetX)
|
||||||
_offsetY = State(initialValue: config.offsetY)
|
_offsetY = State(initialValue: config.offsetY)
|
||||||
|
|
@ -106,17 +108,30 @@ struct AnimationEditorView: View {
|
||||||
.frame(width: 30 * previewScale, height: 20 * previewScale)
|
.frame(width: 30 * previewScale, height: 20 * previewScale)
|
||||||
|
|
||||||
// Animation preview with transformations
|
// Animation preview with transformations
|
||||||
LottieView(state: LUStateData(
|
if let existing = existingAnimation, case .builtInFace = existing.source {
|
||||||
type: .loadedFrom(sourceURL),
|
// Show built-in face
|
||||||
speed: speed,
|
MinimalFaceFeatures(height: 20, width: 30)
|
||||||
loopMode: loopMode.lottieLoopMode
|
.scaleEffect(previewScale)
|
||||||
))
|
.frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale)
|
||||||
.frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale)
|
.offset(x: offsetX * previewScale, y: offsetY * previewScale)
|
||||||
.offset(x: offsetX * previewScale, y: offsetY * previewScale)
|
.rotationEffect(.degrees(rotation))
|
||||||
.rotationEffect(.degrees(rotation))
|
.opacity(opacity)
|
||||||
.opacity(opacity)
|
.padding(.bottom, paddingBottom * previewScale)
|
||||||
.padding(.bottom, paddingBottom * previewScale)
|
.clipped()
|
||||||
.clipped()
|
} else {
|
||||||
|
// Show Lottie animation
|
||||||
|
LottieView(state: LUStateData(
|
||||||
|
type: .loadedFrom(sourceURL),
|
||||||
|
speed: speed,
|
||||||
|
loopMode: loopMode.lottieLoopMode
|
||||||
|
))
|
||||||
|
.frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale)
|
||||||
|
.offset(x: offsetX * previewScale, y: offsetY * previewScale)
|
||||||
|
.rotationEffect(.degrees(rotation))
|
||||||
|
.opacity(opacity)
|
||||||
|
.padding(.bottom, paddingBottom * previewScale)
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.padding()
|
.padding()
|
||||||
|
|
@ -475,18 +490,26 @@ struct AnimationEditorView: View {
|
||||||
loopMode: loopMode
|
loopMode: loopMode
|
||||||
)
|
)
|
||||||
|
|
||||||
// If editing existing animation, create a NEW animation object with updated values
|
// If editing existing animation, save transform config to overrides
|
||||||
if let existing = existingAnimation {
|
if let existing = existingAnimation {
|
||||||
// Create completely new animation to force view refresh
|
// Store only the transform config override
|
||||||
let updatedAnimation = CustomIdleAnimation(
|
var overrides = Defaults[.animationTransformOverrides]
|
||||||
id: existing.id, // Keep same ID so it replaces the right one
|
overrides[existing.id.uuidString] = config
|
||||||
name: name,
|
Defaults[.animationTransformOverrides] = overrides
|
||||||
source: existing.source,
|
|
||||||
speed: speed,
|
print("✅ [AnimationEditor] Saved transform override for: \(existing.name)")
|
||||||
isBuiltIn: existing.isBuiltIn,
|
print("✅ [AnimationEditor] Override: \(config)")
|
||||||
transformConfig: config
|
|
||||||
)
|
// Force view refresh by updating selectedIdleAnimation if this is the selected one
|
||||||
animation = updatedAnimation
|
if Defaults[.selectedIdleAnimation]?.id == existing.id {
|
||||||
|
// Trigger refresh by re-setting the same animation
|
||||||
|
let current = Defaults[.selectedIdleAnimation]
|
||||||
|
Defaults[.selectedIdleAnimation] = nil
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||||
|
Defaults[.selectedIdleAnimation] = current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dismiss()
|
dismiss()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -495,8 +518,7 @@ struct AnimationEditorView: View {
|
||||||
let result = IdleAnimationManager.shared.importLottieFile(
|
let result = IdleAnimationManager.shared.importLottieFile(
|
||||||
from: sourceURL,
|
from: sourceURL,
|
||||||
name: name,
|
name: name,
|
||||||
speed: speed,
|
speed: speed
|
||||||
transformConfig: config
|
|
||||||
)
|
)
|
||||||
|
|
||||||
switch result {
|
switch result {
|
||||||
|
|
|
||||||
|
|
@ -51,16 +51,31 @@ struct IdleAnimationsSettingsSection: View {
|
||||||
selectedForDeletion = animation
|
selectedForDeletion = animation
|
||||||
showingDeleteAlert = true
|
showingDeleteAlert = true
|
||||||
},
|
},
|
||||||
onEdit: animation.isBuiltIn ? nil : {
|
onEdit: {
|
||||||
|
print("🔧 [Edit] Attempting to edit animation: \(animation.name)")
|
||||||
|
print("🔧 [Edit] Animation source: \(animation.source)")
|
||||||
editingExistingAnimation = animation
|
editingExistingAnimation = animation
|
||||||
if case .lottieFile(let url) = animation.source {
|
|
||||||
|
switch animation.source {
|
||||||
|
case .lottieFile(let url):
|
||||||
|
print("🔧 [Edit] Lottie file URL: \(url)")
|
||||||
|
print("🔧 [Edit] File exists: \(FileManager.default.fileExists(atPath: url.path))")
|
||||||
editorSourceURL = url
|
editorSourceURL = url
|
||||||
editorIsRemote = false
|
editorIsRemote = false
|
||||||
showingEditor = true
|
showingEditor = true
|
||||||
} else if case .lottieURL(let url) = animation.source {
|
|
||||||
|
case .lottieURL(let url):
|
||||||
|
print("🔧 [Edit] Lottie URL: \(url)")
|
||||||
editorSourceURL = url
|
editorSourceURL = url
|
||||||
editorIsRemote = true
|
editorIsRemote = true
|
||||||
showingEditor = true
|
showingEditor = true
|
||||||
|
|
||||||
|
case .builtInFace:
|
||||||
|
print("🔧 [Edit] Built-in face - using dummy URL")
|
||||||
|
// Use a dummy URL for built-in face (editor only needs URL for preview)
|
||||||
|
editorSourceURL = URL(string: "builtin://face")!
|
||||||
|
editorIsRemote = false
|
||||||
|
showingEditor = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -77,39 +77,34 @@ class IdleAnimationManager {
|
||||||
|
|
||||||
/// Load animations from the LottieAnimations folder in the bundle
|
/// Load animations from the LottieAnimations folder in the bundle
|
||||||
private func loadBundledAnimations() -> [CustomIdleAnimation]? {
|
private func loadBundledAnimations() -> [CustomIdleAnimation]? {
|
||||||
guard let bundlePath = Bundle.main.resourcePath else {
|
print("📦 [IdleAnimationManager] Loading bundled animations...")
|
||||||
print("⚠️ [IdleAnimationManager] Could not find bundle resource path")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let lottieFolder = URL(fileURLWithPath: bundlePath).appendingPathComponent("LottieAnimations")
|
// The JSON files are added as individual resources, not in a folder
|
||||||
|
let bundledFiles = ["Dog waiting", "Moody Dog", "Orange Cat Peeping"]
|
||||||
|
var animations: [CustomIdleAnimation] = []
|
||||||
|
|
||||||
guard FileManager.default.fileExists(atPath: lottieFolder.path) else {
|
for filename in bundledFiles {
|
||||||
print("⚠️ [IdleAnimationManager] LottieAnimations folder not found at: \(lottieFolder.path)")
|
if let url = Bundle.main.url(forResource: filename, withExtension: "json") {
|
||||||
return nil
|
let animation = CustomIdleAnimation(
|
||||||
}
|
name: filename,
|
||||||
|
|
||||||
do {
|
|
||||||
let files = try FileManager.default.contentsOfDirectory(at: lottieFolder, includingPropertiesForKeys: nil)
|
|
||||||
let jsonFiles = files.filter { $0.pathExtension.lowercased() == "json" }
|
|
||||||
|
|
||||||
let animations = jsonFiles.map { url -> CustomIdleAnimation in
|
|
||||||
let name = url.deletingPathExtension().lastPathComponent
|
|
||||||
return CustomIdleAnimation(
|
|
||||||
name: name,
|
|
||||||
source: .lottieFile(url),
|
source: .lottieFile(url),
|
||||||
speed: 1.0,
|
speed: 1.0,
|
||||||
isBuiltIn: true
|
isBuiltIn: true
|
||||||
)
|
)
|
||||||
|
animations.append(animation)
|
||||||
|
print("✅ [IdleAnimationManager] Loaded bundled animation: \(filename)")
|
||||||
|
} else {
|
||||||
|
print("⚠️ [IdleAnimationManager] Could not find bundled animation: \(filename).json")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
print("📦 [IdleAnimationManager] Loaded \(animations.count) bundled animations")
|
|
||||||
return animations
|
guard !animations.isEmpty else {
|
||||||
|
print("⚠️ [IdleAnimationManager] No bundled animations found")
|
||||||
} catch {
|
|
||||||
print("❌ [IdleAnimationManager] Error loading bundled animations: \(error)")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print("📦 [IdleAnimationManager] Loaded \(animations.count) bundled animations")
|
||||||
|
return animations
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - User Animations
|
// MARK: - User Animations
|
||||||
|
|
@ -144,20 +139,20 @@ class IdleAnimationManager {
|
||||||
// MARK: - Import & Export
|
// MARK: - Import & Export
|
||||||
|
|
||||||
/// Import a Lottie JSON file from URL (either local file or download from remote)
|
/// Import a Lottie JSON file from URL (either local file or download from remote)
|
||||||
func importLottieFile(from url: URL, name: String? = nil, speed: CGFloat = 1.0, transformConfig: AnimationTransformConfig = .default) -> Result<CustomIdleAnimation, Error> {
|
func importLottieFile(from url: URL, name: String? = nil, speed: CGFloat = 1.0) -> Result<CustomIdleAnimation, Error> {
|
||||||
let fileName = name ?? url.deletingPathExtension().lastPathComponent
|
let fileName = name ?? url.deletingPathExtension().lastPathComponent
|
||||||
|
|
||||||
// If it's a remote URL, download it first
|
// If it's a remote URL, download it first
|
||||||
if url.scheme == "http" || url.scheme == "https" {
|
if url.scheme == "http" || url.scheme == "https" {
|
||||||
return importRemoteAnimation(from: url, name: fileName, speed: speed, transformConfig: transformConfig)
|
return importRemoteAnimation(from: url, name: fileName, speed: speed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local file import
|
// Local file import
|
||||||
return importLocalFile(from: url, name: fileName, speed: speed, transformConfig: transformConfig)
|
return importLocalFile(from: url, name: fileName, speed: speed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Import a local Lottie JSON file
|
/// Import a local Lottie JSON file
|
||||||
private func importLocalFile(from sourceURL: URL, name: String, speed: CGFloat, transformConfig: AnimationTransformConfig) -> Result<CustomIdleAnimation, Error> {
|
private func importLocalFile(from sourceURL: URL, name: String, speed: CGFloat) -> Result<CustomIdleAnimation, Error> {
|
||||||
do {
|
do {
|
||||||
// Validate it's a JSON file
|
// Validate it's a JSON file
|
||||||
guard sourceURL.pathExtension.lowercased() == "json" else {
|
guard sourceURL.pathExtension.lowercased() == "json" else {
|
||||||
|
|
@ -177,13 +172,12 @@ class IdleAnimationManager {
|
||||||
// Copy file to storage
|
// Copy file to storage
|
||||||
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
|
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
|
||||||
|
|
||||||
// Create animation object
|
// Create animation object (transforms will be stored separately)
|
||||||
let animation = CustomIdleAnimation(
|
let animation = CustomIdleAnimation(
|
||||||
name: name,
|
name: name,
|
||||||
source: .lottieFile(destinationURL),
|
source: .lottieFile(destinationURL),
|
||||||
speed: speed,
|
speed: speed,
|
||||||
isBuiltIn: false,
|
isBuiltIn: false
|
||||||
transformConfig: transformConfig
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add to defaults
|
// Add to defaults
|
||||||
|
|
@ -201,7 +195,7 @@ class IdleAnimationManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Import animation from remote URL
|
/// Import animation from remote URL
|
||||||
private func importRemoteAnimation(from url: URL, name: String, speed: CGFloat, transformConfig: AnimationTransformConfig) -> Result<CustomIdleAnimation, Error> {
|
private func importRemoteAnimation(from url: URL, name: String, speed: CGFloat) -> Result<CustomIdleAnimation, Error> {
|
||||||
// For remote URLs, we store the URL directly (no download)
|
// For remote URLs, we store the URL directly (no download)
|
||||||
// The LottieView will handle downloading when needed
|
// The LottieView will handle downloading when needed
|
||||||
|
|
||||||
|
|
@ -209,8 +203,7 @@ class IdleAnimationManager {
|
||||||
name: name,
|
name: name,
|
||||||
source: .lottieURL(url),
|
source: .lottieURL(url),
|
||||||
speed: speed,
|
speed: speed,
|
||||||
isBuiltIn: false,
|
isBuiltIn: false
|
||||||
transformConfig: transformConfig
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add to defaults
|
// Add to defaults
|
||||||
|
|
|
||||||
|
|
@ -33,19 +33,28 @@ struct CustomIdleAnimation: Codable, Hashable, Equatable, Defaults.Serializable,
|
||||||
var source: AnimationSource
|
var source: AnimationSource
|
||||||
var speed: CGFloat = 1.0
|
var speed: CGFloat = 1.0
|
||||||
var isBuiltIn: Bool = false // Track if it's bundled vs user-added
|
var isBuiltIn: Bool = false // Track if it's bundled vs user-added
|
||||||
var transformConfig: AnimationTransformConfig = .default // Transform settings
|
|
||||||
|
|
||||||
init(id: UUID = UUID(), name: String, source: AnimationSource, speed: CGFloat = 1.0, isBuiltIn: Bool = false, transformConfig: AnimationTransformConfig = .default) {
|
init(id: UUID = UUID(), name: String, source: AnimationSource, speed: CGFloat = 1.0, isBuiltIn: Bool = false) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.source = source
|
self.source = source
|
||||||
self.speed = speed
|
self.speed = speed
|
||||||
self.isBuiltIn = isBuiltIn
|
self.isBuiltIn = isBuiltIn
|
||||||
self.transformConfig = transformConfig
|
}
|
||||||
|
|
||||||
|
/// Get the effective transform config (override or default)
|
||||||
|
func getTransformConfig() -> AnimationTransformConfig {
|
||||||
|
let override = Defaults[.animationTransformOverrides][id.uuidString]
|
||||||
|
if let override = override {
|
||||||
|
print("📋 [CustomIdleAnimation] Found override for '\(name)': \(override)")
|
||||||
|
} else {
|
||||||
|
print("📋 [CustomIdleAnimation] No override for '\(name)', using default")
|
||||||
|
}
|
||||||
|
return override ?? .default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AnimationTransformConfig: Codable, Hashable, Equatable {
|
struct AnimationTransformConfig: Codable, Hashable, Equatable, Defaults.Serializable {
|
||||||
var scale: CGFloat = 1.0
|
var scale: CGFloat = 1.0
|
||||||
var offsetX: CGFloat = 0
|
var offsetX: CGFloat = 0
|
||||||
var offsetY: CGFloat = 0
|
var offsetY: CGFloat = 0
|
||||||
|
|
@ -299,6 +308,7 @@ extension Defaults.Keys {
|
||||||
static let showNotHumanFace = Key<Bool>("showNotHumanFace", default: false)
|
static let showNotHumanFace = Key<Bool>("showNotHumanFace", default: false)
|
||||||
static let customIdleAnimations = Key<[CustomIdleAnimation]>("customIdleAnimations", default: [])
|
static let customIdleAnimations = Key<[CustomIdleAnimation]>("customIdleAnimations", default: [])
|
||||||
static let selectedIdleAnimation = Key<CustomIdleAnimation?>("selectedIdleAnimation", default: nil)
|
static let selectedIdleAnimation = Key<CustomIdleAnimation?>("selectedIdleAnimation", default: nil)
|
||||||
|
static let animationTransformOverrides = Key<[String: AnimationTransformConfig]>("animationTransformOverrides", default: [:])
|
||||||
static let tileShowLabels = Key<Bool>("tileShowLabels", default: false)
|
static let tileShowLabels = Key<Bool>("tileShowLabels", default: false)
|
||||||
static let showCalendar = Key<Bool>("showCalendar", default: false)
|
static let showCalendar = Key<Bool>("showCalendar", default: false)
|
||||||
static let hideCompletedReminders = Key<Bool>("hideCompletedReminders", default: true)
|
static let hideCompletedReminders = Key<Bool>("hideCompletedReminders", default: true)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue