mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Working Animation Lock Screen
This commit is contained in:
parent
68b912463c
commit
71e6be4218
9 changed files with 158 additions and 30 deletions
121
DynamicIsland/components/LockScreen/LockIconAnimationView.swift
Normal file
121
DynamicIsland/components/LockScreen/LockIconAnimationView.swift
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
//
|
||||
// LockIconAnimationView.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Shared animator and SwiftUI Lottie rendering for the lock/unlock sequence.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Lottie
|
||||
|
||||
@MainActor
|
||||
final class LockIconAnimator: ObservableObject {
|
||||
@Published private(set) var progress: CGFloat
|
||||
|
||||
private var animationTask: Task<Void, Never>?
|
||||
private let animationDuration: TimeInterval = 0.35
|
||||
private let animationSteps: Int = 48
|
||||
|
||||
init(initiallyLocked: Bool) {
|
||||
progress = initiallyLocked ? 1.0 : 0.0
|
||||
}
|
||||
|
||||
deinit {
|
||||
animationTask?.cancel()
|
||||
}
|
||||
|
||||
func update(isLocked: Bool, animated: Bool = true) {
|
||||
let target = isLocked ? 1.0 : 0.0
|
||||
let clampedTarget = max(0.0, min(1.0, target))
|
||||
|
||||
if !animated {
|
||||
animationTask?.cancel()
|
||||
progress = clampedTarget
|
||||
return
|
||||
}
|
||||
|
||||
guard abs(progress - clampedTarget) > 0.0005 else {
|
||||
progress = clampedTarget
|
||||
return
|
||||
}
|
||||
|
||||
animationTask?.cancel()
|
||||
|
||||
let startProgress = progress
|
||||
let delta = clampedTarget - startProgress
|
||||
let stepDuration = animationDuration / Double(animationSteps)
|
||||
let stepNanoseconds = UInt64(stepDuration * 1_000_000_000)
|
||||
|
||||
animationTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
for step in 0...animationSteps {
|
||||
if Task.isCancelled { return }
|
||||
|
||||
if step > 0 {
|
||||
try? await Task.sleep(nanoseconds: stepNanoseconds)
|
||||
}
|
||||
|
||||
let fraction = Double(step) / Double(animationSteps)
|
||||
let eased = easeOutCubic(fraction)
|
||||
progress = startProgress + CGFloat(eased) * CGFloat(delta)
|
||||
}
|
||||
|
||||
progress = clampedTarget
|
||||
}
|
||||
}
|
||||
|
||||
private func easeOutCubic(_ t: Double) -> Double {
|
||||
let clamped = max(0.0, min(1.0, t))
|
||||
return 1.0 - pow(1.0 - clamped, 3)
|
||||
}
|
||||
}
|
||||
|
||||
struct LockIconProgressView: View {
|
||||
var progress: CGFloat
|
||||
var iconColor: Color = .white
|
||||
|
||||
var body: some View {
|
||||
if LockIconLottieView.isAvailable {
|
||||
Rectangle()
|
||||
.fill(iconColor)
|
||||
.mask {
|
||||
LockIconLottieView(progress: 1 - progress)
|
||||
.scaleEffect(1.12)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: progress >= 0.5 ? "lock.fill" : "lock.open.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(iconColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LockIconLottieView: View {
|
||||
var progress: CGFloat
|
||||
|
||||
private static let animation: LottieAnimation? = {
|
||||
if let animation = LottieAnimation.named("Lock") {
|
||||
return animation
|
||||
} else {
|
||||
print("⚠️ [LockIconLottieView] Missing Lock.json animation – falling back to SF Symbols")
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
static var isAvailable: Bool {
|
||||
animation != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let animation = Self.animation {
|
||||
LottieView(animation: animation)
|
||||
.currentProgress(progress)
|
||||
.configuration(.init(renderingEngine: .mainThread))
|
||||
} else {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,30 +11,27 @@ import Defaults
|
|||
struct LockScreenLiveActivity: View {
|
||||
@EnvironmentObject var vm: DynamicIslandViewModel
|
||||
@ObservedObject private var lockScreenManager = LockScreenManager.shared
|
||||
@StateObject private var iconAnimator = LockIconAnimator(initiallyLocked: LockScreenManager.shared.isLocked)
|
||||
@State private var isHovering: Bool = false
|
||||
@State private var gestureProgress: CGFloat = 0
|
||||
@State private var isExpanded: Bool = false
|
||||
@Namespace private var lockIconSpace
|
||||
|
||||
private var iconName: String {
|
||||
lockScreenManager.isLocked ? "lock.fill" : "lock.open.fill"
|
||||
}
|
||||
|
||||
private var iconColor: Color {
|
||||
.white
|
||||
}
|
||||
|
||||
private var indicatorDimension: CGFloat {
|
||||
max(0, vm.effectiveClosedNotchHeight - 12)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Left - Lock icon with subtle glow
|
||||
Color.clear
|
||||
.overlay(alignment: .leading) {
|
||||
if isExpanded {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(iconColor)
|
||||
.matchedGeometryEffect(id: "lock-icon", in: lockIconSpace)
|
||||
.frame(width: vm.effectiveClosedNotchHeight - 12, height: vm.effectiveClosedNotchHeight - 12)
|
||||
LockIconProgressView(progress: iconAnimator.progress, iconColor: iconColor)
|
||||
.frame(width: indicatorDimension, height: indicatorDimension)
|
||||
}
|
||||
}
|
||||
.frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))
|
||||
|
|
@ -50,6 +47,7 @@ struct LockScreenLiveActivity: View {
|
|||
}
|
||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0))
|
||||
.onAppear {
|
||||
iconAnimator.update(isLocked: lockScreenManager.isLocked, animated: false)
|
||||
// Expand immediately without animation to avoid conflicts
|
||||
withAnimation(.smooth(duration: 0.4)) {
|
||||
isExpanded = true
|
||||
|
|
@ -70,6 +68,9 @@ struct LockScreenLiveActivity: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: lockScreenManager.isLocked) { _, newValue in
|
||||
iconAnimator.update(isLocked: newValue)
|
||||
}
|
||||
.animation(.spring(response: 0.5, dampingFraction: 0.85), value: lockScreenManager.isLocked)
|
||||
.animation(.easeOut(duration: 0.25), value: isExpanded)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import SwiftUI
|
||||
|
||||
final class LockScreenLiveActivityOverlayModel: ObservableObject {
|
||||
@Published var iconName: String = "lock.fill"
|
||||
@Published var scale: CGFloat = 0.6
|
||||
@Published var opacity: Double = 0
|
||||
}
|
||||
|
||||
struct LockScreenLiveActivityOverlay: View {
|
||||
@ObservedObject var model: LockScreenLiveActivityOverlayModel
|
||||
@ObservedObject var animator: LockIconAnimator
|
||||
let notchSize: CGSize
|
||||
|
||||
private var indicatorSize: CGFloat {
|
||||
|
|
@ -26,9 +26,7 @@ struct LockScreenLiveActivityOverlay: View {
|
|||
HStack(spacing: 0) {
|
||||
Color.clear
|
||||
.overlay(alignment: .leading) {
|
||||
Image(systemName: model.iconName)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
LockIconProgressView(progress: animator.progress)
|
||||
.frame(width: indicatorSize, height: indicatorSize)
|
||||
}
|
||||
.frame(width: indicatorSize, height: notchSize.height)
|
||||
|
|
@ -52,6 +50,5 @@ struct LockScreenLiveActivityOverlay: View {
|
|||
.frame(width: totalWidth, height: notchSize.height)
|
||||
.scaleEffect(x: model.scale, y: 1, anchor: .center)
|
||||
.opacity(model.opacity)
|
||||
.drawingGroup()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue