Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2025-12-31 09:52:56 -06:00
parent 3aa1ed77ae
commit e1655ce20c
3 changed files with 113 additions and 78 deletions

View File

@ -26,17 +26,29 @@ struct CardStackView: View {
/// Animation offset for dealing cards (direction cards fly in from). /// Animation offset for dealing cards (direction cards fly in from).
let dealOffset: CGPoint let dealOffset: CGPoint
/// Minimum number of card slots to reserve space for.
/// This prevents width changes during dealing by pre-allocating placeholder space.
let minimumCardSlots: Int
/// Number of additional placeholders needed to reach minimum slots.
private var placeholdersNeeded: Int {
max(0, minimumCardSlots - cards.count)
}
/// Scaled animation duration based on dealing speed. /// Scaled animation duration based on dealing speed.
private var animationDuration: Double { private var animationDuration: Double {
Design.Animation.springDuration * dealingSpeed Design.Animation.springDuration * dealingSpeed
} }
var body: some View { var body: some View {
HStack(spacing: cards.isEmpty ? Design.Spacing.small : cardSpacing) { HStack(spacing: cards.isEmpty && placeholdersNeeded > 0 ? Design.Spacing.small : cardSpacing) {
if cards.isEmpty { if cards.isEmpty && placeholdersNeeded > 0 {
CardPlaceholderView(width: cardWidth) // Show placeholders when empty
CardPlaceholderView(width: cardWidth) ForEach(0..<placeholdersNeeded, id: \.self) { _ in
CardPlaceholderView(width: cardWidth)
}
} else { } else {
// Show actual cards
ForEach(cards.indices, id: \.self) { index in ForEach(cards.indices, id: \.self) { index in
let faceUp = isFaceUp(index) let faceUp = isFaceUp(index)
CardView( CardView(
@ -61,6 +73,13 @@ struct CardStackView: View {
: .identity : .identity
) )
} }
// Show trailing placeholders to reserve space for upcoming cards
ForEach(0..<placeholdersNeeded, id: \.self) { index in
CardPlaceholderView(width: cardWidth)
.opacity(Design.Opacity.medium)
.zIndex(Double(cards.count + index))
}
} }
} }
.animation( .animation(
@ -96,18 +115,21 @@ extension CardStackView {
dealOffset: CGPoint( dealOffset: CGPoint(
x: Design.DealAnimation.dealerOffsetX, x: Design.DealAnimation.dealerOffsetX,
y: Design.DealAnimation.dealerOffsetY y: Design.DealAnimation.dealerOffsetY
) ),
minimumCardSlots: 2
) )
} }
/// Creates a card stack for the player (all cards face up). /// Creates a card stack for the player (all cards face up).
/// - Parameter minimumCardSlots: Minimum slots to reserve (default 2 for initial deal).
static func player( static func player(
cards: [Card], cards: [Card],
cardWidth: CGFloat, cardWidth: CGFloat,
cardSpacing: CGFloat, cardSpacing: CGFloat,
showAnimations: Bool, showAnimations: Bool,
dealingSpeed: Double, dealingSpeed: Double,
showCardCount: Bool showCardCount: Bool,
minimumCardSlots: Int = 2
) -> CardStackView { ) -> CardStackView {
CardStackView( CardStackView(
cards: cards, cards: cards,
@ -120,7 +142,8 @@ extension CardStackView {
dealOffset: CGPoint( dealOffset: CGPoint(
x: Design.DealAnimation.playerOffsetX, x: Design.DealAnimation.playerOffsetX,
y: Design.DealAnimation.playerOffsetY y: Design.DealAnimation.playerOffsetY
) ),
minimumCardSlots: minimumCardSlots
) )
} }
} }
@ -138,7 +161,8 @@ extension CardStackView {
dealingSpeed: 1.0, dealingSpeed: 1.0,
showCardCount: false, showCardCount: false,
isFaceUp: { _ in true }, isFaceUp: { _ in true },
dealOffset: .zero dealOffset: .zero,
minimumCardSlots: 2
) )
} }
} }

View File

@ -48,23 +48,15 @@ struct DealerHandView: View {
.animation(nil, value: showHoleCard) .animation(nil, value: showHoleCard)
// Cards with result badge overlay // Cards with result badge overlay
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) { CardStackView.dealer(
CardStackView.dealer( cards: hand.cards,
cards: hand.cards, showHoleCard: showHoleCard,
showHoleCard: showHoleCard, cardWidth: cardWidth,
cardWidth: cardWidth, cardSpacing: cardSpacing,
cardSpacing: cardSpacing, showAnimations: showAnimations,
showAnimations: showAnimations, dealingSpeed: dealingSpeed,
dealingSpeed: dealingSpeed, showCardCount: showCardCount
showCardCount: showCardCount )
)
// Show placeholder for second card in European mode (no hole card)
if hand.cards.count == 1 && !showHoleCard {
CardPlaceholderView(width: cardWidth)
.opacity(Design.Opacity.medium)
}
}
.overlay { .overlay {
// Result badge - centered on cards // Result badge - centered on cards
if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil { if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil {

View File

@ -29,68 +29,87 @@ struct PlayerHandsContainer: View {
/// Whether the hint toast should be visible. /// Whether the hint toast should be visible.
let showHintToast: Bool let showHintToast: Bool
/// Total card count across all hands - used to trigger scroll when hitting /// Tracks the currently scrolled-to hand ID for smooth positioning
@State private var scrolledHandID: UUID?
/// The active hand's ID, used for scroll position binding
private var activeHandID: UUID? {
guard activeHandIndex < hands.count else { return nil }
return hands[activeHandIndex].id
}
/// Total card count across all hands - used for animation
private var totalCardCount: Int { private var totalCardCount: Int {
hands.reduce(0) { $0 + $1.cards.count } hands.reduce(0) { $0 + $1.cards.count }
} }
var body: some View { /// The hands content HStack
ScrollViewReader { proxy in private var handsContent: some View {
ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: Design.Spacing.large) {
HStack(spacing: Design.Spacing.large) { // Display hands in reverse order (right to left play order)
// Display hands in reverse order (right to left play order) // Visual order: Hand 3, Hand 2, Hand 1 (left to right)
// Visual order: Hand 3, Hand 2, Hand 1 (left to right) // Play order: Hand 1 played first (rightmost), then Hand 2, etc.
// Play order: Hand 1 played first (rightmost), then Hand 2, etc. ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in
ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in let isActiveHand = index == activeHandIndex && isPlayerTurn
let isActiveHand = index == activeHandIndex && isPlayerTurn let visibleCount = index < visibleCardCounts.count ? visibleCardCounts[index] : 0
let visibleCount = index < visibleCardCounts.count ? visibleCardCounts[index] : 0 PlayerHandView(
PlayerHandView( hand: hand,
hand: hand, isActive: isActiveHand,
isActive: isActiveHand, showCardCount: showCardCount,
showCardCount: showCardCount, showAnimations: showAnimations,
showAnimations: showAnimations, dealingSpeed: dealingSpeed,
dealingSpeed: dealingSpeed, // Hand numbers: rightmost (index 0) is Hand 1, played first
// Hand numbers: rightmost (index 0) is Hand 1, played first handNumber: hands.count > 1 ? index + 1 : nil,
handNumber: hands.count > 1 ? index + 1 : nil, cardWidth: cardWidth,
cardWidth: cardWidth, cardSpacing: cardSpacing,
cardSpacing: cardSpacing, visibleCardCount: visibleCount,
visibleCardCount: visibleCount, // Only show hint on the active hand
// Only show hint on the active hand currentHint: isActiveHand ? currentHint : nil,
currentHint: isActiveHand ? currentHint : nil, showHintToast: isActiveHand && showHintToast
showHintToast: isActiveHand && showHintToast )
) .id(hand.id)
.id(hand.id) .transition(.scale.combined(with: .opacity))
.transition(.scale.combined(with: .opacity))
}
}
.animation(.spring(duration: Design.Animation.springDuration), value: hands.count)
.padding(.horizontal, Design.Spacing.xxLarge)
}
.scrollClipDisabled()
.scrollBounceBehavior(.always)
.defaultScrollAnchor(.center)
.onChange(of: activeHandIndex) { _, _ in
scrollToActiveHand(proxy: proxy)
}
.onChange(of: totalCardCount) { _, _ in
scrollToActiveHand(proxy: proxy)
}
.onChange(of: hands.count) { _, _ in
scrollToActiveHand(proxy: proxy)
}
.onAppear {
scrollToActiveHand(proxy: proxy)
} }
} }
.frame(maxWidth: .infinity) .animation(.spring(duration: Design.Animation.springDuration), value: hands.count)
// Animate card count changes so width growth is smooth
.animation(.spring(duration: Design.Animation.springDuration), value: totalCardCount)
} }
private func scrollToActiveHand(proxy: ScrollViewProxy) { var body: some View {
guard activeHandIndex < hands.count else { return } ScrollView(.horizontal, showsIndicators: false) {
let activeHandId = hands[activeHandIndex].id handsContent
withAnimation(.easeInOut(duration: Design.Animation.quick)) { .padding(.horizontal, Design.Spacing.xxLarge)
proxy.scrollTo(activeHandId, anchor: .center) // Ensure minimum width to fill viewport, centering smaller content via layout
// This avoids scroll anchor re-centering which doesn't animate
.containerRelativeFrame(.horizontal, alignment: .center)
.scrollTargetLayout()
} }
.scrollClipDisabled()
.scrollBounceBehavior(.always)
.scrollPosition(id: $scrolledHandID, anchor: .center)
.scrollTargetBehavior(.viewAligned)
.onChange(of: activeHandID) { _, newID in
// Animate scroll when switching between split hands
if let newID {
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
scrolledHandID = newID
}
}
}
.onChange(of: hands.count) { _, _ in
// Re-center when hands are added (split)
if let activeID = activeHandID {
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
scrolledHandID = activeID
}
}
}
.onAppear {
// Set initial position without animation
scrolledHandID = activeHandID
}
.frame(maxWidth: .infinity)
} }
} }