refactor: 修改动画
This commit is contained in:
parent
5017594762
commit
5c25d0bf4c
@ -93,6 +93,11 @@
|
|||||||
- 2025-09-08 16:28 +08: 完成 polling-1:
|
- 2025-09-08 16:28 +08: 完成 polling-1:
|
||||||
- 新增 `wake/View/Blind/BlindBoxPolling.swift`,提供 `AsyncThrowingStream` 序列:`singleBox(boxId:)` 与 `firstUnopened()`,内部可取消(`Task.isCancelled`)并使用统一的间隔控制。
|
- 新增 `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`。
|
- `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`。
|
- 采用顶层 `NavigationStack + Router`,子页面取消 `NavigationView`。
|
||||||
|
|||||||
@ -39,7 +39,7 @@ final class BlindBoxViewModel: ObservableObject {
|
|||||||
|
|
||||||
func startPolling() async {
|
func startPolling() async {
|
||||||
// 如果已经是 Unopened,无需继续轮询
|
// 如果已经是 Unopened,无需继续轮询
|
||||||
if blindGenerate?.status == "Unopened" { return }
|
if blindGenerate?.status.lowercased() == "unopened" { return }
|
||||||
stopPolling()
|
stopPolling()
|
||||||
if let boxId = currentBoxId {
|
if let boxId = currentBoxId {
|
||||||
// Poll a single box until unopened
|
// Poll a single box until unopened
|
||||||
@ -107,11 +107,11 @@ final class BlindBoxViewModel: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
do {
|
do {
|
||||||
let list = try await BlindBoxApi.shared.getBlindBoxList()
|
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)
|
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
|
self.blindGenerate = item
|
||||||
if mediaType == .image {
|
if mediaType == .image {
|
||||||
self.imageURL = item.resultFile?.url ?? ""
|
self.imageURL = item.resultFile?.url ?? ""
|
||||||
@ -165,10 +165,51 @@ final class BlindBoxViewModel: ObservableObject {
|
|||||||
private func loadBlindCount() async {
|
private func loadBlindCount() async {
|
||||||
do {
|
do {
|
||||||
let list = try await BlindBoxApi.shared.getBlindBoxList()
|
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)
|
self.blindCount = BlindCount(availableQuantity: count)
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ 获取盲盒列表失败: \(error)")
|
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 showModal = false // 控制用户资料弹窗显示
|
||||||
@State private var showSettings = false // 控制设置页面显示
|
@State private var showSettings = false // 控制设置页面显示
|
||||||
@State private var showLogin = false
|
@State private var showLogin = false
|
||||||
// 按钮状态 倒计时
|
// 倒计时由 ViewModel 管理(countdownText)
|
||||||
@State private var countdown: (minutes: Int, seconds: Int, milliseconds: Int) = (36, 50, 0)
|
|
||||||
@State private var countdownTimer: Timer?
|
|
||||||
// 盲盒数据
|
// 盲盒数据
|
||||||
@State private var showScalingOverlay = false
|
@State private var showScalingOverlay = false
|
||||||
@State private var animationPhase: BlindBoxAnimationPhase = .none
|
@State private var animationPhase: BlindBoxAnimationPhase = .none
|
||||||
@ -99,32 +97,7 @@ struct BlindBoxView: View {
|
|||||||
_viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId))
|
_viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 倒计时
|
// 倒计时已迁移至 ViewModel
|
||||||
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 {
|
.onDisappear {
|
||||||
viewModel.stopPolling()
|
viewModel.stopPolling()
|
||||||
countdownTimer?.invalidate()
|
viewModel.stopCountdown()
|
||||||
countdownTimer = nil
|
|
||||||
|
|
||||||
// Clean up video player
|
// Clean up video player
|
||||||
videoPlayer?.pause()
|
videoPlayer?.pause()
|
||||||
@ -317,30 +289,26 @@ struct BlindBoxView: View {
|
|||||||
}
|
}
|
||||||
.onChange(of: animationPhase) { phase in
|
.onChange(of: animationPhase) { phase in
|
||||||
if phase != .loading {
|
if phase != .loading {
|
||||||
countdownTimer?.invalidate()
|
// 仅用于迁移前清理;现倒计时在 VM 中管理
|
||||||
countdownTimer = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.videoURL) { url in
|
.onChange(of: viewModel.videoURL) { url in
|
||||||
if !url.isEmpty {
|
if !url.isEmpty {
|
||||||
withAnimation { self.animationPhase = .ready }
|
withAnimation { self.animationPhase = .ready }
|
||||||
countdownTimer?.invalidate()
|
|
||||||
countdownTimer = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.imageURL) { url in
|
.onChange(of: viewModel.imageURL) { url in
|
||||||
if !url.isEmpty {
|
if !url.isEmpty {
|
||||||
withAnimation { self.animationPhase = .ready }
|
withAnimation { self.animationPhase = .ready }
|
||||||
countdownTimer?.invalidate()
|
|
||||||
countdownTimer = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.didBootstrap) { done in
|
.onChange(of: viewModel.didBootstrap) { done in
|
||||||
guard done else { return }
|
guard done else { return }
|
||||||
// 根据首帧状态决定初始动画态,避免先显示 loading 再跳到 ready 的割裂感
|
// 根据首帧状态决定初始动画态,避免先显示 loading 再跳到 ready 的割裂感
|
||||||
if viewModel.blindGenerate?.status.lowercased() == "unopened" {
|
let initialStatus = viewModel.blindGenerate?.status.lowercased() ?? ""
|
||||||
|
if initialStatus == "unopened" {
|
||||||
withAnimation { self.animationPhase = .ready }
|
withAnimation { self.animationPhase = .ready }
|
||||||
} else if viewModel.blindGenerate?.status.lowercased() == "preparing" {
|
} else if initialStatus == "preparing" {
|
||||||
withAnimation { self.animationPhase = .loading }
|
withAnimation { self.animationPhase = .loading }
|
||||||
} else {
|
} else {
|
||||||
// 若未知状态,保持 none;后续 onChange 会驱动到正确态
|
// 若未知状态,保持 none;后续 onChange 会驱动到正确态
|
||||||
@ -526,7 +494,7 @@ struct BlindBoxView: View {
|
|||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
switch animationPhase {
|
switch animationPhase {
|
||||||
case .loading:
|
case .loading:
|
||||||
GIFView(name: "BlindLoading")
|
LottieView(name: "loading")
|
||||||
.frame(width: 300, height: 300)
|
.frame(width: 300, height: 300)
|
||||||
// .onAppear {
|
// .onAppear {
|
||||||
// DispatchQueue.main.asyncAfter(deadline: .now() + 6) {
|
// DispatchQueue.main.asyncAfter(deadline: .now() + 6) {
|
||||||
@ -538,7 +506,7 @@ struct BlindBoxView: View {
|
|||||||
|
|
||||||
case .ready:
|
case .ready:
|
||||||
ZStack {
|
ZStack {
|
||||||
GIFView(name: "BlindReady")
|
LottieView(name: "data")
|
||||||
.frame(width: 300, height: 300)
|
.frame(width: 300, height: 300)
|
||||||
|
|
||||||
// Add a transparent overlay to capture taps
|
// Add a transparent overlay to capture taps
|
||||||
@ -548,21 +516,11 @@ struct BlindBoxView: View {
|
|||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
print("点击了盲盒")
|
print("点击了盲盒")
|
||||||
|
|
||||||
// 标记盲盒开启
|
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
|
||||||
if let boxId = self.currentBoxId {
|
if let boxId = boxIdToOpen {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
|
try await viewModel.openBlindBox(for: boxId)
|
||||||
print("✅ 盲盒开启成功")
|
|
||||||
} catch {
|
|
||||||
print("❌ 开启盲盒失败: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let boxId = self.viewModel.blindGenerate?.id {
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
|
|
||||||
print("✅ 盲盒开启成功")
|
print("✅ 盲盒开启成功")
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ 开启盲盒失败: \(error)")
|
print("❌ 开启盲盒失败: \(error)")
|
||||||
@ -578,10 +536,13 @@ struct BlindBoxView: View {
|
|||||||
|
|
||||||
case .opening:
|
case .opening:
|
||||||
ZStack {
|
ZStack {
|
||||||
GIFView(name: "BlindOpen")
|
if !showMedia {
|
||||||
.frame(width: 300, height: 300)
|
GIFView(name: "BlindOpen")
|
||||||
.scaleEffect(scale)
|
.frame(width: 300, height: 300)
|
||||||
.opacity(showMedia ? 0 : 1) // 当显示媒体时隐藏GIF
|
.scaleEffect(scale)
|
||||||
|
}
|
||||||
|
// 当显示媒体时,移除 GIFView 避免后台播放
|
||||||
|
Color.clear
|
||||||
.onAppear {
|
.onAppear {
|
||||||
print("开始播放开启动画")
|
print("开始播放开启动画")
|
||||||
// 初始缩放为1(原始大小)
|
// 初始缩放为1(原始大小)
|
||||||
@ -662,21 +623,11 @@ struct BlindBoxView: View {
|
|||||||
Button(action: {
|
Button(action: {
|
||||||
if animationPhase == .ready {
|
if animationPhase == .ready {
|
||||||
// 准备就绪点击,开启盲盒
|
// 准备就绪点击,开启盲盒
|
||||||
// 标记盲盒开启
|
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
|
||||||
if let boxId = self.currentBoxId {
|
if let boxId = boxIdToOpen {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
|
try await viewModel.openBlindBox(for: boxId)
|
||||||
print("✅ 盲盒开启成功")
|
|
||||||
} catch {
|
|
||||||
print("❌ 开启盲盒失败: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let boxId = self.viewModel.blindGenerate?.id {
|
|
||||||
Task {
|
|
||||||
do {
|
|
||||||
try await BlindBoxApi.shared.openBlindBox(boxId: boxId)
|
|
||||||
print("✅ 盲盒开启成功")
|
print("✅ 盲盒开启成功")
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ 开启盲盒失败: \(error)")
|
print("❌ 开启盲盒失败: \(error)")
|
||||||
@ -691,7 +642,7 @@ struct BlindBoxView: View {
|
|||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
if animationPhase == .loading {
|
if animationPhase == .loading {
|
||||||
Text("Next: \(countdown.minutes):\(String(format: "%02d", countdown.seconds))")
|
Text("Next: \(viewModel.countdownText)")
|
||||||
.font(Typography.font(for: .body))
|
.font(Typography.font(for: .body))
|
||||||
.fontWeight(.bold)
|
.fontWeight(.bold)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@ -699,9 +650,6 @@ struct BlindBoxView: View {
|
|||||||
.background(Color.white)
|
.background(Color.white)
|
||||||
.foregroundColor(.black)
|
.foregroundColor(.black)
|
||||||
.cornerRadius(32)
|
.cornerRadius(32)
|
||||||
.onAppear {
|
|
||||||
startCountdown()
|
|
||||||
}
|
|
||||||
} else if animationPhase == .ready {
|
} else if animationPhase == .ready {
|
||||||
Text("Ready")
|
Text("Ready")
|
||||||
.font(Typography.font(for: .body))
|
.font(Typography.font(for: .body))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user