feat: 加速后匀速

This commit is contained in:
jinyaqiu 2025-08-24 12:20:49 +08:00
parent 0aa1271c93
commit 949b371b49
3 changed files with 366 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)
}
ZStack {
//
FilmCarouselView(images: reel1)
.rotationEffect(Angle(degrees: upperTilt))
.offset(x: upperTrackPosition, y: -verticalSpacing/2)
.zIndex(1)
//
if showTiltedReels {
FilmReelView(images: reelImages[2])
.rotationEffect(Angle(degrees: lowerTilt)) //
.offset(x: lowerReelXPosition, y: verticalSpacing/2)
.scaleEffect(tiltedScale)
.opacity(tiltedOpacity)
.zIndex(1)
}
//
FilmCarouselView(images: reel3)
.rotationEffect(Angle(degrees: lowerTilt))
.offset(x: lowerTrackPosition, y: verticalSpacing/2)
.zIndex(1)
//
FilmReelView(images: reelImages[1])
.offset(x: middleReelXPosition, y: 0)
.scaleEffect(middleScale)
.opacity(1.0)
.zIndex(2)
.edgesIgnoringSafeArea(.all)
}
)
.onAppear {
startAnimation()
}
//
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)
}
}

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

@ -0,0 +1,246 @@
import SwiftUI
import UIKit
//
struct FilmFrame: Identifiable {
let id = UUID()
let imageName: String
let systemImage: String
}
//
class FilmAnimationController: NSObject, ObservableObject {
@Published var offset: CGFloat = 0
var currentSpeed: CGFloat = 0
var lastUpdateTime: CFTimeInterval = 0
var startTime: CFTimeInterval = 0
var isAccelerating = true
var displayLink: CADisplayLink?
// -
let frames: [FilmFrame]
let rotationAngle: Double
let verticalOffset: 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
//
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, direction: Direction,
isMiddleReel: Bool = false,
initialSpeed: CGFloat = 100, //
maxSpeed: CGFloat = 600, // 400
accelerationDuration: Double = 4, //
frameWidth: CGFloat = 100, spacing: CGFloat = 10) {
self.frames = frames
self.rotationAngle = rotationAngle
self.verticalOffset = verticalOffset
self.direction = direction
self.initialSpeed = initialSpeed
self.maxSpeed = maxSpeed
self.accelerationDuration = accelerationDuration
self.frameWidth = frameWidth
self.spacing = spacing
self.isMiddleReel = isMiddleReel
let baseFramesCount = CGFloat(frames.count)
let adjustedFramesCount = isMiddleReel ? baseFramesCount * 1.5 : baseFramesCount
self.frameCycleWidth = adjustedFramesCount * (frameWidth + spacing)
super.init()
}
//
func startAnimation() {
offset = -frameCycleWidth / (isMiddleReel ? 1.5 : 1)
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
}
//
@objc private func updateAnimation(_ displayLink: CADisplayLink) {
let currentTime = CACurrentMediaTime()
let deltaTime = currentTime - lastUpdateTime
lastUpdateTime = currentTime
//
if isAccelerating {
let elapsedTime = currentTime - startTime
let progress = min(CGFloat(elapsedTime) / CGFloat(accelerationDuration), 1.0)
currentSpeed = initialSpeed + (maxSpeed - initialSpeed) * progress
if progress >= 1.0 {
isAccelerating = false
}
}
//
let distance = currentSpeed * CGFloat(deltaTime) * direction.multiplier
offset += distance
//
let cycleThreshold = isMiddleReel ? frameCycleWidth * 1.2 : frameCycleWidth * 2
if direction == .left {
while offset < -cycleThreshold {
offset += frameCycleWidth / (isMiddleReel ? 1.5 : 1)
}
} else {
while offset > 0 {
offset -= frameCycleWidth
}
}
}
}
//
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) {
_animationController = StateObject(wrappedValue: FilmAnimationController(
frames: frames,
rotationAngle: rotationAngle,
verticalOffset: verticalOffset,
direction: direction,
isMiddleReel: isMiddleReel
))
self.zIndex = zIndex
}
var body: some View {
let containerWidth = animationController.frameCycleWidth * 4
ZStack {
Color.clear
.frame(width: containerWidth)
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)
}
.frame(width: animationController.frameWidth, height: 150)
.shadow(radius: 3)
}
}
.offset(x: animationController.offset, y: animationController.verticalOffset)
.onAppear {
animationController.startAnimation()
}
.onDisappear {
animationController.stopAnimation()
}
}
.rotationEffect(.degrees(animationController.rotationAngle))
.zIndex(zIndex)
}
}
//
struct Box7: View {
//
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")
]
var body: some View {
ZStack(alignment: .center) {
//
FilmReelView(
frames: filmFrames,
rotationAngle: 6,
verticalOffset: -120,
direction: .right,
zIndex: 1
)
//
FilmReelView(
frames: filmFrames,
rotationAngle: 0,
verticalOffset: 0,
direction: .left,
isMiddleReel: true,
zIndex: 0
)
//
FilmReelView(
frames: filmFrames,
rotationAngle: -6,
verticalOffset: 120,
direction: .right,
zIndex: 2
)
}
.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)
}
}
//
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()
}