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).
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.
private var animationDuration: Double {
Design.Animation.springDuration * dealingSpeed
}
var body: some View {
HStack(spacing: cards.isEmpty ? Design.Spacing.small : cardSpacing) {
if cards.isEmpty {
CardPlaceholderView(width: cardWidth)
HStack(spacing: cards.isEmpty && placeholdersNeeded > 0 ? Design.Spacing.small : cardSpacing) {
if cards.isEmpty && placeholdersNeeded > 0 {
// Show placeholders when empty
ForEach(0..<placeholdersNeeded, id: \.self) { _ in
CardPlaceholderView(width: cardWidth)
}
} else {
// Show actual cards
ForEach(cards.indices, id: \.self) { index in
let faceUp = isFaceUp(index)
CardView(
@ -61,6 +73,13 @@ struct CardStackView: View {
: .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(
@ -96,18 +115,21 @@ extension CardStackView {
dealOffset: CGPoint(
x: Design.DealAnimation.dealerOffsetX,
y: Design.DealAnimation.dealerOffsetY
)
),
minimumCardSlots: 2
)
}
/// 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(
cards: [Card],
cardWidth: CGFloat,
cardSpacing: CGFloat,
showAnimations: Bool,
dealingSpeed: Double,
showCardCount: Bool
showCardCount: Bool,
minimumCardSlots: Int = 2
) -> CardStackView {
CardStackView(
cards: cards,
@ -120,7 +142,8 @@ extension CardStackView {
dealOffset: CGPoint(
x: Design.DealAnimation.playerOffsetX,
y: Design.DealAnimation.playerOffsetY
)
),
minimumCardSlots: minimumCardSlots
)
}
}
@ -138,7 +161,8 @@ extension CardStackView {
dealingSpeed: 1.0,
showCardCount: false,
isFaceUp: { _ in true },
dealOffset: .zero
dealOffset: .zero,
minimumCardSlots: 2
)
}
}

View File

@ -48,7 +48,6 @@ struct DealerHandView: View {
.animation(nil, value: showHoleCard)
// Cards with result badge overlay
HStack(spacing: hand.cards.isEmpty ? Design.Spacing.small : cardSpacing) {
CardStackView.dealer(
cards: hand.cards,
showHoleCard: showHoleCard,
@ -58,13 +57,6 @@ struct DealerHandView: View {
dealingSpeed: dealingSpeed,
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 {
// Result badge - centered on cards
if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil {

View File

@ -29,14 +29,22 @@ struct PlayerHandsContainer: View {
/// Whether the hint toast should be visible.
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 {
hands.reduce(0) { $0 + $1.cards.count }
}
var body: some View {
ScrollViewReader { proxy in
ScrollView(.horizontal, showsIndicators: false) {
/// The hands content HStack
private var handsContent: some View {
HStack(spacing: Design.Spacing.large) {
// Display hands in reverse order (right to left play order)
// Visual order: Hand 3, Hand 2, Hand 1 (left to right)
@ -64,34 +72,45 @@ struct PlayerHandsContainer: View {
}
}
.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)
}
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
handsContent
.padding(.horizontal, Design.Spacing.xxLarge)
// 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)
.defaultScrollAnchor(.center)
.onChange(of: activeHandIndex) { _, _ in
scrollToActiveHand(proxy: proxy)
.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: totalCardCount) { _, _ in
scrollToActiveHand(proxy: proxy)
}
.onChange(of: hands.count) { _, _ in
scrollToActiveHand(proxy: proxy)
// Re-center when hands are added (split)
if let activeID = activeHandID {
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
scrolledHandID = activeID
}
}
}
.onAppear {
scrollToActiveHand(proxy: proxy)
}
// Set initial position without animation
scrolledHandID = activeHandID
}
.frame(maxWidth: .infinity)
}
private func scrollToActiveHand(proxy: ScrollViewProxy) {
guard activeHandIndex < hands.count else { return }
let activeHandId = hands[activeHandIndex].id
withAnimation(.easeInOut(duration: Design.Animation.quick)) {
proxy.scrollTo(activeHandId, anchor: .center)
}
}
}
// MARK: - Previews