Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
3aa1ed77ae
commit
e1655ce20c
@ -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
|
||||||
|
ForEach(0..<placeholdersNeeded, id: \.self) { _ in
|
||||||
CardPlaceholderView(width: cardWidth)
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,7 +48,6 @@ 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,
|
||||||
@ -58,13 +57,6 @@ struct DealerHandView: View {
|
|||||||
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 {
|
||||||
|
|||||||
@ -29,14 +29,22 @@ 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)
|
||||||
@ -64,34 +72,45 @@ struct PlayerHandsContainer: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.spring(duration: Design.Animation.springDuration), value: hands.count)
|
.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)
|
.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()
|
.scrollClipDisabled()
|
||||||
.scrollBounceBehavior(.always)
|
.scrollBounceBehavior(.always)
|
||||||
.defaultScrollAnchor(.center)
|
.scrollPosition(id: $scrolledHandID, anchor: .center)
|
||||||
.onChange(of: activeHandIndex) { _, _ in
|
.scrollTargetBehavior(.viewAligned)
|
||||||
scrollToActiveHand(proxy: proxy)
|
.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
|
.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 {
|
.onAppear {
|
||||||
scrollToActiveHand(proxy: proxy)
|
// Set initial position without animation
|
||||||
}
|
scrolledHandID = activeHandID
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.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
|
// MARK: - Previews
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user