diff --git a/Blackjack/Blackjack/Views/Table/CardStackView.swift b/Blackjack/Blackjack/Views/Table/CardStackView.swift index d7392b9..cef4ea7 100644 --- a/Blackjack/Blackjack/Views/Table/CardStackView.swift +++ b/Blackjack/Blackjack/Views/Table/CardStackView.swift @@ -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) - CardPlaceholderView(width: cardWidth) + HStack(spacing: cards.isEmpty && placeholdersNeeded > 0 ? Design.Spacing.small : cardSpacing) { + if cards.isEmpty && placeholdersNeeded > 0 { + // Show placeholders when empty + ForEach(0.. 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 ) } } diff --git a/Blackjack/Blackjack/Views/Table/DealerHandView.swift b/Blackjack/Blackjack/Views/Table/DealerHandView.swift index a2b189d..78c53bd 100644 --- a/Blackjack/Blackjack/Views/Table/DealerHandView.swift +++ b/Blackjack/Blackjack/Views/Table/DealerHandView.swift @@ -48,23 +48,15 @@ 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, - cardWidth: cardWidth, - cardSpacing: cardSpacing, - showAnimations: showAnimations, - 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) - } - } + CardStackView.dealer( + cards: hand.cards, + showHoleCard: showHoleCard, + cardWidth: cardWidth, + cardSpacing: cardSpacing, + showAnimations: showAnimations, + dealingSpeed: dealingSpeed, + showCardCount: showCardCount + ) .overlay { // Result badge - centered on cards if let result = hand.cards.count >= 2 && showHoleCard ? handResultText : nil { diff --git a/Blackjack/Blackjack/Views/Table/PlayerHandsContainer.swift b/Blackjack/Blackjack/Views/Table/PlayerHandsContainer.swift index b462323..84d3b09 100644 --- a/Blackjack/Blackjack/Views/Table/PlayerHandsContainer.swift +++ b/Blackjack/Blackjack/Views/Table/PlayerHandsContainer.swift @@ -29,68 +29,87 @@ 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) { - 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) - // Play order: Hand 1 played first (rightmost), then Hand 2, etc. - ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in - let isActiveHand = index == activeHandIndex && isPlayerTurn - let visibleCount = index < visibleCardCounts.count ? visibleCardCounts[index] : 0 - PlayerHandView( - hand: hand, - isActive: isActiveHand, - showCardCount: showCardCount, - showAnimations: showAnimations, - dealingSpeed: dealingSpeed, - // Hand numbers: rightmost (index 0) is Hand 1, played first - handNumber: hands.count > 1 ? index + 1 : nil, - cardWidth: cardWidth, - cardSpacing: cardSpacing, - visibleCardCount: visibleCount, - // Only show hint on the active hand - currentHint: isActiveHand ? currentHint : nil, - showHintToast: isActiveHand && showHintToast - ) - .id(hand.id) - .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) + /// 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) + // Play order: Hand 1 played first (rightmost), then Hand 2, etc. + ForEach(Array(hands.enumerated()).reversed(), id: \.element.id) { index, hand in + let isActiveHand = index == activeHandIndex && isPlayerTurn + let visibleCount = index < visibleCardCounts.count ? visibleCardCounts[index] : 0 + PlayerHandView( + hand: hand, + isActive: isActiveHand, + showCardCount: showCardCount, + showAnimations: showAnimations, + dealingSpeed: dealingSpeed, + // Hand numbers: rightmost (index 0) is Hand 1, played first + handNumber: hands.count > 1 ? index + 1 : nil, + cardWidth: cardWidth, + cardSpacing: cardSpacing, + visibleCardCount: visibleCount, + // Only show hint on the active hand + currentHint: isActiveHand ? currentHint : nil, + showHintToast: isActiveHand && showHintToast + ) + .id(hand.id) + .transition(.scale.combined(with: .opacity)) } } - .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) { - guard activeHandIndex < hands.count else { return } - let activeHandId = hands[activeHandIndex].id - withAnimation(.easeInOut(duration: Design.Animation.quick)) { - proxy.scrollTo(activeHandId, anchor: .center) + 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) + .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) } }