From 8fe4a8b8a527f11148cb5d69f878b8702e812cf8 Mon Sep 17 00:00:00 2001 From: jinyaqiu Date: Sun, 24 Aug 2025 13:26:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8A=A8=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wake/View/Blind/Box7.swift | 396 +++++++++++++++++++++++++++++-------- 1 file changed, 314 insertions(+), 82 deletions(-) diff --git a/wake/View/Blind/Box7.swift b/wake/View/Blind/Box7.swift index def76a3..cdc810a 100644 --- a/wake/View/Blind/Box7.swift +++ b/wake/View/Blind/Box7.swift @@ -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) } }