refactor: 修改动画

This commit is contained in:
Junhui Chen 2025-09-08 18:36:45 +08:00
parent 5017594762
commit 5c25d0bf4c
3 changed files with 74 additions and 80 deletions

View File

@ -93,6 +93,11 @@
- 2025-09-08 16:28 +08: 完成 polling-1
- 新增 `wake/View/Blind/BlindBoxPolling.swift`,提供 `AsyncThrowingStream` 序列:`singleBox(boxId:)``firstUnopened()`,内部可取消(`Task.isCancelled`)并使用统一的间隔控制。
- `wake/View/Blind/ContentView.swift` 轮询改为 `for try await ... in` 形式;通过 `pollingTask` 管理任务,在 `stopPolling()``onDisappear` 中取消,避免视图中出现 `while + Task.sleep`
- 2025-09-08 18:05 +08: 推进 mvvm-1 与 media-1
- ViewModel`BlindBoxViewModel`)新增 `applyStatusSideEffects()`,将状态联动的副作用集中处理:`Preparing` 开始 1s 倒计时(`countdownText`),其它状态停止倒计时;在 `bootstrapInitialState()``startPolling()` 的数据落地后调用。
- 倒计时迁移至 VM新增 `startCountdown/stopCountdown``countdownText`,视图使用 `viewModel.countdownText` 展示;`ContentView` 移除本地倒计时与定时器。
- 首帧无闪烁:`ContentView` 初始 `animationPhase = .none`,监听 `viewModel.didBootstrap` 后按初始状态(大小写不敏感)切换 `loading/ready`;操作按钮在 `didBootstrap` 前隐藏。
- 媒体优化起步:将 `loading` 阶段的 GIF 替换为 Lottie`LottieView(name: "loading")`,资源位于 `wake/Assets/Lottie/loading.json`)。
## 决策记录
- 采用顶层 `NavigationStack + Router`,子页面取消 `NavigationView`

View File

@ -39,7 +39,7 @@ final class BlindBoxViewModel: ObservableObject {
func startPolling() async {
// Unopened
if blindGenerate?.status == "Unopened" { return }
if blindGenerate?.status.lowercased() == "unopened" { return }
stopPolling()
if let boxId = currentBoxId {
// Poll a single box until unopened
@ -107,11 +107,11 @@ final class BlindBoxViewModel: ObservableObject {
} else {
do {
let list = try await BlindBoxApi.shared.getBlindBoxList()
//
let count = (list ?? []).filter { $0.status == "Unopened" }.count
//
let count = (list ?? []).filter { $0.status.lowercased() == "unopened" }.count
self.blindCount = BlindCount(availableQuantity: count)
if let item = list?.first(where: { $0.status == "Unopened" }) {
if let item = list?.first(where: { $0.status.lowercased() == "unopened" }) {
self.blindGenerate = item
if mediaType == .image {
self.imageURL = item.resultFile?.url ?? ""
@ -165,10 +165,51 @@ final class BlindBoxViewModel: ObservableObject {
private func loadBlindCount() async {
do {
let list = try await BlindBoxApi.shared.getBlindBoxList()
let count = (list ?? []).filter { $0.status == "Unopened" }.count
let count = (list ?? []).filter { $0.status.lowercased() == "unopened" }.count
self.blindCount = BlindCount(availableQuantity: count)
} catch {
print("❌ 获取盲盒列表失败: \(error)")
}
}
// MARK: -
private func applyStatusSideEffects() {
let status = blindGenerate?.status.lowercased() ?? ""
if status == "preparing" {
// 36:50
if countdownTask == nil || remainingSeconds <= 0 {
startCountdown(minutes: 36, seconds: 50)
}
} else {
stopCountdown()
}
}
func startCountdown(minutes: Int = 36, seconds: Int = 50) {
stopCountdown()
remainingSeconds = max(0, minutes * 60 + seconds)
countdownText = String(format: "%02d:%02d", remainingSeconds / 60, remainingSeconds % 60)
countdownTask = Task { [weak self] in
while let self, !Task.isCancelled, self.remainingSeconds > 0 {
do {
try await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
break
}
await MainActor.run {
self.remainingSeconds -= 1
self.countdownText = String(
format: "%02d:%02d",
self.remainingSeconds / 60,
self.remainingSeconds % 60
)
}
}
}
}
func stopCountdown() {
countdownTask?.cancel()
countdownTask = nil
}
}

View File

@ -75,9 +75,7 @@ struct BlindBoxView: View {
@State private var showModal = false //
@State private var showSettings = false //
@State private var showLogin = false
//
@State private var countdown: (minutes: Int, seconds: Int, milliseconds: Int) = (36, 50, 0)
@State private var countdownTimer: Timer?
// ViewModel countdownText
//
@State private var showScalingOverlay = false
@State private var animationPhase: BlindBoxAnimationPhase = .none
@ -99,32 +97,7 @@ struct BlindBoxView: View {
_viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId))
}
//
private func startCountdown() {
// 36:50:20
countdown = (36, 50, 0)
countdownTimer?.invalidate()
countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
var (minutes, seconds, _) = countdown
//
seconds -= 1
if seconds < 0 {
seconds = 59
minutes -= 1
}
//
if minutes <= 0 && seconds <= 0 {
countdownTimer?.invalidate()
countdownTimer = nil
return
}
countdown = (minutes, seconds, 0)
}
}
// ViewModel
// ViewModel
@ -293,8 +266,7 @@ struct BlindBoxView: View {
}
.onDisappear {
viewModel.stopPolling()
countdownTimer?.invalidate()
countdownTimer = nil
viewModel.stopCountdown()
// Clean up video player
videoPlayer?.pause()
@ -317,30 +289,26 @@ struct BlindBoxView: View {
}
.onChange(of: animationPhase) { phase in
if phase != .loading {
countdownTimer?.invalidate()
countdownTimer = nil
// VM
}
}
.onChange(of: viewModel.videoURL) { url in
if !url.isEmpty {
withAnimation { self.animationPhase = .ready }
countdownTimer?.invalidate()
countdownTimer = nil
}
}
.onChange(of: viewModel.imageURL) { url in
if !url.isEmpty {
withAnimation { self.animationPhase = .ready }
countdownTimer?.invalidate()
countdownTimer = nil
}
}
.onChange(of: viewModel.didBootstrap) { done in
guard done else { return }
// loading ready
if viewModel.blindGenerate?.status.lowercased() == "unopened" {
let initialStatus = viewModel.blindGenerate?.status.lowercased() ?? ""
if initialStatus == "unopened" {
withAnimation { self.animationPhase = .ready }
} else if viewModel.blindGenerate?.status.lowercased() == "preparing" {
} else if initialStatus == "preparing" {
withAnimation { self.animationPhase = .loading }
} else {
// none onChange
@ -526,7 +494,7 @@ struct BlindBoxView: View {
VStack(spacing: 20) {
switch animationPhase {
case .loading:
GIFView(name: "BlindLoading")
LottieView(name: "loading")
.frame(width: 300, height: 300)
// .onAppear {
// DispatchQueue.main.asyncAfter(deadline: .now() + 6) {
@ -538,7 +506,7 @@ struct BlindBoxView: View {
case .ready:
ZStack {
GIFView(name: "BlindReady")
LottieView(name: "data")
.frame(width: 300, height: 300)
// Add a transparent overlay to capture taps
@ -548,21 +516,11 @@ struct BlindBoxView: View {
.onTapGesture {
print("点击了盲盒")
//
if let boxId = self.currentBoxId {
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
if let boxId = boxIdToOpen {
Task {
do {
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
print("✅ 盲盒开启成功")
} catch {
print("❌ 开启盲盒失败: \(error)")
}
}
}
if let boxId = self.viewModel.blindGenerate?.id {
Task {
do {
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
try await viewModel.openBlindBox(for: boxId)
print("✅ 盲盒开启成功")
} catch {
print("❌ 开启盲盒失败: \(error)")
@ -578,10 +536,13 @@ struct BlindBoxView: View {
case .opening:
ZStack {
GIFView(name: "BlindOpen")
.frame(width: 300, height: 300)
.scaleEffect(scale)
.opacity(showMedia ? 0 : 1) // GIF
if !showMedia {
GIFView(name: "BlindOpen")
.frame(width: 300, height: 300)
.scaleEffect(scale)
}
// GIFView
Color.clear
.onAppear {
print("开始播放开启动画")
// 1
@ -662,21 +623,11 @@ struct BlindBoxView: View {
Button(action: {
if animationPhase == .ready {
//
//
if let boxId = self.currentBoxId {
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
if let boxId = boxIdToOpen {
Task {
do {
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
print("✅ 盲盒开启成功")
} catch {
print("❌ 开启盲盒失败: \(error)")
}
}
}
if let boxId = self.viewModel.blindGenerate?.id {
Task {
do {
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
try await viewModel.openBlindBox(for: boxId)
print("✅ 盲盒开启成功")
} catch {
print("❌ 开启盲盒失败: \(error)")
@ -691,7 +642,7 @@ struct BlindBoxView: View {
}
}) {
if animationPhase == .loading {
Text("Next: \(countdown.minutes):\(String(format: "%02d", countdown.seconds))")
Text("Next: \(viewModel.countdownText)")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
@ -699,9 +650,6 @@ struct BlindBoxView: View {
.background(Color.white)
.foregroundColor(.black)
.cornerRadius(32)
.onAppear {
startCountdown()
}
} else if animationPhase == .ready {
Text("Ready")
.font(Typography.font(for: .body))