Compare commits

...

2 Commits
main ... Blind

Author SHA1 Message Date
jinyaqiu
8fe4a8b8a5 feat: 动效 2025-08-24 13:26:04 +08:00
jinyaqiu
949b371b49 feat: 加速后匀速 2025-08-24 12:20:49 +08:00
3 changed files with 598 additions and 166 deletions

View File

@ -3,240 +3,193 @@ import SwiftUI
struct FilmAnimation: View {
//
private let deviceWidth = UIScreen.main.bounds.width
private let deviceHeight = UIScreen.main.bounds.height
//
@State private var animationProgress: CGFloat = 0.0 // 0-1
@State private var isAnimating: Bool = false
@State private var animationComplete: Bool = false
@State private var animationProgress: CGFloat = 0.0
@State private var hasAccelerated: Bool = false //
//
private let reelImages: [[String]] = [
(0..<300).map { "film1-\($0+1)" }, //
(0..<350).map { "film2-\($0+1)" }, //
(0..<300).map { "film3-\($0+1)" } //
]
// 16
private let reelFrames: Int = 16
private var reel1: [String] { (0..<reelFrames).map { "film1-\($0+1)" } }
private var reel2: [String] { (0..<reelFrames).map { "film2-\($0+1)" } }
private var reel3: [String] { (0..<reelFrames).map { "film3-\($0+1)" } }
//
//
private let frameWidth: CGFloat = 90
private let frameHeight: CGFloat = 130
private let frameSpacing: CGFloat = 12
private var frameTotalWidth: CGFloat { frameWidth + frameSpacing }
private var visibleFrames: Int { Int(deviceWidth / frameTotalWidth) + 2 } //
//
private let accelerationDuration: Double = 5.0 // 0-5s
private let constantSpeedDuration: Double = 1.0 // 5-6s
private let scaleStartDuration: Double = 1.0 // 6-7s
private let scaleFinishDuration: Double = 1.0 // 7-8s
private var totalDuration: Double {
accelerationDuration + constantSpeedDuration + scaleStartDuration + scaleFinishDuration
}
//
private let accelerationDuration: Double = 3.0 //
private let cycleDuration: Double = 2.0 //
//
private var accelerationEnd: CGFloat { accelerationDuration / totalDuration }
private var constantSpeedEnd: CGFloat { (accelerationDuration + constantSpeedDuration) / totalDuration }
private var scaleStartEnd: CGFloat {
(accelerationDuration + constantSpeedDuration + scaleStartDuration) / totalDuration
}
//
private let upperTilt: Double = -10 //
private let lowerTilt: Double = 10 //
private let verticalSpacing: CGFloat = 200
//
private let tiltAngle: Double = 10 //
private let upperTilt: Double = -10 //
private let lowerTilt: Double = 10 //
private let verticalSpacing: CGFloat = 200 //
private let finalScale: CGFloat = 4.5
//
private let maxTiltedReelMovement: CGFloat = 3500 //
private let maxMiddleReelMovement: CGFloat = -3000 //
// =
private var trackLength: CGFloat { CGFloat(reelFrames) * frameTotalWidth }
var body: some View {
//
Color(red: 0.08, green: 0.08, blue: 0.08)
.edgesIgnoringSafeArea(.all)
.overlay(
ZStack {
//
if showTiltedReels {
FilmReelView(images: reelImages[0])
.rotationEffect(Angle(degrees: upperTilt)) //
.offset(x: upperReelXPosition, y: -verticalSpacing/2)
.scaleEffect(tiltedScale)
.opacity(tiltedOpacity)
.zIndex(1)
}
//
if showTiltedReels {
FilmReelView(images: reelImages[2])
.rotationEffect(Angle(degrees: lowerTilt)) //
.offset(x: lowerReelXPosition, y: verticalSpacing/2)
.scaleEffect(tiltedScale)
.opacity(tiltedOpacity)
.zIndex(1)
}
//
FilmReelView(images: reelImages[1])
.offset(x: middleReelXPosition, y: 0)
.scaleEffect(middleScale)
.opacity(1.0)
.zIndex(2)
.edgesIgnoringSafeArea(.all)
}
)
.onAppear {
startAnimation()
}
ZStack {
//
FilmCarouselView(images: reel1)
.rotationEffect(Angle(degrees: upperTilt))
.offset(x: upperTrackPosition, y: -verticalSpacing/2)
.zIndex(1)
//
FilmCarouselView(images: reel3)
.rotationEffect(Angle(degrees: lowerTilt))
.offset(x: lowerTrackPosition, y: verticalSpacing/2)
.zIndex(1)
//
FilmCarouselView(images: reel2)
.offset(x: middleTrackPosition, y: 0)
.zIndex(2)
}
.onAppear {
startCarouselAnimation()
}
}
// MARK: -
// MARK: -
private func startAnimation() {
guard !isAnimating && !animationComplete else { return }
isAnimating = true
withAnimation(Animation.easeInOut(duration: totalDuration)) {
private func startCarouselAnimation() {
//
withAnimation(Animation.linear(duration: accelerationDuration)) {
animationProgress = 1.0
}
DispatchQueue.main.asyncAfter(deadline: .now() + totalDuration) {
isAnimating = false
animationComplete = true
//
DispatchQueue.main.asyncAfter(deadline: .now() + accelerationDuration) {
hasAccelerated = true
//
withAnimation(Animation.linear(duration: cycleDuration).repeatForever(autoreverses: false)) {
animationProgress = 2.0 // 1.02.0
}
}
}
// MARK: -
// MARK: -
// X
private var upperReelXPosition: CGFloat {
let startPosition: CGFloat = -deviceWidth * 1.2 //
return startPosition + (maxTiltedReelMovement * movementProgress)
}
// X
private var lowerReelXPosition: CGFloat {
let startPosition: CGFloat = -deviceWidth * 0.8 //
return startPosition + (maxTiltedReelMovement * movementProgress)
}
// X
private var middleReelXPosition: CGFloat {
let startPosition: CGFloat = deviceWidth * 0.3
return startPosition + (maxMiddleReelMovement * movementProgress)
}
// 0-1
private var movementProgress: CGFloat {
if animationProgress < constantSpeedEnd {
return animationProgress / constantSpeedEnd
private var effectiveProgress: CGFloat {
if !hasAccelerated {
// 01线
return animationProgress * animationProgress * animationProgress
} else {
return 1.0 // 6
// 12线
return 1.0 + (animationProgress - 1.0).truncatingRemainder(dividingBy: 1.0)
}
}
// MARK: -
// MARK: -
//
private var middleScale: CGFloat {
guard animationProgress >= constantSpeedEnd else {
return 1.0
}
let scalePhaseProgress = (animationProgress - constantSpeedEnd) / (1.0 - constantSpeedEnd)
return 1.0 + (finalScale - 1.0) * scalePhaseProgress
private var upperTrackPosition: CGFloat {
// 使
let rawPosition = -effectiveProgress * trackLength
//
return rawPosition.truncatingRemainder(dividingBy: -trackLength)
}
//
private var tiltedScale: CGFloat {
guard animationProgress >= constantSpeedEnd, animationProgress < scaleStartEnd else {
return 1.0
}
let scalePhaseProgress = (animationProgress - constantSpeedEnd) / (scaleStartEnd - constantSpeedEnd)
return 1.0 + (finalScale * 0.6 - 1.0) * scalePhaseProgress
private var lowerTrackPosition: CGFloat {
// 3
let rawPosition = -effectiveProgress * trackLength + frameTotalWidth * 3
return rawPosition.truncatingRemainder(dividingBy: -trackLength)
}
//
private var tiltedOpacity: Double {
guard animationProgress >= constantSpeedEnd, animationProgress < scaleStartEnd else {
return 0.8
}
let fadeProgress = (animationProgress - constantSpeedEnd) / (scaleStartEnd - constantSpeedEnd)
return 0.8 * (1.0 - fadeProgress)
}
//
private var showTiltedReels: Bool {
animationProgress < scaleStartEnd
private var middleTrackPosition: CGFloat {
// 使
let rawPosition = effectiveProgress * trackLength
return rawPosition.truncatingRemainder(dividingBy: trackLength)
}
}
// MARK: -
// MARK: -
struct FilmReelView: View {
struct FilmCarouselView: View {
let images: [String]
var body: some View {
HStack(spacing: 12) {
//
ForEach(images.indices, id: \.self) { index in
FilmFrameView(imageName: images[index])
}
//
ForEach(images.indices, id: \.self) { index in
FilmFrameView(imageName: images[index])
}
}
//
.mask(
HStack {
Spacer()
Rectangle()
.frame(width: UIScreen.main.bounds.width * 1.2) //
Spacer()
}
)
}
}
// MARK: -
struct FilmFrameView: View {
let imageName: String
var body: some View {
ZStack {
//
RoundedRectangle(cornerRadius: 4)
.stroke(Color.gray, lineWidth: 2)
.background(Color(red: 0.15, green: 0.15, blue: 0.15))
//
Rectangle()
.fill(gradientColor)
.cornerRadius(2)
.padding(2)
//
Text(imageName)
.foregroundColor(.white)
.font(.caption2)
}
.frame(width: 90, height: 130)
//
.overlay(
HStack {
VStack(spacing: 6) {
ForEach(0..<6) { _ in
Circle()
.frame(width: 6, height: 6)
.foregroundColor(.gray)
}
}
Spacer()
VStack(spacing: 6) {
ForEach(0..<6) { _ in
Circle()
.frame(width: 6, height: 6)
.foregroundColor(.gray)
}
.overlay(alignment: .leading) {
VStack(spacing: 6) {
ForEach(0..<6) { _ in
Circle()
.frame(width: 6, height: 6)
.foregroundColor(.gray)
}
}
)
.padding(.leading, -3)
}
.overlay(alignment: .trailing) {
VStack(spacing: 6) {
ForEach(0..<6) { _ in
Circle()
.frame(width: 6, height: 6)
.foregroundColor(.gray)
}
}
.padding(.trailing, -3)
}
}
private var gradientColor: LinearGradient {
if imageName.hasPrefix("film1") {
return LinearGradient(gradient: Gradient(colors: [.blue, .indigo]), startPoint: .topLeading, endPoint: .bottomTrailing)
} else if imageName.hasPrefix("film2") {
return LinearGradient(gradient: Gradient(colors: [.yellow, .orange]), startPoint: .topLeading, endPoint: .bottomTrailing)
} else {
return LinearGradient(gradient: Gradient(colors: [.purple, .pink]), startPoint: .topLeading, endPoint: .bottomTrailing)
switch imageName.prefix(5) {
case "film1":
return LinearGradient(colors: [.blue, .indigo], startPoint: .topLeading, endPoint: .bottomTrailing)
case "film2":
return LinearGradient(colors: [.yellow, .orange], startPoint: .topLeading, endPoint: .bottomTrailing)
case "film3":
return LinearGradient(colors: [.purple, .pink], startPoint: .topLeading, endPoint: .bottomTrailing)
default:
return LinearGradient(colors: [.gray, .white], startPoint: .topLeading, endPoint: .bottomTrailing)
}
}
}
@ -245,6 +198,7 @@ struct FilmFrameView: View {
struct FilmAnimation_Previews: PreviewProvider {
static var previews: some View {
FilmAnimation()
.background(Color.black)
}
}

478
wake/View/Blind/Box7.swift Normal file
View File

@ -0,0 +1,478 @@
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 //
// - 使svgNamefullImageName
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")
}
}

View File

@ -19,7 +19,7 @@ struct SplashView: View {
)
.edgesIgnoringSafeArea(.all)
VStack(spacing: 50) {
FilmAnimation()
Box7()
}
.padding()
}