page curl
Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
2a74386b71
commit
833841dcdd
@ -3236,10 +3236,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"PEEL" : {
|
|
||||||
"comment" : "Text shown on the card when it's in the interactive reveal state, instructing them to peel the card.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Player" : {
|
"Player" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
|
|||||||
@ -21,25 +21,11 @@ struct InteractiveCardView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
if revealStyle == .squeeze && !isFaceUp && isWaiting {
|
if revealStyle == .squeeze && !isFaceUp && isWaiting {
|
||||||
// Specialized squeeze view
|
PageCurlView(
|
||||||
SqueezeCardView(card: card, width: cardWidth, progress: progress)
|
card: card,
|
||||||
.contentShape(Rectangle())
|
width: cardWidth,
|
||||||
.gesture(
|
onReveal: onReveal
|
||||||
DragGesture(minimumDistance: 0)
|
)
|
||||||
.onChanged { value in
|
|
||||||
let dx = abs(value.translation.width)
|
|
||||||
let dy = abs(value.translation.height)
|
|
||||||
let distance = sqrt(dx*dx + dy*dy)
|
|
||||||
let maxDistance = cardWidth * 1.0
|
|
||||||
let newProgress = min(1.0, max(progress, Double(distance / maxDistance)))
|
|
||||||
onUpdateProgress(newProgress)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.overlay {
|
|
||||||
if progress < 0.05 {
|
|
||||||
revealInstructionOverlay(text: String(localized: "PEEL"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Standard CardView (handles its own flip animation)
|
// Standard CardView (handles its own flip animation)
|
||||||
CardView(card: card, isFaceUp: isFaceUp, cardWidth: cardWidth)
|
CardView(card: card, isFaceUp: isFaceUp, cardWidth: cardWidth)
|
||||||
|
|||||||
129
Baccarat/Baccarat/Views/Table/PageCurlView.swift
Normal file
129
Baccarat/Baccarat/Views/Table/PageCurlView.swift
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
//
|
||||||
|
// PageCurlView.swift
|
||||||
|
// Baccarat
|
||||||
|
//
|
||||||
|
// A SwiftUI wrapper around UIPageViewController to provide native iBooks-style
|
||||||
|
// page curl transitions for card reveals, treated as a single double-sided sheet.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
import CasinoKit
|
||||||
|
|
||||||
|
struct PageCurlView: View {
|
||||||
|
let card: Card
|
||||||
|
let width: CGFloat
|
||||||
|
let onReveal: () -> Void
|
||||||
|
|
||||||
|
@State private var currentIndex = 0
|
||||||
|
|
||||||
|
private var height: CGFloat {
|
||||||
|
width * CasinoDesign.Size.cardAspectRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
PageCurlRepresentable(currentIndex: $currentIndex, onReveal: onReveal, pages: [
|
||||||
|
// Page 0: Card Back (Top side)
|
||||||
|
AnyView(
|
||||||
|
CardBackView(width: width, height: height)
|
||||||
|
.tag(0)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
),
|
||||||
|
// Page 1: Card Front (Underside/Reveal side)
|
||||||
|
AnyView(
|
||||||
|
CardFrontView(card: card, width: width, height: height)
|
||||||
|
.tag(1)
|
||||||
|
),
|
||||||
|
// Page 2: Empty space behind the card
|
||||||
|
AnyView(
|
||||||
|
Color.clear
|
||||||
|
.frame(width: width, height: height)
|
||||||
|
.tag(2)
|
||||||
|
)
|
||||||
|
])
|
||||||
|
.frame(width: width, height: height)
|
||||||
|
.id("page-curl-\(card.id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PageCurlRepresentable: UIViewControllerRepresentable {
|
||||||
|
@Binding var currentIndex: Int
|
||||||
|
let onReveal: () -> Void
|
||||||
|
let pages: [AnyView]
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIPageViewController {
|
||||||
|
let pageVC = UIPageViewController(
|
||||||
|
transitionStyle: .pageCurl,
|
||||||
|
navigationOrientation: .vertical,
|
||||||
|
options: [UIPageViewController.OptionsKey.spineLocation: UIPageViewController.SpineLocation.min.rawValue]
|
||||||
|
)
|
||||||
|
|
||||||
|
pageVC.isDoubleSided = true
|
||||||
|
pageVC.dataSource = context.coordinator
|
||||||
|
pageVC.delegate = context.coordinator
|
||||||
|
|
||||||
|
let vc = UIHostingController(rootView: pages[0])
|
||||||
|
vc.view.tag = 0
|
||||||
|
vc.view.backgroundColor = .clear
|
||||||
|
pageVC.setViewControllers([vc], direction: .forward, animated: false)
|
||||||
|
|
||||||
|
// Disable internal tap gesture recognizers to ensure purely gestural interaction
|
||||||
|
for gesture in pageVC.gestureRecognizers {
|
||||||
|
if gesture is UITapGestureRecognizer {
|
||||||
|
gesture.isEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageVC
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ pageVC: UIPageViewController, context: Context) { }
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Coordinator: NSObject, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
|
||||||
|
let parent: PageCurlRepresentable
|
||||||
|
|
||||||
|
init(_ parent: PageCurlRepresentable) {
|
||||||
|
self.parent = parent
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageViewController(_ pageVC: UIPageViewController,
|
||||||
|
viewControllerBefore vc: UIViewController) -> UIViewController? {
|
||||||
|
let index = vc.view.tag
|
||||||
|
guard index > 0 else { return nil }
|
||||||
|
|
||||||
|
let prevVC = UIHostingController(rootView: parent.pages[index - 1])
|
||||||
|
prevVC.view.tag = index - 1
|
||||||
|
prevVC.view.backgroundColor = .clear
|
||||||
|
return prevVC
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageViewController(_ pageVC: UIPageViewController,
|
||||||
|
viewControllerAfter vc: UIViewController) -> UIViewController? {
|
||||||
|
let index = vc.view.tag
|
||||||
|
guard index < parent.pages.count - 1 else { return nil }
|
||||||
|
|
||||||
|
let nextVC = UIHostingController(rootView: parent.pages[index + 1])
|
||||||
|
nextVC.view.tag = index + 1
|
||||||
|
nextVC.view.backgroundColor = .clear
|
||||||
|
return nextVC
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageViewController(_ pageVC: UIPageViewController,
|
||||||
|
didFinishAnimating finished: Bool,
|
||||||
|
previousViewControllers: [UIViewController],
|
||||||
|
transitionCompleted completed: Bool) {
|
||||||
|
guard completed,
|
||||||
|
let visibleVC = pageVC.viewControllers?.first else { return }
|
||||||
|
|
||||||
|
let newIndex = visibleVC.view.tag
|
||||||
|
parent.currentIndex = newIndex
|
||||||
|
if newIndex >= 1 {
|
||||||
|
parent.onReveal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,16 +2,23 @@
|
|||||||
// SqueezeCardView.swift
|
// SqueezeCardView.swift
|
||||||
// Baccarat
|
// Baccarat
|
||||||
//
|
//
|
||||||
// A premium card view that allows for a gestural "squeeze" or "peel" reveal.
|
// A premium card view that allows for a gestural "squeeze" or "peel" reveal,
|
||||||
|
// mimicking a real-world page flip from the corners.
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CasinoKit
|
import CasinoKit
|
||||||
|
|
||||||
|
enum SqueezeCorner {
|
||||||
|
case bottomLeft
|
||||||
|
case bottomRight
|
||||||
|
}
|
||||||
|
|
||||||
struct SqueezeCardView: View {
|
struct SqueezeCardView: View {
|
||||||
let card: Card
|
let card: Card
|
||||||
let width: CGFloat
|
let width: CGFloat
|
||||||
let progress: Double // 0.0 (Back) to 1.0 (Front)
|
let progress: Double // 0.0 to 1.0
|
||||||
|
let corner: SqueezeCorner
|
||||||
|
|
||||||
private var height: CGFloat {
|
private var height: CGFloat {
|
||||||
width * CasinoDesign.Size.cardAspectRatio
|
width * CasinoDesign.Size.cardAspectRatio
|
||||||
@ -19,29 +26,34 @@ struct SqueezeCardView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
// The front is always underneath
|
// 1. The front is always at the bottom
|
||||||
CardFrontView(card: card, width: width, height: height)
|
CardFrontView(card: card, width: width, height: height)
|
||||||
|
|
||||||
// The back is on top and masked away as progress increases
|
// 2. The back is on top, masked by the "unpeeled" area
|
||||||
CardBackView(width: width, height: height)
|
CardBackView(width: width, height: height)
|
||||||
.mask(
|
.mask(
|
||||||
SqueezeMask(progress: progress)
|
SqueezeMask(progress: progress, corner: corner)
|
||||||
.frame(width: width, height: height)
|
.fill(Style.maskFill)
|
||||||
)
|
|
||||||
.shadow(
|
|
||||||
color: .black.opacity(Design.Opacity.medium * (1.0 - progress)),
|
|
||||||
radius: 2,
|
|
||||||
x: 2,
|
|
||||||
y: 2
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 3. The "Fold" - visual affordance of the peeled corner
|
||||||
|
if progress > 0 && progress < 1.0 {
|
||||||
|
FoldEffect(progress: progress, corner: corner, width: width, height: height)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(width: width, height: height)
|
.frame(width: width, height: height)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: width * 0.05))
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Style {
|
||||||
|
static let maskFill = Color.black
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A mask that peels away relative to progress.
|
/// A mask that represents the part of the card back that is NOT yet peeled.
|
||||||
struct SqueezeMask: Shape {
|
struct SqueezeMask: Shape {
|
||||||
var progress: Double
|
var progress: Double
|
||||||
|
var corner: SqueezeCorner
|
||||||
|
|
||||||
var animatableData: Double {
|
var animatableData: Double {
|
||||||
get { progress }
|
get { progress }
|
||||||
@ -50,27 +62,130 @@ struct SqueezeMask: Shape {
|
|||||||
|
|
||||||
func path(in rect: CGRect) -> Path {
|
func path(in rect: CGRect) -> Path {
|
||||||
var path = Path()
|
var path = Path()
|
||||||
|
let p = CGFloat(progress)
|
||||||
|
|
||||||
// We want the mask (the card back) to shrink towards the top-left
|
// Initial state: Full rectangle
|
||||||
// as progress (the reveal) increases.
|
path.addRect(rect)
|
||||||
// A simple diagonal peel:
|
|
||||||
let p = CGFloat(1.0 - progress)
|
|
||||||
|
|
||||||
path.move(to: .zero)
|
// Subtraction: The "peeled away" triangle
|
||||||
path.addLine(to: CGPoint(x: rect.width * p * 1.5, y: 0))
|
var peelPath = Path()
|
||||||
path.addLine(to: CGPoint(x: 0, y: rect.height * p * 1.5))
|
let size = rect.width * p * 2.0 // Scaling factor for the peel area
|
||||||
path.closeSubpath()
|
|
||||||
|
|
||||||
|
switch corner {
|
||||||
|
case .bottomRight:
|
||||||
|
peelPath.move(to: CGPoint(x: rect.width, y: rect.height))
|
||||||
|
peelPath.addLine(to: CGPoint(x: rect.width - size, y: rect.height))
|
||||||
|
peelPath.addLine(to: CGPoint(x: rect.width, y: rect.height - size))
|
||||||
|
peelPath.closeSubpath()
|
||||||
|
case .bottomLeft:
|
||||||
|
peelPath.move(to: CGPoint(x: 0, y: rect.height))
|
||||||
|
peelPath.addLine(to: CGPoint(x: size, y: rect.height))
|
||||||
|
peelPath.addLine(to: CGPoint(x: 0, y: rect.height - size))
|
||||||
|
peelPath.closeSubpath()
|
||||||
|
}
|
||||||
|
|
||||||
|
// We use a "subtract" logic by drawing the full rect then "erasing" the peel
|
||||||
|
// In SwiftUI Shape, we just return the "remaining" area.
|
||||||
|
// For simplicity, we'll construct the remaining polygon.
|
||||||
|
|
||||||
|
var remainingPath = Path()
|
||||||
|
switch corner {
|
||||||
|
case .bottomRight:
|
||||||
|
remainingPath.move(to: .zero)
|
||||||
|
remainingPath.addLine(to: CGPoint(x: rect.width, y: 0))
|
||||||
|
remainingPath.addLine(to: CGPoint(x: rect.width, y: max(0, rect.height - size)))
|
||||||
|
remainingPath.addLine(to: CGPoint(x: max(0, rect.width - size), y: rect.height))
|
||||||
|
remainingPath.addLine(to: CGPoint(x: 0, y: rect.height))
|
||||||
|
remainingPath.closeSubpath()
|
||||||
|
case .bottomLeft:
|
||||||
|
remainingPath.move(to: .zero)
|
||||||
|
remainingPath.addLine(to: CGPoint(x: rect.width, y: 0))
|
||||||
|
remainingPath.addLine(to: CGPoint(x: rect.width, y: rect.height))
|
||||||
|
remainingPath.addLine(to: CGPoint(x: min(rect.width, size), y: rect.height))
|
||||||
|
remainingPath.addLine(to: CGPoint(x: 0, y: max(0, rect.height - size)))
|
||||||
|
remainingPath.closeSubpath()
|
||||||
|
}
|
||||||
|
|
||||||
|
return remainingPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The visual effect of the folded corner, including shadows and highlight.
|
||||||
|
struct FoldEffect: View {
|
||||||
|
let progress: Double
|
||||||
|
let corner: SqueezeCorner
|
||||||
|
let width: CGFloat
|
||||||
|
let height: CGFloat
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let size = width * CGFloat(progress) * 2.0
|
||||||
|
let foldWidth = size * 0.5
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
// Fold shadow cast on the front of the card
|
||||||
|
FoldShadow(size: size, corner: corner)
|
||||||
|
.opacity(0.3 * (1.0 - progress))
|
||||||
|
|
||||||
|
// The fold itself (the "back" of the peel)
|
||||||
|
FoldShape(size: size, corner: corner)
|
||||||
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.white.opacity(0.8), .gray.opacity(0.3)],
|
||||||
|
startPoint: corner == .bottomRight ? .bottomTrailing : .bottomLeading,
|
||||||
|
endPoint: corner == .bottomRight ? .topLeading : .topTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
FoldShape(size: size, corner: corner)
|
||||||
|
.stroke(Color.white.opacity(0.5), lineWidth: 0.5)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FoldShape: Shape {
|
||||||
|
let size: CGFloat
|
||||||
|
let corner: SqueezeCorner
|
||||||
|
|
||||||
|
func path(in rect: CGRect) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
// A triangle representing the folded over piece
|
||||||
|
switch corner {
|
||||||
|
case .bottomRight:
|
||||||
|
path.move(to: CGPoint(x: rect.width - size/2, y: rect.height - size/2))
|
||||||
|
path.addLine(to: CGPoint(x: rect.width - size, y: rect.height))
|
||||||
|
path.addLine(to: CGPoint(x: rect.width, y: rect.height - size))
|
||||||
|
path.closeSubpath()
|
||||||
|
case .bottomLeft:
|
||||||
|
path.move(to: CGPoint(x: size/2, y: rect.height - size/2))
|
||||||
|
path.addLine(to: CGPoint(x: size, y: rect.height))
|
||||||
|
path.addLine(to: CGPoint(x: 0, y: rect.height - size))
|
||||||
|
path.closeSubpath()
|
||||||
|
}
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
struct FoldShadow: View {
|
||||||
VStack(spacing: 40) {
|
let size: CGFloat
|
||||||
SqueezeCardView(card: Card(suit: .spades, rank: .ace), width: 150, progress: 0.2)
|
let corner: SqueezeCorner
|
||||||
SqueezeCardView(card: Card(suit: .hearts, rank: .king), width: 150, progress: 0.5)
|
|
||||||
SqueezeCardView(card: Card(suit: .diamonds, rank: .seven), width: 150, progress: 0.8)
|
var body: some View {
|
||||||
|
FoldShape(size: size * 1.1, corner: corner)
|
||||||
|
.fill(Color.black)
|
||||||
|
.blur(radius: 4)
|
||||||
|
.offset(x: corner == .bottomRight ? -2 : 2, y: -2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ZStack {
|
||||||
|
Color.CasinoTable.backgroundDark.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 60) {
|
||||||
|
SqueezeCardView(card: Card(suit: .spades, rank: .ace), width: 140, progress: 0.1, corner: .bottomRight)
|
||||||
|
SqueezeCardView(card: Card(suit: .hearts, rank: .king), width: 140, progress: 0.3, corner: .bottomRight)
|
||||||
|
SqueezeCardView(card: Card(suit: .diamonds, rank: .seven), width: 140, progress: 0.5, corner: .bottomLeft)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
.background(Color.CasinoTable.backgroundDark)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user