feat: 动效
This commit is contained in:
parent
949b371b49
commit
8fe4a8b8a5
@ -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 // 总胶卷数量
|
||||
|
||||
// 胶卷帧数据 - 使用svgName替代fullImageName
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user