251 lines
8.8 KiB
Swift
251 lines
8.8 KiB
Swift
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
|
||
|
||
// 胶卷数据
|
||
private let reelImages: [[String]] = [
|
||
(0..<300).map { "film1-\($0+1)" }, // 上方胶卷
|
||
(0..<350).map { "film2-\($0+1)" }, // 中间胶卷
|
||
(0..<300).map { "film3-\($0+1)" } // 下方胶卷
|
||
]
|
||
|
||
// 胶卷参数
|
||
private let frameWidth: CGFloat = 90
|
||
private let frameHeight: CGFloat = 130
|
||
private let frameSpacing: CGFloat = 12
|
||
|
||
// 动画阶段时间参数
|
||
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 var accelerationEnd: CGFloat { accelerationDuration / totalDuration }
|
||
private var constantSpeedEnd: CGFloat { (accelerationDuration + constantSpeedDuration) / totalDuration }
|
||
private var scaleStartEnd: CGFloat {
|
||
(accelerationDuration + constantSpeedDuration + scaleStartDuration) / totalDuration
|
||
}
|
||
|
||
// 布局与运动参数(核心:对称倾斜角度)
|
||
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 // 中间胶卷最大移动距离
|
||
|
||
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()
|
||
}
|
||
}
|
||
|
||
// MARK: - 动画逻辑
|
||
|
||
private func startAnimation() {
|
||
guard !isAnimating && !animationComplete else { return }
|
||
isAnimating = true
|
||
|
||
withAnimation(Animation.easeInOut(duration: totalDuration)) {
|
||
animationProgress = 1.0
|
||
}
|
||
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + totalDuration) {
|
||
isAnimating = false
|
||
animationComplete = true
|
||
}
|
||
}
|
||
|
||
// 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
|
||
} else {
|
||
return 1.0 // 6秒后停止移动
|
||
}
|
||
}
|
||
|
||
// 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 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 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
|
||
}
|
||
}
|
||
|
||
// MARK: - 胶卷组件
|
||
|
||
struct FilmReelView: View {
|
||
let images: [String]
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
ForEach(images.indices, id: \.self) { index in
|
||
FilmFrameView(imageName: images[index])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
)
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 预览
|
||
struct FilmAnimation_Previews: PreviewProvider {
|
||
static var previews: some View {
|
||
FilmAnimation()
|
||
}
|
||
}
|
||
|