Working LottieAnimations with Scaling and stuff

This commit is contained in:
Hariharan Mudaliar 2025-10-11 12:41:47 +05:30
commit e500f5a5b2
13 changed files with 1451 additions and 1 deletions

View file

@ -0,0 +1,74 @@
//
// IdleAnimationView.swift
// DynamicIsland
//
// Created by AI Assistant on 11/10/2025.
// Unified view for displaying idle animations (Lottie or built-in face)
//
import SwiftUI
import Lottie
import LottieUI
import Defaults
struct IdleAnimationView: View {
@Default(.selectedIdleAnimation) var selectedAnimation
var body: some View {
Group {
if let animation = selectedAnimation {
AnimationContentView(animation: animation)
} else {
// Fallback to original face if nothing selected
MinimalFaceFeatures(height: 20, width: 30)
}
}
}
}
/// Internal view that renders the actual animation content
private struct AnimationContentView: View {
let animation: CustomIdleAnimation
var body: some View {
let config = animation.transformConfig
switch animation.source {
case .lottieFile(let url):
LottieView(state: LUStateData(
type: .loadedFrom(url),
speed: animation.speed,
loopMode: .loop
))
.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)
case .lottieURL(let url):
LottieView(state: LUStateData(
type: .loadedFrom(url),
speed: animation.speed,
loopMode: .loop
))
.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)
case .builtInFace:
MinimalFaceFeatures(height: 20, width: 30)
}
}
}
// MARK: - Preview
#Preview {
ZStack {
Color.black
IdleAnimationView()
}
.frame(width: 100, height: 50)
}

View file

@ -0,0 +1,427 @@
//
// AnimationEditorView.swift
// DynamicIsland
//
// Created by AI Assistant on 11/10/2025.
// Editor for resizing, repositioning, and cropping Lottie animations
//
import SwiftUI
import LottieUI
import Defaults
struct AnimationEditorView: View {
@Environment(\.dismiss) var dismiss
let sourceURL: URL
let isRemoteURL: Bool
@Binding var animation: CustomIdleAnimation?
@State private var name: String
@State private var speed: CGFloat = 1.0
@State private var scale: CGFloat = 1.0
@State private var offsetX: CGFloat = 0
@State private var offsetY: CGFloat = 0
@State private var cropWidth: CGFloat = 30
@State private var cropHeight: CGFloat = 20
@State private var rotation: CGFloat = 0
@State private var opacity: CGFloat = 1.0
@State private var showingError = false
@State private var errorMessage = ""
@State private var isImporting = false
// Preview state
@State private var previewScale: CGFloat = 1.0
init(sourceURL: URL, isRemoteURL: Bool, animation: Binding<CustomIdleAnimation?>) {
self.sourceURL = sourceURL
self.isRemoteURL = isRemoteURL
self._animation = animation
// Initialize name from URL
let fileName = sourceURL.deletingPathExtension().lastPathComponent
_name = State(initialValue: fileName)
}
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Customize Animation")
.font(.title2)
.fontWeight(.semibold)
Spacer()
Button("Cancel") {
dismiss()
}
.keyboardShortcut(.cancelAction)
}
.padding()
Divider()
HSplitView {
// Preview Panel
VStack {
Text("Preview")
.font(.headline)
.padding(.top)
ZStack {
// Background to show bounds
RoundedRectangle(cornerRadius: 8)
.fill(Color(nsColor: .controlBackgroundColor))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.gray.opacity(0.3), lineWidth: 1)
)
// Target size indicator (30x20 - actual size in notch)
Rectangle()
.strokeBorder(Color.blue.opacity(0.5), style: StrokeStyle(lineWidth: 2, dash: [5, 3]))
.frame(width: 30 * previewScale, height: 20 * previewScale)
// Animation preview with transformations
LottieView(state: LUStateData(
type: .loadedFrom(sourceURL),
speed: speed,
loopMode: .loop
))
.frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale)
.offset(x: offsetX * previewScale, y: offsetY * previewScale)
.rotationEffect(.degrees(rotation))
.opacity(opacity)
.clipped()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
// Preview Scale Control
HStack {
Text("Preview Zoom:")
.font(.caption)
.foregroundStyle(.secondary)
Slider(value: $previewScale, in: 1...10)
.frame(width: 150)
Text("\(Int(previewScale))x")
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 30)
}
.padding(.horizontal)
.padding(.bottom)
}
.frame(minWidth: 300)
Divider()
// Controls Panel
ScrollView {
VStack(alignment: .leading, spacing: 20) {
// Name
VStack(alignment: .leading, spacing: 8) {
Text("Animation Name")
.font(.headline)
TextField("Name", text: $name)
.textFieldStyle(.roundedBorder)
}
Divider()
// Size Controls
VStack(alignment: .leading, spacing: 12) {
Text("Size & Scale")
.font(.headline)
// Scale
HStack {
Text("Scale:")
.frame(width: 80, alignment: .leading)
Slider(value: $scale, in: 0.1...5.0)
Text(String(format: "%.2f", scale))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("Reset") {
withAnimation { scale = 1.0 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
// Crop Width
HStack {
Text("Width:")
.frame(width: 80, alignment: .leading)
Slider(value: $cropWidth, in: 5...100)
Text(String(format: "%.0f", cropWidth))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("30") {
withAnimation { cropWidth = 30 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
// Crop Height
HStack {
Text("Height:")
.frame(width: 80, alignment: .leading)
Slider(value: $cropHeight, in: 5...100)
Text(String(format: "%.0f", cropHeight))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("20") {
withAnimation { cropHeight = 20 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
Divider()
// Position Controls
VStack(alignment: .leading, spacing: 12) {
Text("Position")
.font(.headline)
// Offset X
HStack {
Text("Horizontal:")
.frame(width: 80, alignment: .leading)
Slider(value: $offsetX, in: -50...50)
Text(String(format: "%.1f", offsetX))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("Center") {
withAnimation { offsetX = 0 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
// Offset Y
HStack {
Text("Vertical:")
.frame(width: 80, alignment: .leading)
Slider(value: $offsetY, in: -50...50)
Text(String(format: "%.1f", offsetY))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("Center") {
withAnimation { offsetY = 0 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
Divider()
// Rotation & Opacity
VStack(alignment: .leading, spacing: 12) {
Text("Transform")
.font(.headline)
// Rotation
HStack {
Text("Rotation:")
.frame(width: 80, alignment: .leading)
Slider(value: $rotation, in: -180...180)
Text(String(format: "%.0f°", rotation))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("Reset") {
withAnimation { rotation = 0 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
// Opacity
HStack {
Text("Opacity:")
.frame(width: 80, alignment: .leading)
Slider(value: $opacity, in: 0...1)
Text(String(format: "%.0f%%", opacity * 100))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("100%") {
withAnimation { opacity = 1.0 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
Divider()
// Animation Speed
VStack(alignment: .leading, spacing: 12) {
Text("Animation")
.font(.headline)
HStack {
Text("Speed:")
.frame(width: 80, alignment: .leading)
Slider(value: $speed, in: 0.1...3.0)
Text(String(format: "%.2fx", speed))
.frame(width: 50, alignment: .trailing)
.foregroundStyle(.secondary)
Button("1x") {
withAnimation { speed = 1.0 }
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
Divider()
// Presets
VStack(alignment: .leading, spacing: 12) {
Text("Quick Presets")
.font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
Button("Fit to Notch") {
withAnimation {
scale = 1.0
cropWidth = 30
cropHeight = 20
offsetX = 0
offsetY = 0
rotation = 0
}
}
.buttonStyle(.bordered)
Button("Fill Notch") {
withAnimation {
scale = 1.5
cropWidth = 30
cropHeight = 20
offsetX = 0
offsetY = 0
}
}
.buttonStyle(.bordered)
Button("Scale Up 2x") {
withAnimation {
scale = 2.0
}
}
.buttonStyle(.bordered)
Button("Scale Down") {
withAnimation {
scale = 0.75
}
}
.buttonStyle(.bordered)
Button("Reset All") {
withAnimation {
scale = 1.0
cropWidth = 30
cropHeight = 20
offsetX = 0
offsetY = 0
rotation = 0
opacity = 1.0
speed = 1.0
}
}
.buttonStyle(.bordered)
.tint(.red)
}
}
}
.padding()
}
.frame(minWidth: 350, maxWidth: 450)
}
Divider()
// Footer with actions
HStack {
HStack(spacing: 4) {
Image(systemName: "info.circle")
.foregroundStyle(.secondary)
Text("Blue dashed box shows actual size in Dynamic Island")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Import") {
importAnimation()
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(name.isEmpty || isImporting)
}
.padding()
}
.frame(minWidth: 800, minHeight: 600)
.alert("Import Error", isPresented: $showingError) {
Button("OK") { }
} message: {
Text(errorMessage)
}
}
// MARK: - Import Logic
private func importAnimation() {
guard !name.isEmpty else { return }
isImporting = true
// Create animation config with transform settings
let config = AnimationTransformConfig(
scale: scale,
offsetX: offsetX,
offsetY: offsetY,
cropWidth: cropWidth,
cropHeight: cropHeight,
rotation: rotation,
opacity: opacity
)
// Import with config
let result = IdleAnimationManager.shared.importLottieFile(
from: sourceURL,
name: name,
speed: speed,
transformConfig: config
)
switch result {
case .success(let importedAnimation):
animation = importedAnimation
dismiss()
case .failure(let error):
errorMessage = error.localizedDescription
showingError = true
isImporting = false
}
}
}
// MARK: - Preview
#Preview {
AnimationEditorView(
sourceURL: URL(string: "https://assets9.lottiefiles.com/packages/lf20_mniampqn.json")!,
isRemoteURL: true,
animation: .constant(nil)
)
}

View file

@ -0,0 +1,417 @@
//
// IdleAnimationsSettingsSection.swift
// DynamicIsland
//
// Created by AI Assistant on 11/10/2025.
// Settings section for custom idle animations
//
import SwiftUI
import Defaults
import LottieUI
import UniformTypeIdentifiers
struct IdleAnimationsSettingsSection: View {
@Default(.customIdleAnimations) var customIdleAnimations
@Default(.selectedIdleAnimation) var selectedIdleAnimation
@Default(.showNotHumanFace) var showNotHumanFace
@State private var showingFilePicker = false
@State private var showingURLSheet = false
@State private var urlInput = ""
@State private var nameInput = ""
@State private var selectedForDeletion: CustomIdleAnimation?
@State private var showingDeleteAlert = false
@State private var importError: String?
@State private var showingError = false
// Animation editor state
@State private var showingEditor = false
@State private var editorSourceURL: URL?
@State private var editorIsRemote = false
@State private var editedAnimation: CustomIdleAnimation?
var body: some View {
Section {
if showNotHumanFace {
// Horizontal scrollable grid of animations
ScrollView(.horizontal, showsIndicators: false) {
LazyHStack(spacing: 16) {
ForEach(customIdleAnimations) { animation in
AnimationPreviewCard(
animation: animation,
isSelected: selectedIdleAnimation?.id == animation.id,
onSelect: {
withAnimation(.easeInOut(duration: 0.2)) {
selectedIdleAnimation = animation
}
},
onDelete: animation.isBuiltIn ? nil : {
selectedForDeletion = animation
showingDeleteAlert = true
}
)
}
// Add animation button
AddAnimationCard {
showingFilePicker = true
} onAddURL: {
urlInput = ""
nameInput = ""
showingURLSheet = true
}
}
.padding(.vertical, 12)
.padding(.horizontal, 4)
}
.frame(height: 140)
} else {
Text("Enable \"Show cool face animation while inactivity\" to customize animations")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.vertical, 8)
}
} header: {
HStack {
Text("Idle Animation Style")
Spacer()
if showNotHumanFace {
Text("\(customIdleAnimations.count) animations")
.font(.caption)
.foregroundStyle(.secondary)
}
}
} footer: {
if showNotHumanFace {
Text("Choose animation to display when Dynamic Island is idle. Tap to select, hold to delete custom animations.")
}
}
.fileImporter(
isPresented: $showingFilePicker,
allowedContentTypes: [UTType.json],
allowsMultipleSelection: false
) { result in
handleFileImport(result)
}
.sheet(isPresented: $showingURLSheet) {
URLImportSheet(
urlInput: $urlInput,
nameInput: $nameInput,
onImport: { handleURLImport() },
onCancel: { showingURLSheet = false }
)
}
.sheet(isPresented: $showingEditor) {
if let url = editorSourceURL {
AnimationEditorView(
sourceURL: url,
isRemoteURL: editorIsRemote,
animation: $editedAnimation
)
.frame(minWidth: 800, minHeight: 600)
.onChange(of: editedAnimation) { oldValue, newValue in
if let animation = newValue {
// Animation was successfully imported via editor
withAnimation {
selectedIdleAnimation = animation
}
showingEditor = false
// Reset editor state
editorSourceURL = nil
editedAnimation = nil
}
}
}
}
.alert("Delete Animation", isPresented: $showingDeleteAlert) {
Button("Cancel", role: .cancel) { }
Button("Delete", role: .destructive) {
if let animation = selectedForDeletion {
IdleAnimationManager.shared.deleteAnimation(animation)
}
}
} message: {
if let animation = selectedForDeletion {
Text("Are you sure you want to delete \"\(animation.name)\"?")
}
}
.alert("Import Error", isPresented: $showingError) {
Button("OK") { }
} message: {
Text(importError ?? "Unknown error occurred")
}
}
// MARK: - Import Handlers
private func handleFileImport(_ result: Result<[URL], Error>) {
switch result {
case .success(let urls):
guard let url = urls.first else { return }
// Show editor instead of directly importing
editorSourceURL = url
editorIsRemote = false
editedAnimation = nil
showingEditor = true
case .failure(let error):
importError = error.localizedDescription
showingError = true
}
}
private func handleURLImport() {
guard let url = URL(string: urlInput), !nameInput.isEmpty else {
importError = "Please provide both a valid URL and name"
showingError = true
return
}
// Show editor instead of directly importing
editorSourceURL = url
editorIsRemote = true
editedAnimation = nil
showingURLSheet = false
showingEditor = true
}
}
// MARK: - Animation Preview Card
struct AnimationPreviewCard: View {
let animation: CustomIdleAnimation
let isSelected: Bool
let onSelect: () -> Void
let onDelete: (() -> Void)?
@State private var isHovering = false
var body: some View {
VStack(spacing: 8) {
// Preview area
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color(nsColor: .controlBackgroundColor))
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
isSelected ? Color.accentColor : Color.clear,
lineWidth: 2.5
)
)
// Animation preview
AnimationPreview(animation: animation)
.frame(width: 60, height: 40)
// Delete button (only for custom animations)
if let onDelete = onDelete, isHovering {
VStack {
HStack {
Spacer()
Button {
onDelete()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.white, .red)
.font(.system(size: 20))
}
.buttonStyle(.plain)
.padding(4)
}
Spacer()
}
}
}
.frame(width: 100, height: 80)
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = hovering
}
}
// Name and badge
VStack(spacing: 4) {
Text(animation.name)
.font(.caption)
.fontWeight(.medium)
.lineLimit(2)
.multilineTextAlignment(.center)
.frame(width: 100)
if isSelected {
Text("Selected")
.font(.system(size: 10))
.fontWeight(.medium)
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(
Capsule()
.fill(Color.accentColor)
)
} else if animation.isBuiltIn {
Text("Built-in")
.font(.system(size: 10))
.foregroundStyle(.secondary)
}
}
}
.contentShape(Rectangle())
.onTapGesture {
onSelect()
}
}
}
// MARK: - Animation Preview
struct AnimationPreview: View {
let animation: CustomIdleAnimation
var body: some View {
switch animation.source {
case .lottieFile(let url):
LottieView(state: LUStateData(
type: .loadedFrom(url),
speed: animation.speed,
loopMode: .loop
))
.frame(width: 60, height: 40)
case .lottieURL(let url):
LottieView(state: LUStateData(
type: .loadedFrom(url),
speed: animation.speed,
loopMode: .loop
))
.frame(width: 60, height: 40)
case .builtInFace:
MinimalFaceFeatures(height: 40, width: 60)
.scaleEffect(2.0) // Scale up the face for preview
}
}
}
// MARK: - Add Animation Card
struct AddAnimationCard: View {
let onAddFile: () -> Void
let onAddURL: () -> Void
@State private var showingMenu = false
var body: some View {
VStack(spacing: 8) {
Button {
showingMenu.toggle()
} label: {
ZStack {
RoundedRectangle(cornerRadius: 12)
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [5, 3]))
.fill(Color(nsColor: .controlBackgroundColor))
.foregroundStyle(.secondary)
VStack(spacing: 4) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 28))
.foregroundStyle(.secondary)
Text("Add")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.frame(width: 100, height: 80)
}
.buttonStyle(.plain)
.popover(isPresented: $showingMenu, arrowEdge: .bottom) {
VStack(alignment: .leading, spacing: 0) {
Button {
showingMenu = false
onAddFile()
} label: {
Label("Import File...", systemImage: "doc.badge.plus")
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.padding(8)
Divider()
Button {
showingMenu = false
onAddURL()
} label: {
Label("Add from URL...", systemImage: "link.badge.plus")
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.padding(8)
}
.frame(width: 180)
}
Text("Add New")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
.frame(width: 100)
}
}
}
// MARK: - URL Import Sheet
struct URLImportSheet: View {
@Binding var urlInput: String
@Binding var nameInput: String
let onImport: () -> Void
let onCancel: () -> Void
var body: some View {
VStack(spacing: 20) {
Text("Add Animation from URL")
.font(.headline)
VStack(alignment: .leading, spacing: 8) {
Text("Lottie JSON URL")
.font(.caption)
.foregroundStyle(.secondary)
TextField("https://example.com/animation.json", text: $urlInput)
.textFieldStyle(.roundedBorder)
}
VStack(alignment: .leading, spacing: 8) {
Text("Animation Name")
.font(.caption)
.foregroundStyle(.secondary)
TextField("My Animation", text: $nameInput)
.textFieldStyle(.roundedBorder)
}
HStack {
Button("Cancel") {
onCancel()
}
.keyboardShortcut(.cancelAction)
Spacer()
Button("Add") {
onImport()
}
.keyboardShortcut(.defaultAction)
.disabled(urlInput.isEmpty || nameInput.isEmpty)
}
}
.padding(24)
.frame(width: 400)
}
}
// MARK: - Preview
#Preview {
Form {
IdleAnimationsSettingsSection()
}
.frame(width: 600, height: 400)
}

View file

@ -1227,6 +1227,9 @@ struct Appearance: View {
Text("Additional features")
}
}
// MARK: - Custom Idle Animations Section
IdleAnimationsSettingsSection()
Section {
HStack {