Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
612b04843b
commit
22884fcba7
@ -412,6 +412,192 @@ Text(String(localized: "Blackjack!"))
|
|||||||
Text(String(localized: "Bust!"))
|
Text(String(localized: "Bust!"))
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Responsive Layout (iPhone vs iPad)
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
On iPad, content that looks great on iPhone will **stretch to fill the screen**, making:
|
||||||
|
- Betting areas look awkward and disproportionate
|
||||||
|
- Cards appear too spread out
|
||||||
|
- Buttons become oversized
|
||||||
|
- Overlays cover the entire screen unnecessarily
|
||||||
|
|
||||||
|
### The Solution: Constrained Width Containers
|
||||||
|
|
||||||
|
Use `horizontalSizeClass` to detect iPad and constrain content width:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct GameTableView: View {
|
||||||
|
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||||
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||||
|
|
||||||
|
/// Whether we're on iPad (regular horizontal size class)
|
||||||
|
private var isIPad: Bool {
|
||||||
|
horizontalSizeClass == .regular
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum content width based on device and orientation
|
||||||
|
private var maxContentWidth: CGFloat {
|
||||||
|
if isIPad {
|
||||||
|
// Landscape on iPad gets more width
|
||||||
|
return verticalSizeClass == .compact
|
||||||
|
? CasinoDesign.Size.maxContentWidthLandscape // 800pt
|
||||||
|
: CasinoDesign.Size.maxContentWidthPortrait // 500pt
|
||||||
|
}
|
||||||
|
return .infinity // iPhone uses full width
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
TableBackgroundView()
|
||||||
|
|
||||||
|
VStack {
|
||||||
|
TopBarView(...)
|
||||||
|
.frame(maxWidth: maxContentWidth)
|
||||||
|
|
||||||
|
// Game table - constrained on iPad
|
||||||
|
YourTableLayoutView(...)
|
||||||
|
.frame(maxWidth: maxContentWidth)
|
||||||
|
|
||||||
|
// Chip selector - constrained
|
||||||
|
ChipSelectorView(...)
|
||||||
|
.frame(maxWidth: maxContentWidth)
|
||||||
|
|
||||||
|
// Action buttons - constrained
|
||||||
|
ActionButtonsView(...)
|
||||||
|
.frame(maxWidth: maxContentWidth)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity) // Centers constrained content
|
||||||
|
|
||||||
|
// Overlays - full screen background, constrained content
|
||||||
|
if showResultBanner {
|
||||||
|
ResultBannerView(...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Overlay Pattern (Result Banner, Game Over)
|
||||||
|
|
||||||
|
Overlays need special handling: **full-screen dim background** with **constrained content card**:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct ResultBannerView: View {
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// 1. Full-screen dark background
|
||||||
|
Color.black.opacity(0.7)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// 2. Constrained content card
|
||||||
|
VStack {
|
||||||
|
// Your content
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(RoundedRectangle(cornerRadius: 24).fill(...))
|
||||||
|
.frame(maxWidth: CasinoDesign.Size.maxModalWidth) // 450pt
|
||||||
|
// Centered automatically by ZStack
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fixed-Size Containers (Prevent Layout Shifts)
|
||||||
|
|
||||||
|
When content changes (cards dealt, buttons appear/disappear), prevent jarring layout shifts:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// ❌ BAD: Container resizes as cards are added
|
||||||
|
HStack {
|
||||||
|
ForEach(cards) { card in
|
||||||
|
CardView(card: card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ GOOD: Fixed container based on max possible content
|
||||||
|
ZStack {
|
||||||
|
// Reserve space for max cards (e.g., 3 cards with overlap)
|
||||||
|
Color.clear
|
||||||
|
.frame(width: calculateMaxWidth(), height: cardHeight)
|
||||||
|
|
||||||
|
// Actual cards centered within
|
||||||
|
HStack(spacing: cardSpacing) {
|
||||||
|
ForEach(cards) { card in
|
||||||
|
CardView(card: card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Same for buttons:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// ✅ GOOD: Fixed height container for buttons
|
||||||
|
ZStack {
|
||||||
|
Color.clear
|
||||||
|
.frame(height: 60) // Fixed height
|
||||||
|
|
||||||
|
// Buttons animate in/out within fixed space
|
||||||
|
if showDealButton {
|
||||||
|
ActionButton("Deal", ...)
|
||||||
|
.transition(.scale.combined(with: .opacity))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.spring(duration: 0.3), value: currentPhase)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Confetti Full-Screen Fix
|
||||||
|
|
||||||
|
Confetti must cover the entire screen on iPad:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct ConfettiView: View {
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ZStack {
|
||||||
|
ForEach(0..<50, id: \.self) { _ in
|
||||||
|
ConfettiPiece(containerSize: geometry.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.ignoresSafeArea() // Critical!
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Design Constants for Responsive Layout
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// In CasinoDesign.swift
|
||||||
|
enum Size {
|
||||||
|
// Max widths for iPad constraint
|
||||||
|
static let maxContentWidthPortrait: CGFloat = 500
|
||||||
|
static let maxContentWidthLandscape: CGFloat = 800
|
||||||
|
static let maxModalWidth: CGFloat = 450
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Pitfalls
|
||||||
|
|
||||||
|
| Issue | Symptom | Fix |
|
||||||
|
|-------|---------|-----|
|
||||||
|
| Stretched table | Betting zones look huge on iPad | Add `.frame(maxWidth: maxContentWidth)` |
|
||||||
|
| Overlay too wide | Result banner covers entire iPad screen | Use full-screen bg + constrained content card |
|
||||||
|
| Layout shifts | Cards/buttons cause content to jump | Use fixed-size `ZStack` containers |
|
||||||
|
| Confetti cut off | Only shows in center portion | Use `GeometryReader` + `.ignoresSafeArea()` |
|
||||||
|
| Settings rows cramped | Title/subtitle too close to divider | Add `.padding(.vertical, ...)` |
|
||||||
|
|
||||||
|
### Testing Checklist
|
||||||
|
|
||||||
|
- [ ] iPhone SE (smallest)
|
||||||
|
- [ ] iPhone Pro Max (largest iPhone)
|
||||||
|
- [ ] iPad Portrait
|
||||||
|
- [ ] iPad Landscape
|
||||||
|
- [ ] iPad Split View
|
||||||
|
- [ ] Dynamic Type at maximum accessibility size
|
||||||
|
|
||||||
## Checklist for New Game
|
## Checklist for New Game
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
@ -445,11 +631,19 @@ Text(String(localized: "Bust!"))
|
|||||||
- [ ] Add localization for all strings
|
- [ ] Add localization for all strings
|
||||||
|
|
||||||
### Polish
|
### Polish
|
||||||
- [ ] Test Dynamic Type scaling
|
- [ ] Test Dynamic Type scaling (all sizes including accessibility)
|
||||||
- [ ] Test VoiceOver
|
- [ ] Test VoiceOver navigation
|
||||||
- [ ] Test iPad layout
|
|
||||||
- [ ] Create app icon using `AppIconView`
|
- [ ] Create app icon using `AppIconView`
|
||||||
|
|
||||||
|
### Responsive Layout (iPad)
|
||||||
|
- [ ] Add `maxContentWidth` constraint to main views
|
||||||
|
- [ ] Test iPad Portrait - content centered, not stretched
|
||||||
|
- [ ] Test iPad Landscape - wider constraint works well
|
||||||
|
- [ ] Verify overlays: full-screen bg, constrained content
|
||||||
|
- [ ] Fixed-size containers prevent layout shifts
|
||||||
|
- [ ] Confetti covers full screen
|
||||||
|
- [ ] Settings rows have proper padding
|
||||||
|
|
||||||
## What Could Be Added to CasinoKit
|
## What Could Be Added to CasinoKit
|
||||||
|
|
||||||
The following patterns from Baccarat could be abstracted:
|
The following patterns from Baccarat could be abstracted:
|
||||||
|
|||||||
@ -65,6 +65,10 @@
|
|||||||
"comment" : "The value of the balance displayed in the top bar.",
|
"comment" : "The value of the balance displayed in the top bar.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"$%lld bet" : {
|
||||||
|
"comment" : "A value describing the bet amount in the accessibility label. The argument is the bet amount.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"1. Use Xcode's preview to screenshot these icons" : {
|
"1. Use Xcode's preview to screenshot these icons" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@ -124,6 +128,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Betting disabled" : {
|
||||||
|
"comment" : "A hint that appears when a betting zone is disabled.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Card face down" : {
|
"Card face down" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -278,6 +286,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Double tap to place bet" : {
|
||||||
|
"comment" : "A hint text describing the action to take in the betting zone.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Eight" : {
|
"Eight" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -656,6 +668,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"No bet" : {
|
||||||
|
"comment" : "A description of a zone with no active bet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Perfect for learning" : {
|
"Perfect for learning" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -1078,28 +1094,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"You've run out of chips!" : {
|
|
||||||
"localizations" : {
|
|
||||||
"en" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "You've run out of chips!"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"es-MX" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "¡Te quedaste sin fichas!"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fr-CA" : {
|
|
||||||
"stringUnit" : {
|
|
||||||
"state" : "translated",
|
|
||||||
"value" : "Vous n'avez plus de jetons!"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"WIN" : {
|
"WIN" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"en" : {
|
"en" : {
|
||||||
@ -1121,6 +1115,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"You've run out of chips!" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "You've run out of chips!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es-MX" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "¡Te quedaste sin fichas!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr-CA" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "Vous n'avez plus de jetons!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"version" : "1.1"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user