refactor: 修改动画
This commit is contained in:
parent
5017594762
commit
5c25d0bf4c
@ -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`。
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
if !showMedia {
|
||||
GIFView(name: "BlindOpen")
|
||||
.frame(width: 300, height: 300)
|
||||
.scaleEffect(scale)
|
||||
.opacity(showMedia ? 0 : 1) // 当显示媒体时隐藏GIF
|
||||
}
|
||||
// 当显示媒体时,移除 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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user