feat: 动效

This commit is contained in:
jinyaqiu 2025-08-24 13:26:04 +08:00
parent 949b371b49
commit 8fe4a8b8a5

View File

@ -6,31 +6,54 @@ struct FilmFrame: Identifiable {
let id = UUID() let id = UUID()
let imageName: String let imageName: String
let systemImage: String let systemImage: String
let svgName: String // SVG
}
//
enum AnimationPhase {
case accelerating //
case uniform //
case exiting // 退
case stationary //
}
// -
class AnimationCoordinator: ObservableObject {
@Published var shouldStartExiting = false // 退
} }
// //
class FilmAnimationController: NSObject, ObservableObject { class FilmAnimationController: NSObject, ObservableObject {
@Published var offset: CGFloat = 0 @Published var offset: CGPoint = .zero
@Published var scale: CGFloat = 1.0
@Published var opacity: Double = 1.0
@Published var isPresented: Bool = true
@Published var phase: AnimationPhase = .accelerating
let originalRotation: Double
let reelIdentifier: UUID //
var currentSpeed: CGFloat = 0 var currentSpeed: CGFloat = 0
var lastUpdateTime: CFTimeInterval = 0 var lastUpdateTime: CFTimeInterval = 0
var startTime: CFTimeInterval = 0 var startTime: CFTimeInterval = 0
var isAccelerating = true var uniformStartTime: CFTimeInterval = 0
var exitStartTime: CFTimeInterval = 0
var displayLink: CADisplayLink? var displayLink: CADisplayLink?
var targetFrameIndex: Int //
// - //
let frames: [FilmFrame] let frames: [FilmFrame]
let rotationAngle: Double let verticalOffset: CGFloat //
let verticalOffset: CGFloat let exitDistance: CGFloat // 退
let direction: Direction let direction: Direction
let initialSpeed: CGFloat let initialSpeed: CGFloat
let maxSpeed: CGFloat // let maxSpeed: CGFloat
let accelerationDuration: Double let accelerationDuration: Double
let frameWidth: CGFloat let frameWidth: CGFloat
let spacing: CGFloat let spacing: CGFloat
let frameCycleWidth: CGFloat let frameCycleWidth: CGFloat
let isMiddleReel: Bool let isMiddleReel: Bool
weak var coordinator: AnimationCoordinator? //
//
enum Direction { enum Direction {
case left case left
case right case right
@ -43,16 +66,17 @@ class FilmAnimationController: NSObject, ObservableObject {
} }
} }
// - init(frames: [FilmFrame], rotationAngle: Double, verticalOffset: CGFloat, exitDistance: CGFloat,
init(frames: [FilmFrame], rotationAngle: Double, verticalOffset: CGFloat, direction: Direction, direction: Direction, coordinator: AnimationCoordinator,
isMiddleReel: Bool = false, isMiddleReel: Bool = false,
initialSpeed: CGFloat = 100, // initialSpeed: CGFloat = 100,
maxSpeed: CGFloat = 600, // 400 maxSpeed: CGFloat = 600,
accelerationDuration: Double = 4, // accelerationDuration: Double = 4,
frameWidth: CGFloat = 100, spacing: CGFloat = 10) { frameWidth: CGFloat = 100, spacing: CGFloat = 10) {
self.frames = frames self.frames = frames
self.rotationAngle = rotationAngle self.originalRotation = rotationAngle
self.verticalOffset = verticalOffset self.verticalOffset = verticalOffset
self.exitDistance = exitDistance
self.direction = direction self.direction = direction
self.initialSpeed = initialSpeed self.initialSpeed = initialSpeed
self.maxSpeed = maxSpeed self.maxSpeed = maxSpeed
@ -60,17 +84,32 @@ class FilmAnimationController: NSObject, ObservableObject {
self.frameWidth = frameWidth self.frameWidth = frameWidth
self.spacing = spacing self.spacing = spacing
self.isMiddleReel = isMiddleReel self.isMiddleReel = isMiddleReel
self.coordinator = coordinator
self.reelIdentifier = UUID()
//
self.targetFrameIndex = frames.count - 1
let baseFramesCount = CGFloat(frames.count) let baseFramesCount = CGFloat(frames.count)
let adjustedFramesCount = isMiddleReel ? baseFramesCount * 1.5 : baseFramesCount let adjustedFramesCount = isMiddleReel ? baseFramesCount * 1.5 : baseFramesCount
self.frameCycleWidth = adjustedFramesCount * (frameWidth + spacing) self.frameCycleWidth = adjustedFramesCount * (frameWidth + spacing)
super.init() super.init()
// 退
NotificationCenter.default.addObserver(self, selector: #selector(startExitingWhenTriggered), name: NSNotification.Name("StartExiting"), object: nil)
}
// 退
@objc private func startExitingWhenTriggered() {
if self.phase == .uniform {
startExitAnimation()
}
} }
//
func startAnimation() { func startAnimation() {
offset = -frameCycleWidth / (isMiddleReel ? 1.5 : 1) offset.x = -frameCycleWidth / (isMiddleReel ? 1.5 : 1)
offset.y = verticalOffset
currentSpeed = initialSpeed currentSpeed = initialSpeed
startTime = CACurrentMediaTime() startTime = CACurrentMediaTime()
lastUpdateTime = CACurrentMediaTime() lastUpdateTime = CACurrentMediaTime()
@ -78,43 +117,89 @@ class FilmAnimationController: NSObject, ObservableObject {
displayLink?.add(to: .main, forMode: .common) displayLink?.add(to: .main, forMode: .common)
} }
//
func stopAnimation() { func stopAnimation() {
displayLink?.invalidate() displayLink?.invalidate()
displayLink = nil displayLink = nil
NotificationCenter.default.removeObserver(self)
}
// 退
func startExitAnimation() {
phase = .exiting
exitStartTime = CACurrentMediaTime()
if isMiddleReel {
//
withAnimation(.easeIn(duration: 0.6)) {
self.opacity = 0.0
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
self.isPresented = false
}
} else {
//
withAnimation(.easeInOut(duration: 1.2)) {
self.offset.y = self.verticalOffset + self.exitDistance
self.opacity = 0.0
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
self.isPresented = false
}
}
} }
//
@objc private func updateAnimation(_ displayLink: CADisplayLink) { @objc private func updateAnimation(_ displayLink: CADisplayLink) {
let currentTime = CACurrentMediaTime() let currentTime = CACurrentMediaTime()
let deltaTime = currentTime - lastUpdateTime let deltaTime = currentTime - lastUpdateTime
lastUpdateTime = currentTime lastUpdateTime = currentTime
// guard isPresented else { return }
if isAccelerating {
switch phase {
case .accelerating:
let elapsedTime = currentTime - startTime let elapsedTime = currentTime - startTime
let progress = min(CGFloat(elapsedTime) / CGFloat(accelerationDuration), 1.0) let progress = min(CGFloat(elapsedTime) / CGFloat(accelerationDuration), 1.0)
currentSpeed = initialSpeed + (maxSpeed - initialSpeed) * progress currentSpeed = initialSpeed + (maxSpeed - initialSpeed) * progress
if progress >= 1.0 { if progress >= 1.0 {
isAccelerating = false phase = .uniform
uniformStartTime = currentTime
NotificationCenter.default.post(name: NSNotification.Name("ReelEnteredUniform"), object: reelIdentifier)
} }
let distance = currentSpeed * CGFloat(deltaTime) * direction.multiplier
offset.x += distance
resetOffset()
case .uniform:
let uniformTimeElapsed = currentTime - uniformStartTime
if uniformTimeElapsed >= 3.0 {
if isMiddleReel {
NotificationCenter.default.post(name: NSNotification.Name("AllReelsReadyToExit"), object: nil)
}
} else {
let distance = currentSpeed * CGFloat(deltaTime) * direction.multiplier
offset.x += distance
resetOffset()
}
case .exiting, .stationary:
break
} }
}
//
let distance = currentSpeed * CGFloat(deltaTime) * direction.multiplier private func resetOffset() {
offset += distance
//
let cycleThreshold = isMiddleReel ? frameCycleWidth * 1.2 : frameCycleWidth * 2 let cycleThreshold = isMiddleReel ? frameCycleWidth * 1.2 : frameCycleWidth * 2
if direction == .left { if direction == .left {
while offset < -cycleThreshold { while offset.x < -cycleThreshold {
offset += frameCycleWidth / (isMiddleReel ? 1.5 : 1) offset.x += frameCycleWidth / (isMiddleReel ? 1.5 : 1)
} }
} else { } else {
while offset > 0 { while offset.x > 0 {
offset -= frameCycleWidth offset.x -= frameCycleWidth
} }
} }
} }
@ -125,115 +210,262 @@ struct FilmReelView: View {
@StateObject private var animationController: FilmAnimationController @StateObject private var animationController: FilmAnimationController
let zIndex: Double let zIndex: Double
init(frames: [FilmFrame], rotationAngle: Double, verticalOffset: CGFloat, init(frames: [FilmFrame], rotationAngle: Double, verticalOffset: CGFloat, exitDistance: CGFloat,
direction: FilmAnimationController.Direction, isMiddleReel: Bool = false, zIndex: Double = 0) { direction: FilmAnimationController.Direction, coordinator: AnimationCoordinator,
isMiddleReel: Bool = false, zIndex: Double = 0) {
_animationController = StateObject(wrappedValue: FilmAnimationController( _animationController = StateObject(wrappedValue: FilmAnimationController(
frames: frames, frames: frames,
rotationAngle: rotationAngle, rotationAngle: rotationAngle,
verticalOffset: verticalOffset, verticalOffset: verticalOffset,
exitDistance: exitDistance,
direction: direction, direction: direction,
coordinator: coordinator,
isMiddleReel: isMiddleReel isMiddleReel: isMiddleReel
)) ))
self.zIndex = zIndex self.zIndex = zIndex
} }
var body: some View { var body: some View {
let containerWidth = animationController.frameCycleWidth * 4 if animationController.isPresented {
let containerWidth = animationController.frameCycleWidth * 4
ZStack {
Color.clear
.frame(width: containerWidth)
HStack(spacing: animationController.spacing) { ZStack {
let repeatCount = animationController.isMiddleReel ? 4 : 3 HStack(spacing: animationController.spacing) {
ForEach(Array(repeating: animationController.frames, count: repeatCount).flatMap { $0 }) { frame in let repeatCount = animationController.isMiddleReel ? 4 : 3
ZStack { ForEach(Array(repeating: animationController.frames, count: repeatCount).flatMap { $0 }) { frame in
RoundedRectangle(cornerRadius: 4) filmFrameView(frame: frame)
.stroke(Color.black, lineWidth: 2)
.background(Color(white: 0.9, opacity: 0.7))
Group {
if let uiImage = UIImage(named: frame.imageName) {
Image(uiImage: uiImage)
.resizable()
} else {
Image(systemName: frame.systemImage)
.resizable()
}
}
.aspectRatio(contentMode: .fit)
.padding(6)
.foregroundColor(animationController.isMiddleReel ? .red : .blue)
} }
.frame(width: animationController.frameWidth, height: 150)
.shadow(radius: 3)
} }
.offset(x: animationController.offset.x, y: animationController.offset.y)
} }
.offset(x: animationController.offset, y: animationController.verticalOffset) .frame(width: containerWidth)
.rotationEffect(.degrees(animationController.originalRotation))
.scaleEffect(animationController.scale)
.opacity(animationController.opacity)
.zIndex(zIndex)
.onAppear { .onAppear {
animationController.startAnimation() animationController.startAnimation()
} }
.onDisappear { .onDisappear {
animationController.stopAnimation() animationController.stopAnimation()
} }
} else {
EmptyView()
} }
.rotationEffect(.degrees(animationController.rotationAngle)) }
.zIndex(zIndex)
private func filmFrameView(frame: FilmFrame) -> some View {
ZStack {
RoundedRectangle(cornerRadius: 4)
.stroke(Color.black, lineWidth: 2)
.background(Color(white: 0.9, opacity: 0.7))
Group {
if let uiImage = UIImage(named: frame.imageName) {
Image(uiImage: uiImage)
.resizable()
} else {
Image(systemName: frame.systemImage)
.resizable()
}
}
.aspectRatio(contentMode: .fit)
.padding(6)
.foregroundColor(animationController.isMiddleReel ? .red : .blue)
}
.frame(width: animationController.frameWidth, height: 150)
.shadow(radius: 3)
}
}
//
struct ShakeEffect: GeometryEffect {
var amount: CGFloat = 10
var shakesPerUnit: CGFloat = 3
var animatableData: CGFloat
func effectValue(size: CGSize) -> ProjectionTransform {
ProjectionTransform(CGAffineTransform(translationX:
amount * sin(animatableData * .pi * shakesPerUnit),
y: 0))
}
}
// SVG -
struct MainSVGView: View {
@Binding var isVisible: Bool
@State private var scale: CGFloat = 0.1
@State private var opacity: Double = 0.0
@State private var showFront: Bool = true
@State private var shakeAmount: CGFloat = 0
@State private var rotation: Double = 0
let svgName: String // SVG
var body: some View {
ZStack {
if showFront {
// SVG
SVGImage(svgName: "IP")
.aspectRatio(contentMode: .fit)
.padding()
.background(Color.white)
.cornerRadius(12)
.shadow(radius: 15)
.rotation3DEffect(.degrees(rotation), axis: (x: 0, y: 1, z: 0))
} else {
// SVG
SVGImage(svgName: svgName)
.aspectRatio(contentMode: .fit)
.padding()
.background(Color.white)
.cornerRadius(12)
.shadow(radius: 15)
.rotation3DEffect(.degrees(rotation), axis: (x: 0, y: 1, z: 0))
}
}
.scaleEffect(scale)
.opacity(opacity)
.modifier(ShakeEffect(animatableData: shakeAmount))
.onAppear {
//
withAnimation(.easeOut(duration: 1.5).delay(0.3)) {
self.scale = 1.0
self.opacity = 1.0
self.shakeAmount = 1 //
}
//
DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) {
withAnimation(.linear(duration: 0.5)) {
self.shakeAmount = 0
}
}
//
DispatchQueue.main.asyncAfter(deadline: .now() + 2.3) {
//
withAnimation(.easeInOut(duration: 0.5)) {
self.shakeAmount = 2 //
}
//
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.easeInOut(duration: 1.0)) {
self.rotation = 180 //
}
//
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
withAnimation(.linear(duration: 0.5)) {
self.shakeAmount = 0
self.showFront = false
}
}
}
}
}
.edgesIgnoringSafeArea(.all)
} }
} }
// //
struct Box7: View { struct Box7: View {
// @StateObject private var coordinator = AnimationCoordinator()
@State private var showMainSVG = false
@State private var readyReelsCount = 0
private let totalReels = 3 //
// - 使svgNamefullImageName
private let filmFrames: [FilmFrame] = [ private let filmFrames: [FilmFrame] = [
FilmFrame(imageName: "frame1", systemImage: "photo"), FilmFrame(imageName: "frame1", systemImage: "photo", svgName: "IP1"),
FilmFrame(imageName: "frame2", systemImage: "camera"), FilmFrame(imageName: "frame2", systemImage: "camera", svgName: "IP2"),
FilmFrame(imageName: "frame3", systemImage: "video"), FilmFrame(imageName: "frame3", systemImage: "video", svgName: "IP3"),
FilmFrame(imageName: "frame4", systemImage: "movie"), FilmFrame(imageName: "frame4", systemImage: "movie", svgName: "IP4"),
FilmFrame(imageName: "frame5", systemImage: "film"), FilmFrame(imageName: "frame5", systemImage: "film", svgName: "IP5"),
FilmFrame(imageName: "frame6", systemImage: "photo.on.rectangle"), FilmFrame(imageName: "frame6", systemImage: "photo.on.rectangle", svgName: "IP6"),
FilmFrame(imageName: "frame7", systemImage: "photo.circle"), FilmFrame(imageName: "frame7", systemImage: "photo.circle", svgName: "IP7"),
FilmFrame(imageName: "frame8", systemImage: "film.frame") FilmFrame(imageName: "frame8", systemImage: "film.frame", svgName: "IP") // 使"IP" SVG
] ]
var body: some View { var body: some View {
ZStack(alignment: .center) { ZStack(alignment: .center) {
// //
LinearGradient(
gradient: Gradient(colors: [.gray.opacity(0.1), .gray.opacity(0.2)]),
startPoint: .top,
endPoint: .bottom
)
.edgesIgnoringSafeArea(.all)
//
// - 退
FilmReelView( FilmReelView(
frames: filmFrames, frames: filmFrames,
rotationAngle: 6, rotationAngle: 6,
verticalOffset: -120, verticalOffset: -120,
exitDistance: -250,
direction: .right, direction: .right,
zIndex: 1 coordinator: coordinator,
zIndex: 2
) )
// // -
FilmReelView( FilmReelView(
frames: filmFrames, frames: filmFrames,
rotationAngle: 0, rotationAngle: 0,
verticalOffset: 0, verticalOffset: 0,
exitDistance: 0,
direction: .left, direction: .left,
coordinator: coordinator,
isMiddleReel: true, isMiddleReel: true,
zIndex: 0 zIndex: 1
) )
// // - 退
FilmReelView( FilmReelView(
frames: filmFrames, frames: filmFrames,
rotationAngle: -6, rotationAngle: -6,
verticalOffset: 120, verticalOffset: 120,
exitDistance: 250,
direction: .right, direction: .right,
coordinator: coordinator,
zIndex: 2 zIndex: 2
) )
// SVG - 退使SVGImage(svgName: "IP")
if showMainSVG {
MainSVGView(
isVisible: $showMainSVG,
svgName: filmFrames.last!.svgName // "IP" SVG
)
.zIndex(10) //
}
}
.onAppear {
//
NotificationCenter.default.addObserver(forName: NSNotification.Name("ReelEnteredUniform"), object: nil, queue: .main) { _ in
readyReelsCount += 1
if readyReelsCount >= totalReels {
print("所有胶卷已准备就绪")
}
}
// 退
NotificationCenter.default.addObserver(forName: NSNotification.Name("AllReelsReadyToExit"), object: nil, queue: .main) { _ in
coordinator.shouldStartExiting = true
NotificationCenter.default.post(name: NSNotification.Name("StartExiting"), object: nil)
// 退SVG
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
showMainSVG = true
}
}
}
.onDisappear {
NotificationCenter.default.removeObserver(self)
} }
.padding(.horizontal, 60) .padding(.horizontal, 60)
.padding(.vertical, 80) .padding(.vertical, 80)
.background(LinearGradient(
gradient: Gradient(colors: [.gray.opacity(0.1), .gray.opacity(0.2)]),
startPoint: .top,
endPoint: .bottom
))
.edgesIgnoringSafeArea(.all)
} }
} }