479 lines
17 KiB
Swift
479 lines
17 KiB
Swift
import SwiftUI
|
||
import UIKit
|
||
|
||
// 胶卷帧数据模型
|
||
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: 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 uniformStartTime: CFTimeInterval = 0
|
||
var exitStartTime: CFTimeInterval = 0
|
||
var displayLink: CADisplayLink?
|
||
var targetFrameIndex: Int // 目标帧索引
|
||
|
||
// 配置参数
|
||
let frames: [FilmFrame]
|
||
let verticalOffset: CGFloat // 初始垂直偏移
|
||
let exitDistance: CGFloat // 退出时的移动距离
|
||
let direction: Direction
|
||
let initialSpeed: 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
|
||
|
||
var multiplier: CGFloat {
|
||
switch self {
|
||
case .left: return -1
|
||
case .right: return 1
|
||
}
|
||
}
|
||
}
|
||
|
||
init(frames: [FilmFrame], rotationAngle: Double, verticalOffset: CGFloat, exitDistance: CGFloat,
|
||
direction: Direction, coordinator: AnimationCoordinator,
|
||
isMiddleReel: Bool = false,
|
||
initialSpeed: CGFloat = 100,
|
||
maxSpeed: CGFloat = 600,
|
||
accelerationDuration: Double = 4,
|
||
frameWidth: CGFloat = 100, spacing: CGFloat = 10) {
|
||
self.frames = frames
|
||
self.originalRotation = rotationAngle
|
||
self.verticalOffset = verticalOffset
|
||
self.exitDistance = exitDistance
|
||
self.direction = direction
|
||
self.initialSpeed = initialSpeed
|
||
self.maxSpeed = maxSpeed
|
||
self.accelerationDuration = accelerationDuration
|
||
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.x = -frameCycleWidth / (isMiddleReel ? 1.5 : 1)
|
||
offset.y = verticalOffset
|
||
currentSpeed = initialSpeed
|
||
startTime = CACurrentMediaTime()
|
||
lastUpdateTime = CACurrentMediaTime()
|
||
displayLink = CADisplayLink(target: self, selector: #selector(updateAnimation(_:)))
|
||
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
|
||
|
||
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 {
|
||
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
|
||
}
|
||
}
|
||
|
||
private func resetOffset() {
|
||
let cycleThreshold = isMiddleReel ? frameCycleWidth * 1.2 : frameCycleWidth * 2
|
||
|
||
if direction == .left {
|
||
while offset.x < -cycleThreshold {
|
||
offset.x += frameCycleWidth / (isMiddleReel ? 1.5 : 1)
|
||
}
|
||
} else {
|
||
while offset.x > 0 {
|
||
offset.x -= frameCycleWidth
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 胶卷视图
|
||
struct FilmReelView: View {
|
||
@StateObject private var animationController: FilmAnimationController
|
||
let zIndex: Double
|
||
|
||
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 {
|
||
if animationController.isPresented {
|
||
let containerWidth = animationController.frameCycleWidth * 4
|
||
|
||
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)
|
||
}
|
||
}
|
||
.offset(x: animationController.offset.x, y: animationController.offset.y)
|
||
}
|
||
.frame(width: containerWidth)
|
||
.rotationEffect(.degrees(animationController.originalRotation))
|
||
.scaleEffect(animationController.scale)
|
||
.opacity(animationController.opacity)
|
||
.zIndex(zIndex)
|
||
.onAppear {
|
||
animationController.startAnimation()
|
||
}
|
||
.onDisappear {
|
||
animationController.stopAnimation()
|
||
}
|
||
} else {
|
||
EmptyView()
|
||
}
|
||
}
|
||
|
||
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", 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,
|
||
coordinator: coordinator,
|
||
zIndex: 2
|
||
)
|
||
|
||
// 中间胶卷 - 直接消失
|
||
FilmReelView(
|
||
frames: filmFrames,
|
||
rotationAngle: 0,
|
||
verticalOffset: 0,
|
||
exitDistance: 0,
|
||
direction: .left,
|
||
coordinator: coordinator,
|
||
isMiddleReel: true,
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 预览
|
||
struct Box7_Previews: PreviewProvider {
|
||
static var previews: some View {
|
||
Box7()
|
||
.previewDevice("iPhone 13")
|
||
}
|
||
}
|