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" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
|
||||
@ -21,25 +21,11 @@ struct InteractiveCardView: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if revealStyle == .squeeze && !isFaceUp && isWaiting {
|
||||
// Specialized squeeze view
|
||||
SqueezeCardView(card: card, width: cardWidth, progress: progress)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
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)
|
||||
}
|
||||
PageCurlView(
|
||||
card: card,
|
||||
width: cardWidth,
|
||||
onReveal: onReveal
|
||||
)
|
||||
.overlay {
|
||||
if progress < 0.05 {
|
||||
revealInstructionOverlay(text: String(localized: "PEEL"))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standard CardView (handles its own flip animation)
|
||||
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
|
||||
// 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 CasinoKit
|
||||
|
||||
enum SqueezeCorner {
|
||||
case bottomLeft
|
||||
case bottomRight
|
||||
}
|
||||
|
||||
struct SqueezeCardView: View {
|
||||
let card: Card
|
||||
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 {
|
||||
width * CasinoDesign.Size.cardAspectRatio
|
||||
@ -19,29 +26,34 @@ struct SqueezeCardView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// The front is always underneath
|
||||
// 1. The front is always at the bottom
|
||||
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)
|
||||
.mask(
|
||||
SqueezeMask(progress: progress)
|
||||
.frame(width: width, height: height)
|
||||
)
|
||||
.shadow(
|
||||
color: .black.opacity(Design.Opacity.medium * (1.0 - progress)),
|
||||
radius: 2,
|
||||
x: 2,
|
||||
y: 2
|
||||
SqueezeMask(progress: progress, corner: corner)
|
||||
.fill(Style.maskFill)
|
||||
)
|
||||
|
||||
// 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)
|
||||
.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 {
|
||||
var progress: Double
|
||||
var corner: SqueezeCorner
|
||||
|
||||
var animatableData: Double {
|
||||
get { progress }
|
||||
@ -50,27 +62,130 @@ struct SqueezeMask: Shape {
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let p = CGFloat(progress)
|
||||
|
||||
// We want the mask (the card back) to shrink towards the top-left
|
||||
// as progress (the reveal) increases.
|
||||
// A simple diagonal peel:
|
||||
let p = CGFloat(1.0 - progress)
|
||||
// Initial state: Full rectangle
|
||||
path.addRect(rect)
|
||||
|
||||
path.move(to: .zero)
|
||||
path.addLine(to: CGPoint(x: rect.width * p * 1.5, y: 0))
|
||||
path.addLine(to: CGPoint(x: 0, y: rect.height * p * 1.5))
|
||||
// Subtraction: The "peeled away" triangle
|
||||
var peelPath = Path()
|
||||
let size = rect.width * p * 2.0 // Scaling factor for the peel area
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack(spacing: 40) {
|
||||
SqueezeCardView(card: Card(suit: .spades, rank: .ace), width: 150, progress: 0.2)
|
||||
SqueezeCardView(card: Card(suit: .hearts, rank: .king), width: 150, progress: 0.5)
|
||||
SqueezeCardView(card: Card(suit: .diamonds, rank: .seven), width: 150, progress: 0.8)
|
||||
struct FoldShadow: View {
|
||||
let size: CGFloat
|
||||
let corner: SqueezeCorner
|
||||
|
||||
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