refactor: 进一步拆解BlindBoxView

This commit is contained in:
Junhui Chen 2025-09-11 17:06:12 +08:00
parent 1e57f993c2
commit 0ab33cab47
9 changed files with 324 additions and 321 deletions

View File

@ -0,0 +1,20 @@
import SwiftUI
import AVKit
// AVPlayer
struct AVPlayerController: UIViewControllerRepresentable {
@Binding var player: AVPlayer?
func makeUIViewController(context: Context) -> AVPlayerViewController {
let controller = AVPlayerViewController()
controller.player = player
controller.showsPlaybackControls = false
controller.videoGravity = .resizeAspect
controller.view.backgroundColor = .clear
return controller
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
uiViewController.player = player
}
}

View File

@ -0,0 +1,53 @@
import SwiftUI
struct BlindBoxActionButton: View {
let phase: BlindBoxAnimationPhase
let countdownText: String
let onOpen: () -> Void
let onGoToBuy: () -> Void
var body: some View {
Button(action: {
switch phase {
case .ready:
onOpen()
case .none:
onGoToBuy()
default:
break
}
}) {
Group {
switch phase {
case .loading:
Text("Next: \(countdownText)")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.white)
.foregroundColor(.black)
.cornerRadius(32)
case .ready:
Text("Ready")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.themePrimary)
.foregroundColor(Color.themeTextMessageMain)
.cornerRadius(32)
default:
Text("Go to Buy")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.themePrimary)
.foregroundColor(Color.themeTextMessageMain)
.cornerRadius(32)
}
}
}
}
}

View File

@ -0,0 +1,19 @@
import SwiftUI
struct BlindBoxDescriptionView: View {
let name: String
let description: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(name)
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(Color.themeTextMessageMain)
Text(description)
.font(.system(size: 14))
.foregroundColor(Color.themeTextMessageMain)
}
.frame(width: UIScreen.main.bounds.width * 0.70, alignment: .leading)
.padding()
}
}

View File

@ -0,0 +1,37 @@
import SwiftUI
struct BlindBoxHeaderBar: View {
let onMenuTap: () -> Void
let remainPoints: Int
@Binding var showLogin: Bool
var body: some View {
HStack {
Button(action: onMenuTap) {
Image(systemName: "line.3.horizontal")
.font(.system(size: 20, weight: .regular))
.foregroundColor(.primary)
.padding(13)
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
Spacer()
NavigationLink(destination: SubscribeView()) {
Text("\(remainPoints)")
.font(Typography.font(for: .subtitle))
.fontWeight(.bold)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.black)
.foregroundColor(.white)
.cornerRadius(16)
}
.padding(.trailing)
.fullScreenCover(isPresented: $showLogin) {
LoginView()
}
}
.padding(.horizontal)
.padding(.top, 20)
}
}

View File

@ -0,0 +1,98 @@
import SwiftUI
import UIKit
import AVKit
// /
struct BlindBoxMediaOverlay: View {
let mediaType: BlindBoxMediaType
@Binding var player: AVPlayer?
let displayImage: UIImage?
let isPortrait: Bool
let aspectRatio: CGFloat
@Binding var scale: CGFloat
let onBack: () -> Void
@State private var showControls: Bool = false
private var scaledWidth: CGFloat {
if isPortrait {
return UIScreen.main.bounds.height * scale * 1 / aspectRatio
} else {
return UIScreen.main.bounds.width * scale
}
}
private var scaledHeight: CGFloat {
if isPortrait {
return UIScreen.main.bounds.height * scale
} else {
return UIScreen.main.bounds.width * scale * 1 / aspectRatio
}
}
var body: some View {
ZStack {
VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialLight))
.opacity(0.3)
.edgesIgnoringSafeArea(.all)
Group {
if mediaType == .all, player != nil {
AVPlayerController(player: $player)
.frame(width: scaledWidth, height: scaledHeight)
.opacity(scale == 1 ? 1 : 0.7)
.onAppear { player?.play() }
} else if mediaType == .image, let image = displayImage {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(width: scaledWidth, height: scaledHeight)
.opacity(scale == 1 ? 1 : 0.7)
}
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.easeInOut(duration: 0.1)) {
showControls.toggle()
}
}
if showControls {
VStack {
HStack {
Button(action: onBack) {
Image(systemName: "chevron.left")
.font(.system(size: 24))
.foregroundColor(.black)
}
Spacer()
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 50)
.padding(.leading, 20)
.zIndex(1000)
.transition(.opacity)
.onAppear {
// 2
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation(.easeInOut(duration: 0.3)) {
showControls = true
}
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.animation(.easeInOut(duration: 1.0), value: scale)
.ignoresSafeArea()
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.spring(response: 2.5, dampingFraction: 0.6, blendDuration: 1.0)) {
self.scale = 1.0
}
}
}
}
}

View File

@ -0,0 +1,15 @@
import SwiftUI
struct BlindBoxTitleView: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Hi! Click And")
Text("Open Your Box~")
}
.font(Typography.font(for: .smallLargeTitle))
.fontWeight(.bold)
.foregroundColor(Color.themeTextMessageMain)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
}
}

View File

@ -0,0 +1,19 @@
import SwiftUI
//
struct BlindCountBadge: View {
let text: String
var body: some View {
Text(text)
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
Capsule()
.fill(Color.black)
.shadow(color: Color.black.opacity(0.15), radius: 4, x: 0, y: 2)
)
}
}

View File

@ -0,0 +1,30 @@
import SwiftUI
import UIKit
//
struct VisualEffectView: UIViewRepresentable {
var effect: UIVisualEffect?
func makeUIView(context: Context) -> UIVisualEffectView {
let view = UIVisualEffectView(effect: nil)
let blurEffect = UIBlurEffect(style: .systemUltraThinMaterialLight)
let blurView = UIVisualEffectView(effect: blurEffect)
blurView.alpha = 0.3
let backgroundView = UIView()
backgroundView.backgroundColor = UIColor.white.withAlphaComponent(0.1)
backgroundView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.contentView.addSubview(backgroundView)
view.contentView.addSubview(blurView)
blurView.frame = view.bounds
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
return view
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
//
}
}

View File

@ -8,7 +8,7 @@ extension Notification.Name {
static let blindBoxStatusChanged = Notification.Name("blindBoxStatusChanged") static let blindBoxStatusChanged = Notification.Name("blindBoxStatusChanged")
} }
private enum BlindBoxAnimationPhase { internal enum BlindBoxAnimationPhase {
case loading case loading
case ready case ready
case opening case opening
@ -20,54 +20,6 @@ extension Notification.Name {
} }
// MARK: - // MARK: -
struct VisualEffectView: UIViewRepresentable {
var effect: UIVisualEffect?
func makeUIView(context: Context) -> UIVisualEffectView {
let view = UIVisualEffectView(effect: nil)
// Use a simpler approach without animator
let blurEffect = UIBlurEffect(style: .systemUltraThinMaterialLight)
// Create a custom blur effect with reduced intensity
let blurView = UIVisualEffectView(effect: blurEffect)
blurView.alpha = 0.3 // Reduce intensity
// Add a white background with low opacity for better frosted effect
let backgroundView = UIView()
backgroundView.backgroundColor = UIColor.white.withAlphaComponent(0.1)
backgroundView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.contentView.addSubview(backgroundView)
view.contentView.addSubview(blurView)
blurView.frame = view.bounds
blurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
return view
}
func updateUIView(_ uiView: UIVisualEffectView, context: Context) {
// No need to update the effect
}
}
struct AVPlayerController: UIViewControllerRepresentable {
@Binding var player: AVPlayer?
func makeUIViewController(context: Context) -> AVPlayerViewController {
let controller = AVPlayerViewController()
controller.player = player
controller.showsPlaybackControls = false
controller.videoGravity = .resizeAspect
controller.view.backgroundColor = .clear
return controller
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
uiViewController.player = player
}
}
struct BlindBoxView: View { struct BlindBoxView: View {
let mediaType: BlindBoxMediaType let mediaType: BlindBoxMediaType
let currentBoxId: String? let currentBoxId: String?
@ -80,7 +32,7 @@ struct BlindBoxView: View {
@State private var showScalingOverlay = false @State private var showScalingOverlay = false
@State private var animationPhase: BlindBoxAnimationPhase = .none @State private var animationPhase: BlindBoxAnimationPhase = .none
@State private var scale: CGFloat = 0.1 @State private var scale: CGFloat = 0.1
@State private var showControls = false // showControls BlindBoxMediaOverlay
@State private var isAnimating = true @State private var isAnimating = true
@State private var showMedia = false @State private var showMedia = false
@ -93,18 +45,6 @@ struct BlindBoxView: View {
_viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId)) _viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId))
} }
// ViewModel
// ViewModel
// ViewModel
// ViewModel
// ViewModel
// ViewModel.prepareMedia()
private func startScalingAnimation() { private func startScalingAnimation() {
self.scale = 0.1 self.scale = 0.1
self.showScalingOverlay = true self.showScalingOverlay = true
@ -114,22 +54,7 @@ struct BlindBoxView: View {
} }
} }
// MARK: - Computed Properties // BlindBoxMediaOverlay
private var scaledWidth: CGFloat {
if viewModel.isPortrait {
return UIScreen.main.bounds.height * scale * 1/viewModel.aspectRatio
} else {
return UIScreen.main.bounds.width * scale
}
}
private var scaledHeight: CGFloat {
if viewModel.isPortrait {
return UIScreen.main.bounds.height * scale
} else {
return UIScreen.main.bounds.width * scale * 1/viewModel.aspectRatio
}
}
var body: some View { var body: some View {
ZStack { ZStack {
@ -139,40 +64,6 @@ struct BlindBoxView: View {
print("🎯 BlindBoxView appeared with mediaType: \(mediaType)") print("🎯 BlindBoxView appeared with mediaType: \(mediaType)")
print("🎯 Current thread: \(Thread.current)") print("🎯 Current thread: \(Thread.current)")
//
// if mediaType == .all, let firstItem = blindList.first {
// displayData = BlindBoxData(from: firstItem)
// } else {
// displayData = blindGenerate
// }
//
// NotificationCenter.default.addObserver(
// forName: .blindBoxStatusChanged,
// object: nil,
// queue: .main
// ) { notification in
// if let status = notification.userInfo?["status"] as? String {
// switch status {
// case "Preparing":
// withAnimation {
// self.animationPhase = .loading
// }
// case "Unopened":
// withAnimation {
// self.animationPhase = .ready
// }
// default:
// //
// withAnimation {
// self.animationPhase = .ready
// }
// break
// }
// }
// }
// //
Task { Task {
await viewModel.load() await viewModel.load()
@ -233,151 +124,35 @@ struct BlindBoxView: View {
} }
if showScalingOverlay { if showScalingOverlay {
ZStack { BlindBoxMediaOverlay(
VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialLight)) mediaType: mediaType,
.opacity(0.3) player: .init(get: { viewModel.player }, set: { viewModel.player = $0 }),
.edgesIgnoringSafeArea(.all) displayImage: viewModel.displayImage,
isPortrait: viewModel.isPortrait,
Group { aspectRatio: viewModel.aspectRatio,
if mediaType == .all, viewModel.player != nil { scale: $scale,
// Video Player onBack: {
AVPlayerController(player: .init(get: { viewModel.player }, set: { viewModel.player = $0 })) if mediaType == .all, !viewModel.videoURL.isEmpty, let url = URL(string: viewModel.videoURL) {
.frame(width: scaledWidth, height: scaledHeight) Router.shared.navigate(to: .blindOutcome(media: .video(url, nil), time: viewModel.blindGenerate?.name ?? "Your box", description: viewModel.blindGenerate?.description ?? "", isMember: viewModel.isMember))
.opacity(scale == 1 ? 1 : 0.7)
.onAppear { viewModel.player?.play() }
} else if mediaType == .image, let image = viewModel.displayImage { } else if mediaType == .image, let image = viewModel.displayImage {
// Image View Router.shared.navigate(to: .blindOutcome(media: .image(image), time: viewModel.blindGenerate?.name ?? "Your box", description: viewModel.blindGenerate?.description ?? "", isMember: viewModel.isMember))
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(width: scaledWidth, height: scaledHeight)
.opacity(scale == 1 ? 1 : 0.7)
} }
} }
.onTapGesture { )
withAnimation(.easeInOut(duration: 0.1)) {
showControls.toggle()
}
}
//
if showControls {
VStack {
HStack {
Button(action: {
// BlindOutcomeView
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))
}
}) {
Image(systemName: "chevron.left")
.font(.system(size: 24))
.foregroundColor(.black)
}
Spacer()
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, 50)
.padding(.leading, 20)
.zIndex(1000)
.transition(.opacity)
.onAppear {
// 2
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation(.easeInOut(duration: 0.3)) {
showControls = true
}
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.animation(.easeInOut(duration: 1.0), value: scale)
.ignoresSafeArea()
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.spring(response: 2.5, dampingFraction: 0.6, blendDuration: 1.0)) {
self.scale = 1.0
}
}
}
} else { } else {
// Original content // Original content
VStack { VStack {
VStack(spacing: 20) { VStack(spacing: 20) {
if mediaType == .all { if mediaType == .all {
// BlindBoxHeaderBar(
HStack { onMenuTap: showUserProfile,
// remainPoints: viewModel.memberProfile?.remainPoints ?? 0,
Button(action: showUserProfile) { showLogin: $showLogin
Image(systemName: "line.3.horizontal") )
.font(.system(size: 20, weight: .regular))
.foregroundColor(.primary)
.padding(13) // Increases tap area while keeping visual size
.contentShape(Rectangle()) // Makes the padded area tappable
}
.buttonStyle(PlainButtonStyle()) // Prevents the button from affecting the layout
Spacer()
// //
// NavigationLink(destination: TestView()) {
// Text("TestView")
// .font(.subheadline)
// .padding(.horizontal, 12)
// .padding(.vertical, 6)
// .background(Color.brown)
// .foregroundColor(.white)
// .cornerRadius(8)
// }
// //
// NavigationLink(destination: SubscribeView()) {
// Text("Subscribe")
// .font(.subheadline)
// .padding(.horizontal, 12)
// .padding(.vertical, 6)
// .background(Color.orange)
// .foregroundColor(.white)
// .cornerRadius(8)
// }
// .padding(.trailing)
// .fullScreenCover(isPresented: $showLogin) {
// LoginView()
// }
NavigationLink(destination: SubscribeView()) {
Text("\(viewModel.memberProfile?.remainPoints ?? 0)")
.font(Typography.font(for: .subtitle))
.fontWeight(.bold)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color.black)
.foregroundColor(.white)
.cornerRadius(16)
}
.padding(.trailing)
.fullScreenCover(isPresented: $showLogin) {
LoginView()
}
}
.padding(.horizontal)
.padding(.top, 20)
} }
// //
VStack(alignment: .leading, spacing: 4) { BlindBoxTitleView()
Text("Hi! Click And")
Text("Open Your Box~")
}
.font(Typography.font(for: .smallLargeTitle))
.fontWeight(.bold)
.foregroundColor(Color.themeTextMessageMain)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.opacity(showScalingOverlay ? 0 : 1) .opacity(showScalingOverlay ? 0 : 1)
.offset(y: showScalingOverlay ? -UIScreen.main.bounds.height * 0.2 : 0) .offset(y: showScalingOverlay ? -UIScreen.main.bounds.height * 0.2 : 0)
.animation(.easeInOut(duration: 0.5), value: showScalingOverlay) .animation(.easeInOut(duration: 0.5), value: showScalingOverlay)
@ -501,17 +276,10 @@ struct BlindBoxView: View {
} }
// //
if !showScalingOverlay && !showMedia { if !showScalingOverlay && !showMedia {
VStack(alignment: .leading, spacing: 8) { BlindBoxDescriptionView(
// blindGeneratedescription name: viewModel.blindGenerate?.name ?? "Some box",
Text(viewModel.blindGenerate?.name ?? "Some box") description: viewModel.blindGenerate?.description ?? ""
.font(Typography.font(for: .body, family: .quicksandBold)) )
.foregroundColor(Color.themeTextMessageMain)
Text(viewModel.blindGenerate?.description ?? "")
.font(.system(size: 14))
.foregroundColor(Color.themeTextMessageMain)
}
.frame(width: UIScreen.main.bounds.width * 0.70, alignment: .leading)
.padding()
.offset(x: -10, y: UIScreen.main.bounds.height * 0.2) .offset(x: -10, y: UIScreen.main.bounds.height * 0.2)
} }
} }
@ -527,9 +295,10 @@ struct BlindBoxView: View {
// TODO // TODO
if mediaType == .all, viewModel.didBootstrap { if mediaType == .all, viewModel.didBootstrap {
Button(action: { BlindBoxActionButton(
if animationPhase == .ready { phase: animationPhase,
// countdownText: viewModel.countdownText,
onOpen: {
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
if let boxId = boxIdToOpen { if let boxId = boxIdToOpen {
Task { Task {
@ -544,39 +313,11 @@ struct BlindBoxView: View {
withAnimation { withAnimation {
animationPhase = .opening animationPhase = .opening
} }
} else if animationPhase == .none { },
onGoToBuy: {
Router.shared.navigate(to: .mediaUpload) Router.shared.navigate(to: .mediaUpload)
} }
}) { )
if animationPhase == .loading {
Text("Next: \(viewModel.countdownText)")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.white)
.foregroundColor(.black)
.cornerRadius(32)
} else if animationPhase == .ready {
Text("Ready")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.themePrimary)
.foregroundColor(Color.themeTextMessageMain)
.cornerRadius(32)
} else {
Text("Go to Buy")
.font(Typography.font(for: .body))
.fontWeight(.bold)
.frame(maxWidth: .infinity)
.padding()
.background(Color.themePrimary)
.foregroundColor(Color.themeTextMessageMain)
.cornerRadius(32)
}
}
.padding(.horizontal) .padding(.horizontal)
} }
} }
@ -644,25 +385,6 @@ struct BlindBoxView: View {
} }
} }
// MARK: - SwiftUI
private struct BlindCountBadge: View {
let text: String
var body: some View {
Text(text)
.font(Typography.font(for: .body, family: .quicksandBold))
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(
Capsule()
.fill(Color.black)
.shadow(color: Color.black.opacity(0.15), radius: 4, x: 0, y: 2)
)
}
}
/// ///
private func hideSettings() { private func hideSettings() {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
@ -701,14 +423,4 @@ struct BlindBoxView: View {
} }
#endif #endif
} }
} }
// struct TransparentVideoPlayer: UIViewRepresentable {
// func makeUIView(context: Context) -> UIView {
// let view = UIView()
// view.backgroundColor = .clear
// view.isOpaque = false
// return view
// }
// func updateUIView(_ uiView: UIView, context: Context) {}
// }