Compare commits
5 Commits
1fa7d113fe
...
411bf440d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 411bf440d4 | |||
| 0ab33cab47 | |||
| 1e57f993c2 | |||
| 562c7aab88 | |||
| 2487d7ebf7 |
1
wake/Assets/Lottie/opening.json
Normal file
1
wake/Assets/Lottie/opening.json
Normal file
File diff suppressed because one or more lines are too long
1
wake/Assets/Lottie/preparing.json
Normal file
1
wake/Assets/Lottie/preparing.json
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
20
wake/Features/BlindBox/Components/AVPlayerController.swift
Normal file
20
wake/Features/BlindBox/Components/AVPlayerController.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
77
wake/Features/BlindBox/Components/BackgroundCard.swift
Normal file
77
wake/Features/BlindBox/Components/BackgroundCard.swift
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - SwiftUI 背景重绘(方形版本)
|
||||||
|
struct CardBlindBackground: View {
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let w = geo.size.width
|
||||||
|
let h = geo.size.height
|
||||||
|
ZStack {
|
||||||
|
// 主背景卡片(方形)
|
||||||
|
ScoopRoundedRect(cornerRadius: 20, scoopDepth: 20, scoopHalfWidth: 90, scoopCenterX: 0.5, convexDown: true, flatHalfWidth: 60)
|
||||||
|
.fill(Theme.Colors.primary)
|
||||||
|
.shadow(color: .black.opacity(0.08), radius: 12, y: 6)
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [Color(hex: "FFFFFF"), Color(hex: "FFEFB2")],
|
||||||
|
startPoint: .topTrailing,
|
||||||
|
endPoint: .bottomLeading
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: w - 100 , height: h - 130)
|
||||||
|
.cornerRadius(20)
|
||||||
|
.padding(.top, Theme.Spacing.lg)
|
||||||
|
// var view = UIView()
|
||||||
|
// view.frame = CGRect(x: 0, y: 0, width: 320, height: 464)
|
||||||
|
// let layer0 = CAGradientLayer()
|
||||||
|
// layer0.colors = [
|
||||||
|
// UIColor(red: 1, green: 1, blue: 1, alpha: 1).cgColor,
|
||||||
|
// UIColor(red: 1, green: 0.937, blue: 0.698, alpha: 1).cgColor
|
||||||
|
// ]
|
||||||
|
// layer0.locations = [0, 1]
|
||||||
|
// layer0.startPoint = CGPoint(x: 0.25, y: 0.5)
|
||||||
|
// layer0.endPoint = CGPoint(x: 0.75, y: 0.5)
|
||||||
|
// layer0.transform = CATransform3DMakeAffineTransform(CGAffineTransform(a: -1.13, b: 1.07, c: -1.08, d: -0.54, tx: 1.65, ty: 0.24))
|
||||||
|
// layer0.bounds = view.bounds.insetBy(dx: -0.5*view.bounds.size.width, dy: -0.5*view.bounds.size.height)
|
||||||
|
// layer0.position = view.center
|
||||||
|
// view.layer.addSublayer(layer0)
|
||||||
|
|
||||||
|
// view.layer.cornerRadius = 18
|
||||||
|
|
||||||
|
// 左上光斑
|
||||||
|
// Circle()
|
||||||
|
// .fill(Color.themePrimary.opacity(0.18))
|
||||||
|
// .blur(radius: 40)
|
||||||
|
// .frame(width: min(w, h) * 0.35, height: min(w, h) * 0.35)
|
||||||
|
// .position(x: w * 0.25, y: h * 0.25)
|
||||||
|
|
||||||
|
// 右下光斑
|
||||||
|
// Circle()
|
||||||
|
// .fill(Color.orange.opacity(0.14))
|
||||||
|
// .blur(radius: 50)
|
||||||
|
// .frame(width: min(w, h) * 0.40, height: min(w, h) * 0.40)
|
||||||
|
// .position(x: w * 0.75, y: h * 0.75)
|
||||||
|
|
||||||
|
// 中央高光描边
|
||||||
|
// RoundedRectangle(cornerRadius: 28)
|
||||||
|
// .stroke(Color.white.opacity(0.35), lineWidth: 1)
|
||||||
|
// .frame(width: w * 0.88, height: h * 0.88)
|
||||||
|
// .position(x: w / 2, y: h / 2)
|
||||||
|
// .blendMode(.overlay)
|
||||||
|
// .opacity(0.7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览
|
||||||
|
struct CardBlindBackground_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
CardBlindBackground()
|
||||||
|
.frame(width: 400, height: 600)
|
||||||
|
}
|
||||||
|
}
|
||||||
53
wake/Features/BlindBox/Components/BlindBoxActionButton.swift
Normal file
53
wake/Features/BlindBox/Components/BlindBoxActionButton.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,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()
|
||||||
|
}
|
||||||
|
}
|
||||||
37
wake/Features/BlindBox/Components/BlindBoxHeaderBar.swift
Normal file
37
wake/Features/BlindBox/Components/BlindBoxHeaderBar.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
// 单次播放,不需要在更新时重复触发
|
||||||
|
}
|
||||||
|
}
|
||||||
98
wake/Features/BlindBox/Components/BlindBoxMediaOverlay.swift
Normal file
98
wake/Features/BlindBox/Components/BlindBoxMediaOverlay.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
wake/Features/BlindBox/Components/BlindBoxTitleView.swift
Normal file
15
wake/Features/BlindBox/Components/BlindBoxTitleView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
wake/Features/BlindBox/Components/BlindCountBadge.swift
Normal file
19
wake/Features/BlindBox/Components/BlindCountBadge.swift
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,90 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct CustomLightSequenceAnimation: View {
|
|
||||||
// 核心循环序列:按"123321123321"规律定义基础单元
|
|
||||||
private let baseSequence: [Int] = [1, 2, 3, 3, 2, 1, 1, 2, 3, 3, 2, 1]
|
|
||||||
@State private var currentLight: Int = 1 // 当前显示的图片序号
|
|
||||||
@State private var sequenceIndex: Int = 0 // 当前在序列中的索引
|
|
||||||
|
|
||||||
// 淡入淡出透明度控制(确保切换丝滑)
|
|
||||||
@State private var currentOpacity: CGFloat = 1.0
|
|
||||||
@State private var nextOpacity: CGFloat = 0.0
|
|
||||||
|
|
||||||
// 尺寸参数(适配正方形卡片)
|
|
||||||
private let screenWidth = UIScreen.main.bounds.width
|
|
||||||
private let squareSize: CGFloat
|
|
||||||
private let imageSize: CGFloat
|
|
||||||
|
|
||||||
init() {
|
|
||||||
self.squareSize = screenWidth * 1.8 // 正方形背景尺寸
|
|
||||||
self.imageSize = squareSize / 3 // 光束卡片尺寸(1/3背景大小)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - SwiftUI 背景重绘(方形版本)
|
|
||||||
private struct CardBlindBackground: View {
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { geo in
|
|
||||||
let w = geo.size.width
|
|
||||||
let h = geo.size.height
|
|
||||||
ZStack {
|
|
||||||
// 主背景卡片(方形)
|
|
||||||
RoundedRectangle(cornerRadius: 28)
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.white, Color.white.opacity(0.96)],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.shadow(color: Color.black.opacity(0.06), radius: 16, x: 0, y: 8)
|
|
||||||
.frame(width: w * 0.88, height: h * 0.88)
|
|
||||||
.position(x: w / 2, y: h / 2)
|
|
||||||
|
|
||||||
// 左上光斑
|
|
||||||
Circle()
|
|
||||||
.fill(Color.themePrimary.opacity(0.18))
|
|
||||||
.blur(radius: 40)
|
|
||||||
.frame(width: min(w, h) * 0.35, height: min(w, h) * 0.35)
|
|
||||||
.position(x: w * 0.25, y: h * 0.25)
|
|
||||||
|
|
||||||
// 右下光斑
|
|
||||||
Circle()
|
|
||||||
.fill(Color.orange.opacity(0.14))
|
|
||||||
.blur(radius: 50)
|
|
||||||
.frame(width: min(w, h) * 0.40, height: min(w, h) * 0.40)
|
|
||||||
.position(x: w * 0.75, y: h * 0.75)
|
|
||||||
|
|
||||||
// 中央高光描边
|
|
||||||
RoundedRectangle(cornerRadius: 28)
|
|
||||||
.stroke(Color.white.opacity(0.35), lineWidth: 1)
|
|
||||||
.frame(width: w * 0.88, height: h * 0.88)
|
|
||||||
.position(x: w / 2, y: h / 2)
|
|
||||||
.blendMode(.overlay)
|
|
||||||
.opacity(0.7)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 卡片中心位置(固定,确保摆正居中)
|
|
||||||
private var centerPosition: CGPoint {
|
|
||||||
CGPoint(x: screenWidth / 2, y: squareSize * 0.325)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
// 底部背景(正方形,SwiftUI 重绘)
|
|
||||||
CardBlindBackground()
|
|
||||||
.frame(width: squareSize, height: squareSize)
|
|
||||||
.position(centerPosition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// 预览
|
|
||||||
struct CustomLightSequenceAnimation_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
CustomLightSequenceAnimation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
30
wake/Features/BlindBox/Components/VisualEffectView.swift
Normal file
30
wake/Features/BlindBox/Components/VisualEffectView.swift
Normal 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) {
|
||||||
|
// 无需动态更新
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,66 +8,11 @@ extension Notification.Name {
|
|||||||
static let blindBoxStatusChanged = Notification.Name("blindBoxStatusChanged")
|
static let blindBoxStatusChanged = Notification.Name("blindBoxStatusChanged")
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum BlindBoxAnimationPhase {
|
|
||||||
case loading
|
|
||||||
case ready
|
|
||||||
case opening
|
|
||||||
case none
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let navigateToMediaViewer = Notification.Name("navigateToMediaViewer")
|
static let navigateToMediaViewer = Notification.Name("navigateToMediaViewer")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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?
|
||||||
@ -76,60 +21,18 @@ struct BlindBoxView: View {
|
|||||||
@State private var showSettings = false // 控制设置页面显示
|
@State private var showSettings = false // 控制设置页面显示
|
||||||
@State private var showLogin = false
|
@State private var showLogin = false
|
||||||
// 倒计时由 ViewModel 管理(countdownText)
|
// 倒计时由 ViewModel 管理(countdownText)
|
||||||
// 盲盒数据
|
|
||||||
@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 showControls = false
|
|
||||||
@State private var isAnimating = true
|
|
||||||
@State private var showMedia = false
|
|
||||||
|
|
||||||
// 查询数据 - 简单查询
|
// 查询数据 - 简单查询
|
||||||
@Query private var login: [Login]
|
@Query private var login: [Login]
|
||||||
|
|
||||||
init(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) {
|
init(mediaType: BlindBoxMediaType, blindBoxId: String? = nil) {
|
||||||
self.mediaType = mediaType
|
self.mediaType = mediaType
|
||||||
self.currentBoxId = blindBoxId
|
self.currentBoxId = blindBoxId
|
||||||
_viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId))
|
_viewModel = StateObject(wrappedValue: BlindBoxViewModel(mediaType: mediaType, currentBoxId: blindBoxId))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 倒计时已迁移至 ViewModel
|
|
||||||
|
|
||||||
// 已由 ViewModel 承担加载与轮询逻辑
|
|
||||||
|
|
||||||
// 已迁移至 ViewModel
|
// 计算尺寸逻辑已迁移至 BlindBoxMediaOverlay 组件(已不再使用)
|
||||||
|
|
||||||
// 已迁移至 ViewModel
|
|
||||||
|
|
||||||
// 已迁移至 ViewModel
|
|
||||||
|
|
||||||
// 本地媒体加载逻辑已迁移至 ViewModel.prepareMedia()
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@ -138,41 +41,7 @@ struct BlindBoxView: View {
|
|||||||
Perf.event("BlindBox_Appear")
|
Perf.event("BlindBox_Appear")
|
||||||
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()
|
||||||
@ -209,12 +78,12 @@ struct BlindBoxView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.videoURL) { _, url in
|
.onChange(of: viewModel.videoURL) { _, url in
|
||||||
if !url.isEmpty {
|
if !url.isEmpty && self.animationPhase != .opening {
|
||||||
withAnimation { self.animationPhase = .ready }
|
withAnimation { self.animationPhase = .ready }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.imageURL) { _, url in
|
.onChange(of: viewModel.imageURL) { _, url in
|
||||||
if !url.isEmpty {
|
if !url.isEmpty && self.animationPhase != .opening {
|
||||||
withAnimation { self.animationPhase = .ready }
|
withAnimation { self.animationPhase = .ready }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -232,395 +101,144 @@ struct BlindBoxView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if showScalingOverlay {
|
// 原 overlay 分支已移除,直接展示内容
|
||||||
ZStack {
|
// Original content
|
||||||
VisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterialLight))
|
VStack {
|
||||||
.opacity(0.3)
|
VStack(spacing: 20) {
|
||||||
.edgesIgnoringSafeArea(.all)
|
if mediaType == .all {
|
||||||
|
BlindBoxHeaderBar(
|
||||||
Group {
|
onMenuTap: showUserProfile,
|
||||||
if mediaType == .all, viewModel.player != nil {
|
remainPoints: viewModel.memberProfile?.remainPoints ?? 0,
|
||||||
// Video Player
|
showLogin: $showLogin
|
||||||
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
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标题
|
|
||||||
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)
|
|
||||||
.opacity(showScalingOverlay ? 0 : 1)
|
|
||||||
.offset(y: showScalingOverlay ? -UIScreen.main.bounds.height * 0.2 : 0)
|
|
||||||
.animation(.easeInOut(duration: 0.5), value: showScalingOverlay)
|
|
||||||
|
|
||||||
// 盲盒
|
|
||||||
ZStack {
|
|
||||||
// 1. 背景(SwiftUI 重绘)
|
|
||||||
if !showScalingOverlay {
|
|
||||||
BlindBackground()
|
|
||||||
.opacity(showScalingOverlay ? 0 : 1)
|
|
||||||
.animation(.easeOut(duration: 1.5), value: showScalingOverlay)
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
// 从变量blindGenerate中获取description
|
|
||||||
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()
|
|
||||||
.offset(x: -10, y: UIScreen.main.bounds.height * 0.2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
.frame(
|
|
||||||
maxWidth: .infinity,
|
|
||||||
maxHeight: UIScreen.main.bounds.height * 0.65
|
|
||||||
)
|
)
|
||||||
.opacity(showScalingOverlay ? 0 : 1)
|
}
|
||||||
.animation(.easeOut(duration: 1.5), value: showScalingOverlay)
|
|
||||||
.offset(y: showScalingOverlay ? -100 : 0)
|
// 标题
|
||||||
.animation(.easeInOut(duration: 1.5), value: showScalingOverlay)
|
BlindBoxTitleView()
|
||||||
|
.opacity(animationPhase == .opening ? 0 : 1)
|
||||||
// 打开 TODO 引导时,也要有按钮
|
|
||||||
if mediaType == .all, viewModel.didBootstrap {
|
// 盲盒
|
||||||
Button(action: {
|
ZStack {
|
||||||
if animationPhase == .ready {
|
// 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
|
let boxIdToOpen = self.currentBoxId ?? self.viewModel.blindGenerate?.id
|
||||||
if let boxId = boxIdToOpen {
|
if let boxId = boxIdToOpen {
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await viewModel.openBlindBox(for: boxId)
|
try await viewModel.openBlindBox(for: boxId)
|
||||||
print("✅ 盲盒开启成功")
|
print("✅ 盲盒开启成功")
|
||||||
|
await viewModel.startPolling()
|
||||||
|
withAnimation {
|
||||||
|
animationPhase = .opening
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ 开启盲盒失败: \(error)")
|
print("❌ 开启盲盒失败: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
withAnimation {
|
},
|
||||||
animationPhase = .opening
|
onOpeningCompleted: {
|
||||||
}
|
navigateToOutcome()
|
||||||
} else if animationPhase == .none {
|
|
||||||
Router.shared.navigate(to: .mediaUpload)
|
|
||||||
}
|
}
|
||||||
}) {
|
)
|
||||||
if animationPhase == .loading {
|
}
|
||||||
Text("Next: \(viewModel.countdownText)")
|
.offset(y: -50)
|
||||||
.font(Typography.font(for: .body))
|
.compositingGroup()
|
||||||
.fontWeight(.bold)
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
// 非 opening 阶段显示文字
|
||||||
.padding()
|
if animationPhase != .opening {
|
||||||
.background(Color.white)
|
BlindBoxDescriptionView(
|
||||||
.foregroundColor(.black)
|
name: viewModel.blindGenerate?.name ?? "Some box",
|
||||||
.cornerRadius(32)
|
description: viewModel.blindGenerate?.description ?? ""
|
||||||
} else if animationPhase == .ready {
|
)
|
||||||
Text("Ready")
|
.offset(x: -10, y: UIScreen.main.bounds.height * 0.2)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.padding()
|
||||||
.background(Color.themeTextWhiteSecondary)
|
.frame(
|
||||||
.offset(x: showModal ? UIScreen.main.bounds.width * 0.8 : 0)
|
maxWidth: .infinity,
|
||||||
.animation(.spring(response: 0.6, dampingFraction: 0.8), value: showModal)
|
maxHeight: UIScreen.main.bounds.height * 0.65
|
||||||
.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)
|
// 打开 TODO 引导时,也要有按钮
|
||||||
.transition(.move(edge: .leading))
|
if mediaType == .all, viewModel.didBootstrap {
|
||||||
.zIndex(1)
|
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)
|
.navigationBarBackButtonHidden(true)
|
||||||
}
|
}
|
||||||
@ -635,7 +253,7 @@ struct BlindBoxView: View {
|
|||||||
for (index, item) in login.enumerated() {
|
for (index, item) in login.enumerated() {
|
||||||
print("记录 \(index + 1): 邮箱=\(item.email), 姓名=\(item.name)")
|
print("记录 \(index + 1): 邮箱=\(item.email), 姓名=\(item.name)")
|
||||||
}
|
}
|
||||||
showModal.toggle()
|
showModal.toggle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -645,70 +263,6 @@ struct BlindBoxView: View {
|
|||||||
showModal = false
|
showModal = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - 盲盒背景(SwiftUI 重绘)
|
|
||||||
private struct BlindBackground: View {
|
|
||||||
var body: some View {
|
|
||||||
GeometryReader { geo in
|
|
||||||
let w = geo.size.width
|
|
||||||
let h = geo.size.height
|
|
||||||
ZStack {
|
|
||||||
// 主背景卡片
|
|
||||||
RoundedRectangle(cornerRadius: 28)
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
colors: [Color.white, Color.white.opacity(0.96)],
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.shadow(color: Color.black.opacity(0.06), radius: 16, x: 0, y: 8)
|
|
||||||
.frame(width: min(w * 0.9, 360), height: min(h * 0.6, 260))
|
|
||||||
.position(x: w / 2, y: h * 0.35)
|
|
||||||
|
|
||||||
// 左上光斑
|
|
||||||
Circle()
|
|
||||||
.fill(Color.themePrimary.opacity(0.18))
|
|
||||||
.blur(radius: 40)
|
|
||||||
.frame(width: 160, height: 160)
|
|
||||||
.position(x: w * 0.22, y: h * 0.18)
|
|
||||||
|
|
||||||
// 右下光斑
|
|
||||||
Circle()
|
|
||||||
.fill(Color.orange.opacity(0.14))
|
|
||||||
.blur(radius: 50)
|
|
||||||
.frame(width: 180, height: 180)
|
|
||||||
.position(x: w * 0.78, y: h * 0.55)
|
|
||||||
|
|
||||||
// 中央高光
|
|
||||||
RoundedRectangle(cornerRadius: 28)
|
|
||||||
.stroke(Color.white.opacity(0.35), lineWidth: 1)
|
|
||||||
.frame(width: min(w * 0.9, 360), height: min(h * 0.6, 260))
|
|
||||||
.position(x: w / 2, y: h * 0.35)
|
|
||||||
.blendMode(.overlay)
|
|
||||||
.opacity(0.7)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 隐藏设置页面
|
/// 隐藏设置页面
|
||||||
private func hideSettings() {
|
private func hideSettings() {
|
||||||
@ -716,6 +270,58 @@ struct BlindBoxView: View {
|
|||||||
showSettings = false
|
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: - 预览
|
// MARK: - 预览
|
||||||
@ -749,13 +355,3 @@ 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) {}
|
|
||||||
// }
|
|
||||||
|
|||||||
21
wake/Media.xcassets/Empty.imageset/Contents.json
vendored
Normal file
21
wake/Media.xcassets/Empty.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Empty.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "1x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "2x"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"scale" : "3x"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
wake/Media.xcassets/Empty.imageset/Empty.png
vendored
Normal file
BIN
wake/Media.xcassets/Empty.imageset/Empty.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
110
wake/SharedUI/Graphics/ScoopRoundedRect.swift
Normal file
110
wake/SharedUI/Graphics/ScoopRoundedRect.swift
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// 顶部带“内凹”弧形的连续圆角矩形
|
||||||
|
struct ScoopRoundedRect: Shape {
|
||||||
|
var cornerRadius: CGFloat = 20
|
||||||
|
/// 凹凸的“深度”:>0 向下凸(更接近你截图的效果),<0 为向上凹
|
||||||
|
var scoopDepth: CGFloat = 10
|
||||||
|
/// 半宽:控制水平占据范围(越大越“平缓”)
|
||||||
|
var scoopHalfWidth: CGFloat = 18
|
||||||
|
/// 相对位置(0~1,0.5 正中)
|
||||||
|
var scoopCenterX: CGFloat = 0.33
|
||||||
|
/// 是否向下凸;为 false 时表现为向上“凹”(与 notch 类似)
|
||||||
|
var convexDown: Bool = true
|
||||||
|
/// 凹陷/鼓包底部的“平底”半宽(0 为无平底)
|
||||||
|
var flatHalfWidth: CGFloat = 8
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
let r = min(cornerRadius, min(rect.width, rect.height) * 0.5)
|
||||||
|
let topY = rect.minY
|
||||||
|
|
||||||
|
// 约束中心与范围,避免穿出圆角
|
||||||
|
let minX = rect.minX + r
|
||||||
|
let maxX = rect.maxX - r
|
||||||
|
let centerX = rect.minX + rect.width * scoopCenterX
|
||||||
|
let hw = min(scoopHalfWidth, (maxX - minX) * 0.45)
|
||||||
|
let flatHW = max(0, min(flatHalfWidth, hw * 0.8))
|
||||||
|
let shoulder = max(1, hw - flatHW) // 两侧曲线的水平长度
|
||||||
|
let startX = max(minX, centerX - (flatHW + shoulder))
|
||||||
|
let endX = min(maxX, centerX + (flatHW + shoulder))
|
||||||
|
let leftFlatX = max(minX, centerX - flatHW)
|
||||||
|
let rightFlatX = min(maxX, centerX + flatHW)
|
||||||
|
let depth = (convexDown ? 1 : -1) * scoopDepth
|
||||||
|
|
||||||
|
// 左曲线:P0 -> Lf(水平);右曲线:Rf -> P3(水平);Lf~Rf 之间是一段水平直线
|
||||||
|
let P0 = CGPoint(x: startX, y: topY)
|
||||||
|
let Lf = CGPoint(x: leftFlatX, y: topY + depth)
|
||||||
|
let Rf = CGPoint(x: rightFlatX, y: topY + depth)
|
||||||
|
let P3 = CGPoint(x: endX, y: topY)
|
||||||
|
|
||||||
|
// 使用圆角近似系数控制手柄长度(基于 shoulder)
|
||||||
|
let k = shoulder * 0.5522847498
|
||||||
|
let C1 = CGPoint(x: P0.x + k, y: P0.y) // P0 水平切线
|
||||||
|
let C2 = CGPoint(x: Lf.x - k, y: Lf.y) // Lf 水平切线
|
||||||
|
let C3 = CGPoint(x: Rf.x + k, y: Rf.y) // Rf 水平切线
|
||||||
|
let C4 = CGPoint(x: P3.x - k, y: P3.y) // P3 水平切线
|
||||||
|
|
||||||
|
var p = Path()
|
||||||
|
// 顶部从左上圆角起
|
||||||
|
p.move(to: CGPoint(x: rect.minX + r, y: topY))
|
||||||
|
// 左侧直线到凹/凸开始
|
||||||
|
p.addLine(to: P0)
|
||||||
|
// 左侧进入曲线
|
||||||
|
p.addCurve(to: Lf, control1: C1, control2: C2)
|
||||||
|
// 平底水平直线
|
||||||
|
p.addLine(to: Rf)
|
||||||
|
// 右侧离开曲线
|
||||||
|
p.addCurve(to: P3, control1: C3, control2: C4)
|
||||||
|
// 顶部到右上圆角
|
||||||
|
p.addLine(to: CGPoint(x: rect.maxX - r, y: topY))
|
||||||
|
// 右上圆角
|
||||||
|
p.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.minY + r),
|
||||||
|
control: CGPoint(x: rect.maxX, y: rect.minY))
|
||||||
|
// 右侧直线到右下圆角
|
||||||
|
p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - r))
|
||||||
|
// 右下圆角
|
||||||
|
p.addQuadCurve(to: CGPoint(x: rect.maxX - r, y: rect.maxY),
|
||||||
|
control: CGPoint(x: rect.maxX, y: rect.maxY))
|
||||||
|
// 底边到左下圆角
|
||||||
|
p.addLine(to: CGPoint(x: rect.minX + r, y: rect.maxY))
|
||||||
|
// 左下圆角
|
||||||
|
p.addQuadCurve(to: CGPoint(x: rect.minX, y: rect.maxY - r),
|
||||||
|
control: CGPoint(x: rect.minX, y: rect.maxY))
|
||||||
|
// 左侧到左上圆角
|
||||||
|
p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + r))
|
||||||
|
// 左上圆角
|
||||||
|
p.addQuadCurve(to: CGPoint(x: rect.minX + r, y: rect.minY),
|
||||||
|
control: CGPoint(x: rect.minX, y: rect.minY))
|
||||||
|
p.closeSubpath()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScoopRoundedRect_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
VStack(spacing: 30) {
|
||||||
|
// 向下“鼓包”且底部有平直段
|
||||||
|
ScoopRoundedRect(cornerRadius: 24, scoopDepth: 8, scoopHalfWidth: 26, scoopCenterX: 0.25, convexDown: true, flatHalfWidth: 12)
|
||||||
|
.fill(Color.orange)
|
||||||
|
.frame(height: 140)
|
||||||
|
.shadow(color: .black.opacity(0.08), radius: 12, y: 6)
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
// 中央更深、更宽,平底更宽
|
||||||
|
ScoopRoundedRect(cornerRadius: 28, scoopDepth: 12, scoopHalfWidth: 36, scoopCenterX: 0.5, convexDown: true, flatHalfWidth: 18)
|
||||||
|
.fill(Color.orange)
|
||||||
|
.frame(height: 140)
|
||||||
|
.shadow(color: .black.opacity(0.08), radius: 12, y: 6)
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
// 作为对比:向上“凹陷”的 notch,带平底
|
||||||
|
ScoopRoundedRect(cornerRadius: 24, scoopDepth: 10, scoopHalfWidth: 22, scoopCenterX: 0.6, convexDown: false, flatHalfWidth: 10)
|
||||||
|
.fill(Color.orange)
|
||||||
|
.frame(height: 140)
|
||||||
|
.shadow(color: .black.opacity(0.08), radius: 12, y: 6)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.background(Color(white: 0.96))
|
||||||
|
.previewLayout(.sizeThatFits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -77,9 +77,9 @@ struct CreditsInfoCard: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
// 详情按钮
|
// 详情按钮
|
||||||
Image(systemName: "chevron.right")
|
// Image(systemName: "chevron.right")
|
||||||
.foregroundColor(Theme.Colors.textPrimary)
|
// .foregroundColor(Theme.Colors.textPrimary)
|
||||||
.font(.system(size: 14, weight: .medium))
|
// .font(.system(size: 14, weight: .medium))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(Theme.Spacing.lg)
|
.padding(Theme.Spacing.lg)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user