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")
}
private enum BlindBoxAnimationPhase {
internal enum BlindBoxAnimationPhase {
case loading
case ready
case opening
@ -20,54 +20,6 @@ extension Notification.Name {
}
// 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 {
let mediaType: BlindBoxMediaType
let currentBoxId: String?
@ -80,7 +32,7 @@ struct BlindBoxView: View {
@State private var showScalingOverlay = false
@State private var animationPhase: BlindBoxAnimationPhase = .none
@State private var scale: CGFloat = 0.1
@State private var showControls = false
// showControls BlindBoxMediaOverlay
@State private var isAnimating = true
@State private var showMedia = false
@ -93,18 +45,6 @@ struct BlindBoxView: View {
_viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId))
}
// ViewModel
// ViewModel
// ViewModel
// ViewModel
// ViewModel
// ViewModel.prepareMedia()
private func startScalingAnimation() {
self.scale = 0.1
self.showScalingOverlay = true
@ -114,22 +54,7 @@ struct BlindBoxView: View {
}
}
// MARK: - Computed Properties
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
}
}
// BlindBoxMediaOverlay
var body: some View {
ZStack {
@ -139,40 +64,6 @@ struct BlindBoxView: View {
print("🎯 BlindBoxView appeared with mediaType: \(mediaType)")
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 {
await viewModel.load()
@ -233,151 +124,35 @@ struct BlindBoxView: View {
}
if showScalingOverlay {
ZStack {
VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialLight))
.opacity(0.3)
.edgesIgnoringSafeArea(.all)
Group {
if mediaType == .all, viewModel.player != nil {
// Video Player
AVPlayerController(player: .init(get: { viewModel.player }, set: { viewModel.player = $0 }))
.frame(width: scaledWidth, height: scaledHeight)
.opacity(scale == 1 ? 1 : 0.7)
.onAppear { viewModel.player?.play() }
} else if mediaType == .image, let image = viewModel.displayImage {
// Image View
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
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))
}
}) {
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 {
// Original content
VStack {
VStack(spacing: 20) {
if mediaType == .all {
//
HStack {
//
Button(action: showUserProfile) {
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)
BlindBoxHeaderBar(
onMenuTap: showUserProfile,
remainPoints: viewModel.memberProfile?.remainPoints ?? 0,
showLogin: $showLogin
)
}
//
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)
BlindBoxTitleView()
.opacity(showScalingOverlay ? 0 : 1)
.offset(y: showScalingOverlay ? -UIScreen.main.bounds.height * 0.2 : 0)
.animation(.easeInOut(duration: 0.5), value: showScalingOverlay)
@ -501,17 +276,10 @@ struct BlindBoxView: View {
}
//
if !showScalingOverlay && !showMedia {
VStack(alignment: .leading, spacing: 8) {
// blindGeneratedescription
Text(viewModel.blindGenerate?.name ?? "Some box")
.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()
BlindBoxDescriptionView(
name: viewModel.blindGenerate?.name ?? "Some box",
description: viewModel.blindGenerate?.description ?? ""
)
.offset(x: -10, y: UIScreen.main.bounds.height * 0.2)
}
}
@ -527,9 +295,10 @@ struct BlindBoxView: View {
// TODO
if mediaType == .all, viewModel.didBootstrap {
Button(action: {
if animationPhase == .ready {
//
BlindBoxActionButton(
phase: animationPhase,
countdownText: viewModel.countdownText,
onOpen: {
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
if let boxId = boxIdToOpen {
Task {
@ -544,39 +313,11 @@ struct BlindBoxView: View {
withAnimation {
animationPhase = .opening
}
} else if animationPhase == .none {
},
onGoToBuy: {
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)
}
}
@ -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() {
withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) {
@ -702,13 +424,3 @@ struct BlindBoxView: View {
#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) {}
// }