fix: 盲盒展示
This commit is contained in:
parent
0ab33cab47
commit
411bf440d4
@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
// 盲盒动画阶段
|
||||
enum BlindBoxAnimationPhase {
|
||||
case loading
|
||||
case ready
|
||||
case opening
|
||||
case none
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
import Lottie
|
||||
|
||||
/// 统一管理盲盒开启动画 4 状态的组件:loading / ready / opening / none
|
||||
struct BlindBoxAnimationView: View {
|
||||
@Binding var phase: BlindBoxAnimationPhase
|
||||
let onTapReady: () -> Void
|
||||
let onOpeningCompleted: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
switch phase {
|
||||
case .loading:
|
||||
LottieView(name: "loading", isPlaying: true)
|
||||
case .ready:
|
||||
ZStack {
|
||||
LottieView(name: "ready", isPlaying: true)
|
||||
Color.clear
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onTapReady()
|
||||
}
|
||||
}
|
||||
case .opening:
|
||||
BlindBoxLottieOnceView(name: "opening") {
|
||||
onOpeningCompleted()
|
||||
}
|
||||
case .none:
|
||||
Image("Empty")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
}
|
||||
.frame(width: 300, height: 300)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import SwiftUI
|
||||
import Lottie
|
||||
|
||||
/// 仅播放一次并在完成时回调的 Lottie 视图
|
||||
struct BlindBoxLottieOnceView: UIViewRepresentable {
|
||||
let name: String
|
||||
var animationSpeed: CGFloat = 1.0
|
||||
let onCompleted: () -> Void
|
||||
|
||||
func makeUIView(context: Context) -> LottieAnimationView {
|
||||
let animationView = LottieAnimationView()
|
||||
if let animation = LottieAnimation.named(name) {
|
||||
animationView.animation = animation
|
||||
} else if let path = Bundle.main.path(forResource: name, ofType: "json") {
|
||||
let animation = LottieAnimation.filepath(path)
|
||||
animationView.animation = animation
|
||||
}
|
||||
animationView.loopMode = .playOnce
|
||||
animationView.animationSpeed = animationSpeed
|
||||
animationView.contentMode = .scaleAspectFit
|
||||
animationView.backgroundBehavior = .pauseAndRestore
|
||||
animationView.play { _ in
|
||||
onCompleted()
|
||||
}
|
||||
return animationView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: LottieAnimationView, context: Context) {
|
||||
// 单次播放,不需要在更新时重复触发
|
||||
}
|
||||
}
|
||||
@ -8,13 +8,6 @@ extension Notification.Name {
|
||||
static let blindBoxStatusChanged = Notification.Name("blindBoxStatusChanged")
|
||||
}
|
||||
|
||||
internal enum BlindBoxAnimationPhase {
|
||||
case loading
|
||||
case ready
|
||||
case opening
|
||||
case none
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let navigateToMediaViewer = Notification.Name("navigateToMediaViewer")
|
||||
}
|
||||
@ -28,15 +21,9 @@ struct BlindBoxView: View {
|
||||
@State private var showSettings = false // 控制设置页面显示
|
||||
@State private var showLogin = false
|
||||
// 倒计时由 ViewModel 管理(countdownText)
|
||||
// 盲盒数据
|
||||
@State private var showScalingOverlay = false
|
||||
@State private var animationPhase: BlindBoxAnimationPhase = .none
|
||||
@State private var scale: CGFloat = 0.1
|
||||
// showControls 状态已迁移至 BlindBoxMediaOverlay 组件内
|
||||
@State private var isAnimating = true
|
||||
@State private var showMedia = false
|
||||
|
||||
// 查询数据 - 简单查询
|
||||
// 查询数据 - 简单查询
|
||||
@Query private var login: [Login]
|
||||
|
||||
init(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) {
|
||||
@ -45,16 +32,7 @@ struct BlindBoxView: View {
|
||||
_viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId))
|
||||
}
|
||||
|
||||
private func startScalingAnimation() {
|
||||
self.scale = 0.1
|
||||
self.showScalingOverlay = true
|
||||
|
||||
withAnimation(.spring(response: 2.0, dampingFraction: 0.5, blendDuration: 0.8)) {
|
||||
self.scale = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
// 计算尺寸逻辑已迁移至 BlindBoxMediaOverlay 组件
|
||||
// 计算尺寸逻辑已迁移至 BlindBoxMediaOverlay 组件(已不再使用)
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@ -100,12 +78,12 @@ struct BlindBoxView: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.videoURL) { _, url in
|
||||
if !url.isEmpty {
|
||||
if !url.isEmpty && self.animationPhase != .opening {
|
||||
withAnimation { self.animationPhase = .ready }
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.imageURL) { _, url in
|
||||
if !url.isEmpty {
|
||||
if !url.isEmpty && self.animationPhase != .opening {
|
||||
withAnimation { self.animationPhase = .ready }
|
||||
}
|
||||
}
|
||||
@ -123,243 +101,144 @@ struct BlindBoxView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if showScalingOverlay {
|
||||
BlindBoxMediaOverlay(
|
||||
mediaType: mediaType,
|
||||
player: .init(get: { viewModel.player }, set: { viewModel.player = $0 }),
|
||||
displayImage: viewModel.displayImage,
|
||||
isPortrait: viewModel.isPortrait,
|
||||
aspectRatio: viewModel.aspectRatio,
|
||||
scale: $scale,
|
||||
onBack: {
|
||||
if mediaType == .all, !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.videoURL) {
|
||||
Router.shared.navigate(to: .blindOutcome(media: .video(url, nil), time: viewModel.blindGenerate?.name ?? "Your box", description: viewModel.blindGenerate?.description ?? "", isMember: viewModel.isMember))
|
||||
} else if mediaType == .image, let image = viewModel.displayImage {
|
||||
Router.shared.navigate(to: .blindOutcome(media: .image(image), time: viewModel.blindGenerate?.name ?? "Your box", description: viewModel.blindGenerate?.description ?? "", isMember: viewModel.isMember))
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Original content
|
||||
VStack {
|
||||
VStack(spacing: 20) {
|
||||
if mediaType == .all {
|
||||
BlindBoxHeaderBar(
|
||||
onMenuTap: showUserProfile,
|
||||
remainPoints: viewModel.memberProfile?.remainPoints ?? 0,
|
||||
showLogin: $showLogin
|
||||
)
|
||||
}
|
||||
|
||||
// 标题
|
||||
BlindBoxTitleView()
|
||||
.opacity(showScalingOverlay ? 0 : 1)
|
||||
.offset(y: showScalingOverlay ? -UIScreen.main.bounds.height * 0.2 : 0)
|
||||
.animation(.easeInOut(duration: 0.5), value: showScalingOverlay)
|
||||
|
||||
// 盲盒
|
||||
ZStack {
|
||||
// 1. 背景Card
|
||||
if !showScalingOverlay {
|
||||
CardBlindBackground()
|
||||
}
|
||||
if mediaType == .all && !showScalingOverlay {
|
||||
BlindCountBadge(text: "\(viewModel.blindCount?.availableQuantity ?? 0) Boxes")
|
||||
.position(x: UIScreen.main.bounds.width * 0.7,
|
||||
y: UIScreen.main.bounds.height * 0.18)
|
||||
.opacity(showScalingOverlay ? 0 : 1)
|
||||
.animation(.easeOut(duration: 1.5), value: showScalingOverlay)
|
||||
}
|
||||
if !showScalingOverlay {
|
||||
VStack(spacing: 20) {
|
||||
switch animationPhase {
|
||||
case .loading:
|
||||
LottieView(name: "ready", isPlaying: animationPhase == .loading && !showScalingOverlay)
|
||||
.frame(width: 300, height: 300)
|
||||
// .onAppear {
|
||||
// DispatchQueue.main.asyncAfter(deadline: .now() + 6) {
|
||||
// withAnimation {
|
||||
// animationPhase = .ready
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
case .ready:
|
||||
ZStack {
|
||||
LottieView(name: "ready", isPlaying: animationPhase == .ready && !showScalingOverlay)
|
||||
.frame(width: 300, height: 300)
|
||||
|
||||
// Add a transparent overlay to capture taps
|
||||
Color.clear
|
||||
.contentShape(Rectangle()) // Make the entire area tappable
|
||||
.frame(width: 300, height: 300)
|
||||
.onTapGesture {
|
||||
Perf.event("BlindBox_Open_Tapped")
|
||||
print("点击了盲盒")
|
||||
|
||||
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
|
||||
if let boxId = boxIdToOpen {
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.openBlindBox(for: boxId)
|
||||
print("✅ 盲盒开启成功")
|
||||
} catch {
|
||||
print("❌ 开启盲盒失败: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
withAnimation {
|
||||
animationPhase = .opening
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 300, height: 300)
|
||||
|
||||
case .opening:
|
||||
ZStack {
|
||||
if !showMedia {
|
||||
LottieView(name: "ready", loopMode: .playOnce, isPlaying: !showMedia)
|
||||
.frame(width: 300, height: 300)
|
||||
.scaleEffect(scale)
|
||||
}
|
||||
// 当显示媒体时,移除 GIFView 避免后台播放
|
||||
Color.clear
|
||||
.onAppear {
|
||||
Perf.event("BlindBox_Opening_Begin")
|
||||
print("开始播放开启动画")
|
||||
// 初始缩放为1(原始大小)
|
||||
self.scale = 1.0
|
||||
|
||||
// 1秒后开始全屏动画
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
withAnimation(.spring(response: 1.0, dampingFraction: 0.7)) {
|
||||
// 缩放到全屏
|
||||
self.scale = max(
|
||||
UIScreen.main.bounds.width / 300,
|
||||
UIScreen.main.bounds.height / 300
|
||||
) * 1.2
|
||||
|
||||
// 全屏后稍作停留,然后缩小回原始大小
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
withAnimation(.spring(response: 0.8, dampingFraction: 0.7)) {
|
||||
self.scale = 1.0
|
||||
|
||||
// 显示媒体内容
|
||||
Perf.event("BlindBox_Opening_ShowMedia")
|
||||
self.showScalingOverlay = true
|
||||
Task { await viewModel.prepareMedia() }
|
||||
|
||||
// 标记显示媒体,隐藏GIF
|
||||
self.showMedia = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 300, height: 300)
|
||||
|
||||
case .none:
|
||||
// 首帧占位,避免加载时闪烁
|
||||
LottieView(name: "ready", loopMode: .loop, isPlaying: true)
|
||||
.frame(width: 300, height: 300)
|
||||
.scaleEffect(scale)
|
||||
// Color.clear
|
||||
// .frame(width: 300, height: 300)
|
||||
// SVGImage(svgName: "BlindNone")
|
||||
// .frame(width: 300, height: 300)
|
||||
}
|
||||
}
|
||||
.offset(y: -50)
|
||||
.compositingGroup()
|
||||
.padding()
|
||||
}
|
||||
// 只在未显示媒体且未播放动画时显示文字
|
||||
if !showScalingOverlay && !showMedia {
|
||||
BlindBoxDescriptionView(
|
||||
name: viewModel.blindGenerate?.name ?? "Some box",
|
||||
description: viewModel.blindGenerate?.description ?? ""
|
||||
)
|
||||
.offset(x: -10, y: UIScreen.main.bounds.height * 0.2)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: UIScreen.main.bounds.height * 0.65
|
||||
// 原 overlay 分支已移除,直接展示内容
|
||||
// Original content
|
||||
VStack {
|
||||
VStack(spacing: 20) {
|
||||
if mediaType == .all {
|
||||
BlindBoxHeaderBar(
|
||||
onMenuTap: showUserProfile,
|
||||
remainPoints: viewModel.memberProfile?.remainPoints ?? 0,
|
||||
showLogin: $showLogin
|
||||
)
|
||||
.opacity(showScalingOverlay ? 0 : 1)
|
||||
.animation(.easeOut(duration: 1.5), value: showScalingOverlay)
|
||||
.offset(y: showScalingOverlay ? -100 : 0)
|
||||
.animation(.easeInOut(duration: 1.5), value: showScalingOverlay)
|
||||
}
|
||||
|
||||
// 打开 TODO 引导时,也要有按钮
|
||||
if mediaType == .all, viewModel.didBootstrap {
|
||||
BlindBoxActionButton(
|
||||
phase: animationPhase,
|
||||
countdownText: viewModel.countdownText,
|
||||
onOpen: {
|
||||
// 标题
|
||||
BlindBoxTitleView()
|
||||
.opacity(animationPhase == .opening ? 0 : 1)
|
||||
|
||||
// 盲盒
|
||||
ZStack {
|
||||
// 1. 背景Card
|
||||
CardBlindBackground()
|
||||
if mediaType == .all {
|
||||
BlindCountBadge(text: "\(viewModel.blindCount?.availableQuantity ?? 0) Boxes")
|
||||
.position(x: UIScreen.main.bounds.width * 0.7,
|
||||
y: UIScreen.main.bounds.height * 0.18)
|
||||
}
|
||||
VStack(spacing: 20) {
|
||||
BlindBoxAnimationView(
|
||||
phase: $animationPhase,
|
||||
onTapReady: {
|
||||
Perf.event("BlindBox_Open_Tapped")
|
||||
print("点击了盲盒")
|
||||
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
|
||||
if let boxId = boxIdToOpen {
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.openBlindBox(for: boxId)
|
||||
print("✅ 盲盒开启成功")
|
||||
await viewModel.startPolling()
|
||||
withAnimation {
|
||||
animationPhase = .opening
|
||||
}
|
||||
} catch {
|
||||
print("❌ 开启盲盒失败: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
withAnimation {
|
||||
animationPhase = .opening
|
||||
}
|
||||
},
|
||||
onGoToBuy: {
|
||||
Router.shared.navigate(to: .mediaUpload)
|
||||
onOpeningCompleted: {
|
||||
navigateToOutcome()
|
||||
}
|
||||
)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.offset(y: -50)
|
||||
.compositingGroup()
|
||||
.padding()
|
||||
// 非 opening 阶段显示文字
|
||||
if animationPhase != .opening {
|
||||
BlindBoxDescriptionView(
|
||||
name: viewModel.blindGenerate?.name ?? "Some box",
|
||||
description: viewModel.blindGenerate?.description ?? ""
|
||||
)
|
||||
.offset(x: -10, y: UIScreen.main.bounds.height * 0.2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
.offset(x: showModal ? UIScreen.main.bounds.width * 0.8 : 0)
|
||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showModal)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
}
|
||||
|
||||
// 用户资料弹窗
|
||||
SlideInModal(
|
||||
isPresented: $showModal,
|
||||
onDismiss: hideUserProfile
|
||||
) {
|
||||
UserProfileModal(
|
||||
showModal: $showModal,
|
||||
showSettings: $showSettings,
|
||||
isMember: .init(get: { viewModel.isMember }, set: { viewModel.isMember = $0 }),
|
||||
memberDate: .init(get: { viewModel.memberDate }, set: { viewModel.memberDate = $0 })
|
||||
.padding()
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
maxHeight: UIScreen.main.bounds.height * 0.65
|
||||
)
|
||||
}
|
||||
.offset(x: showSettings ? UIScreen.main.bounds.width : 0)
|
||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
|
||||
|
||||
// 设置页面遮罩层
|
||||
ZStack {
|
||||
if showSettings {
|
||||
Color.black.opacity(0.3)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.onTapGesture(perform: hideSettings)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if showSettings {
|
||||
SettingsView(isPresented: $showSettings)
|
||||
.transition(.move(edge: .leading))
|
||||
.zIndex(1)
|
||||
// 打开 TODO 引导时,也要有按钮
|
||||
if mediaType == .all, viewModel.didBootstrap {
|
||||
BlindBoxActionButton(
|
||||
phase: animationPhase,
|
||||
countdownText: viewModel.countdownText,
|
||||
onOpen: {
|
||||
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
|
||||
if let boxId = boxIdToOpen {
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.openBlindBox(for: boxId)
|
||||
print("✅ 盲盒开启成功")
|
||||
await viewModel.startPolling()
|
||||
withAnimation {
|
||||
animationPhase = .opening
|
||||
}
|
||||
} catch {
|
||||
print("❌ 开启盲盒失败: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onGoToBuy: {
|
||||
Router.shared.navigate(to: .mediaUpload)
|
||||
}
|
||||
)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.themeTextWhiteSecondary)
|
||||
.offset(x: showModal ? UIScreen.main.bounds.width * 0.8 : 0)
|
||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showModal)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
}
|
||||
|
||||
// 用户资料弹窗
|
||||
SlideInModal(
|
||||
isPresented: $showModal,
|
||||
onDismiss: hideUserProfile
|
||||
) {
|
||||
UserProfileModal(
|
||||
showModal: $showModal,
|
||||
showSettings: $showSettings,
|
||||
isMember: .init(get: { viewModel.isMember }, set: { viewModel.isMember = $0 }),
|
||||
memberDate: .init(get: { viewModel.memberDate }, set: { viewModel.memberDate = $0 })
|
||||
)
|
||||
}
|
||||
.offset(x: showSettings ? UIScreen.main.bounds.width : 0)
|
||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
|
||||
|
||||
// 设置页面遮罩层
|
||||
ZStack {
|
||||
if showSettings {
|
||||
Color.black.opacity(0.3)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.onTapGesture(perform: hideSettings)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if showSettings {
|
||||
SettingsView(isPresented: $showSettings)
|
||||
.transition(.move(edge: .leading))
|
||||
.zIndex(1)
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showSettings)
|
||||
}
|
||||
.navigationBarBackButtonHidden(true)
|
||||
}
|
||||
@ -374,7 +253,7 @@ struct BlindBoxView: View {
|
||||
for (index, item) in login.enumerated() {
|
||||
print("记录 \(index + 1): 邮箱=\(item.email), 姓名=\(item.name)")
|
||||
}
|
||||
showModal.toggle()
|
||||
showModal.toggle()
|
||||
}
|
||||
}
|
||||
|
||||
@ -391,6 +270,58 @@ struct BlindBoxView: View {
|
||||
showSettings = false
|
||||
}
|
||||
}
|
||||
|
||||
/// 开启动画播放完成后,准备媒体并跳转到结果页
|
||||
private func navigateToOutcome() {
|
||||
Perf.event("BlindBox_Opening_Completed")
|
||||
Task { @MainActor in
|
||||
let interval: UInt64 = 300_000_000 // 300ms
|
||||
let timeout: UInt64 = 6_000_000_000 // 6s
|
||||
var waited: UInt64 = 0
|
||||
|
||||
if mediaType == .all {
|
||||
// 等待视频 URL 就绪
|
||||
while viewModel.videoURL.isEmpty && waited < timeout {
|
||||
try? await Task.sleep(nanoseconds: interval)
|
||||
waited += interval
|
||||
}
|
||||
// 拿到 URL 即可跳转;不强依赖 player 准备
|
||||
if !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.videoURL) {
|
||||
Router.shared.navigate(
|
||||
to: .blindOutcome(
|
||||
media: .video(url, nil),
|
||||
time: viewModel.blindGenerate?.name ?? "Your box",
|
||||
description: viewModel.blindGenerate?.description ?? "",
|
||||
isMember: viewModel.isMember
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
} else if mediaType == .image {
|
||||
// 等到有 imageURL 后再加载 UIImage
|
||||
while viewModel.imageURL.isEmpty && waited < timeout {
|
||||
try? await Task.sleep(nanoseconds: interval)
|
||||
waited += interval
|
||||
}
|
||||
if viewModel.displayImage == nil && !viewModel.imageURL.isEmpty {
|
||||
await viewModel.prepareMedia()
|
||||
}
|
||||
if let image = viewModel.displayImage {
|
||||
Router.shared.navigate(
|
||||
to: .blindOutcome(
|
||||
media: .image(image),
|
||||
time: viewModel.blindGenerate?.name ?? "Your box",
|
||||
description: viewModel.blindGenerate?.description ?? "",
|
||||
isMember: viewModel.isMember
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 若仍未获取到媒体,记录日志以便排查
|
||||
print("⚠️ navigateToOutcome: 媒体尚未准备好,videoURL=\(viewModel.videoURL), image=\(String(describing: viewModel.displayImage))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 预览
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user