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 imageName: 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 {
@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 lastUpdateTime: CFTimeInterval = 0
var startTime: CFTimeInterval = 0
var isAccelerating = true
var uniformStartTime: CFTimeInterval = 0
var exitStartTime: CFTimeInterval = 0
var displayLink: CADisplayLink?
var targetFrameIndex: Int //
// -
//
let frames: [FilmFrame]
let rotationAngle: Double
let verticalOffset: CGFloat
let verticalOffset: CGFloat //
let exitDistance: CGFloat // 退
let direction: Direction
let initialSpeed: CGFloat
let maxSpeed: CGFloat //
let maxSpeed: CGFloat
let accelerationDuration: Double
let frameWidth: CGFloat
let spacing: CGFloat
let frameCycleWidth: CGFloat
let isMiddleReel: Bool
weak var coordinator: AnimationCoordinator? //
//
enum Direction {
case left
case right
@ -43,16 +66,17 @@ class FilmAnimationController: NSObject, ObservableObject {
}
}
// -
init(frames: [FilmFrame], rotationAngle: Double, verticalOffset: CGFloat, direction: Direction,
init(frames: [FilmFrame], rotationAngle: Double, verticalOffset: CGFloat, exitDistance: CGFloat,
direction: Direction, coordinator: AnimationCoordinator,
isMiddleReel: Bool = false,
initialSpeed: CGFloat = 100, //
maxSpeed: CGFloat = 600, // 400
accelerationDuration: Double = 4, //
initialSpeed: CGFloat = 100,
maxSpeed: CGFloat = 600,
accelerationDuration: Double = 4,
frameWidth: CGFloat = 100, spacing: CGFloat = 10) {
self.frames = frames
self.rotationAngle = rotationAngle
self.originalRotation = rotationAngle
self.verticalOffset = verticalOffset
self.exitDistance = exitDistance
self.direction = direction
self.initialSpeed = initialSpeed
self.maxSpeed = maxSpeed
@ -60,17 +84,32 @@ class FilmAnimationController: NSObject, ObservableObject {
self.frameWidth = frameWidth
self.spacing = spacing
self.isMiddleReel = isMiddleReel
self.coordinator = coordinator
self.reelIdentifier = UUID()
//
self.targetFrameIndex = frames.count - 1
let baseFramesCount = CGFloat(frames.count)
let adjustedFramesCount = isMiddleReel ? baseFramesCount * 1.5 : baseFramesCount
self.frameCycleWidth = adjustedFramesCount * (frameWidth + spacing)
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() {
offset = -frameCycleWidth / (isMiddleReel ? 1.5 : 1)
offset.x = -frameCycleWidth / (isMiddleReel ? 1.5 : 1)
offset.y = verticalOffset
currentSpeed = initialSpeed
startTime = CACurrentMediaTime()
lastUpdateTime = CACurrentMediaTime()
@ -78,43 +117,89 @@ class FilmAnimationController: NSObject, ObservableObject {
displayLink?.add(to: .main, forMode: .common)
}
//
func stopAnimation() {
displayLink?.invalidate()
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) {
let currentTime = CACurrentMediaTime()
let deltaTime = currentTime - lastUpdateTime
lastUpdateTime = currentTime
//
if isAccelerating {
guard isPresented else { return }
switch phase {
case .accelerating:
let elapsedTime = currentTime - startTime
let progress = min(CGFloat(elapsedTime) / CGFloat(accelerationDuration), 1.0)
currentSpeed = initialSpeed + (maxSpeed - initialSpeed) * progress
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
offset += distance
//
}
private func resetOffset() {
let cycleThreshold = isMiddleReel ? frameCycleWidth * 1.2 : frameCycleWidth * 2
if direction == .left {
while offset < -cycleThreshold {
offset += frameCycleWidth / (isMiddleReel ? 1.5 : 1)
while offset.x < -cycleThreshold {
offset.x += frameCycleWidth / (isMiddleReel ? 1.5 : 1)
}
} else {
while offset > 0 {
offset -= frameCycleWidth
while offset.x > 0 {
offset.x -= frameCycleWidth
}
}
}
@ -125,115 +210,262 @@ struct FilmReelView: View {
@StateObject private var animationController: FilmAnimationController
let zIndex: Double
init(frames: [FilmFrame], rotationAngle: Double, verticalOffset: CGFloat,
direction: FilmAnimationController.Direction, isMiddleReel: Bool = false, zIndex: Double = 0) {
init(frames: [FilmFrame], rotationAngle: Double, verticalOffset: CGFloat, exitDistance: CGFloat,
direction: FilmAnimationController.Direction, coordinator: AnimationCoordinator,
isMiddleReel: Bool = false, zIndex: Double = 0) {
_animationController = StateObject(wrappedValue: FilmAnimationController(
frames: frames,
rotationAngle: rotationAngle,
verticalOffset: verticalOffset,
exitDistance: exitDistance,
direction: direction,
coordinator: coordinator,
isMiddleReel: isMiddleReel
))
self.zIndex = zIndex
}
var body: some View {
let containerWidth = animationController.frameCycleWidth * 4
ZStack {
Color.clear
.frame(width: containerWidth)
if animationController.isPresented {
let containerWidth = animationController.frameCycleWidth * 4
HStack(spacing: animationController.spacing) {
let repeatCount = animationController.isMiddleReel ? 4 : 3
ForEach(Array(repeating: animationController.frames, count: repeatCount).flatMap { $0 }) { frame in
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)
ZStack {
HStack(spacing: animationController.spacing) {
let repeatCount = animationController.isMiddleReel ? 4 : 3
ForEach(Array(repeating: animationController.frames, count: repeatCount).flatMap { $0 }) { frame in
filmFrameView(frame: frame)
}
.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 {
animationController.startAnimation()
}
.onDisappear {
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 {
//
@StateObject private var coordinator = AnimationCoordinator()
@State private var showMainSVG = false
@State private var readyReelsCount = 0
private let totalReels = 3 //
// - 使svgNamefullImageName
private let filmFrames: [FilmFrame] = [
FilmFrame(imageName: "frame1", systemImage: "photo"),
FilmFrame(imageName: "frame2", systemImage: "camera"),
FilmFrame(imageName: "frame3", systemImage: "video"),
FilmFrame(imageName: "frame4", systemImage: "movie"),
FilmFrame(imageName: "frame5", systemImage: "film"),
FilmFrame(imageName: "frame6", systemImage: "photo.on.rectangle"),
FilmFrame(imageName: "frame7", systemImage: "photo.circle"),
FilmFrame(imageName: "frame8", systemImage: "film.frame")
FilmFrame(imageName: "frame1", systemImage: "photo", svgName: "IP1"),
FilmFrame(imageName: "frame2", systemImage: "camera", svgName: "IP2"),
FilmFrame(imageName: "frame3", systemImage: "video", svgName: "IP3"),
FilmFrame(imageName: "frame4", systemImage: "movie", svgName: "IP4"),
FilmFrame(imageName: "frame5", systemImage: "film", svgName: "IP5"),
FilmFrame(imageName: "frame6", systemImage: "photo.on.rectangle", svgName: "IP6"),
FilmFrame(imageName: "frame7", systemImage: "photo.circle", svgName: "IP7"),
FilmFrame(imageName: "frame8", systemImage: "film.frame", svgName: "IP") // 使"IP" SVG
]
var body: some View {
ZStack(alignment: .center) {
//
//
LinearGradient(
gradient: Gradient(colors: [.gray.opacity(0.1), .gray.opacity(0.2)]),
startPoint: .top,
endPoint: .bottom
)
.edgesIgnoringSafeArea(.all)
//
// - 退
FilmReelView(
frames: filmFrames,
rotationAngle: 6,
verticalOffset: -120,
exitDistance: -250,
direction: .right,
zIndex: 1
coordinator: coordinator,
zIndex: 2
)
//
// -
FilmReelView(
frames: filmFrames,
rotationAngle: 0,
verticalOffset: 0,
exitDistance: 0,
direction: .left,
coordinator: coordinator,
isMiddleReel: true,
zIndex: 0
zIndex: 1
)
//
// - 退
FilmReelView(
frames: filmFrames,
rotationAngle: -6,
verticalOffset: 120,
exitDistance: 250,
direction: .right,
coordinator: coordinator,
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(.vertical, 80)
.background(LinearGradient(
gradient: Gradient(colors: [.gray.opacity(0.1), .gray.opacity(0.2)]),
startPoint: .top,
endPoint: .bottom
))
.edgesIgnoringSafeArea(.all)
}
}