This is a prompted learning app that shows you a question and its answer on a card. There are a stack of cards with each card representing an item in an array.
If you get the answer correct you swipe right and the card is deleted. If you get it wrong, you swipe left and the card is supposed to be moved to the back of the stack for you to try again. The problem is the ForEach is not updating the view even though the move has been successful.
I have tried creating the array directly in ContentView bypassing the class but this doesn't work. I also tried to move the work from CardView to ContentView but no shot. What is going wrong?
I have stripped down the code to the barest minimum to reproduce the problem here:
struct Card: Identifiable, Equatable {
let id = UUID()
var question: String
var answer: String
static func ==(lhs: Card, rhs: Card) -> Bool {
return lhs.id == rhs.id
}
#if DEBUG
static var example = Card(question: "How many stars are in the milky way galaxy?", answer: "Just one you")
static var example2 = Card(question: "What is your name?", answer: "Bugs Bunny")
static var example3 = Card(question: "What is your favorite color?", answer: "Red")
static var example4 = Card(question: "What is the largest building in the world?", answer: "The Burj Khalifa")
static var example5 = Card(question: "Average daily human calory intake", answer: "2000kcal")
#endif
}
@Observable
class CardC {
var items: [Card] = [.example, .example2, .example3, .example4, .example5]
}
struct ContentView: View {
@State private var cards = CardC()
func resetCards() {
cards.items = [.example, .example2, .example3, .example4, .example5]
}
var body: some View {
VStack {
if cards.items.isEmpty {
withAnimation {
Button("Restart game", role: .cancel, action: resetCards)
.buttonStyle(.borderedProminent)
}
} else {
ZStack {
ForEach(Array(cards.items.enumerated()), id: \.element.id) { index, card in
CardView(cards: cards, card: card)
.allowsHitTesting(index == cards.items.count - 1)
}
}
}
}
.onAppear(perform: resetCards)
}
}
#Preview {
ContentView()
}
struct CardView: View {
@Bindable var cards: CardC
let card: Card
@State private var offset = CGSize.zero
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(.white)
VStack(alignment: .center) {
Text(card.question)
Text(card.answer)
}
}
.shadow(radius: 10)
.frame(width: 450, height: 250)
.offset(x: offset.width * 5)
.gesture(
DragGesture()
.onChanged { drag in
offset = drag.translation
}
.onEnded { _ in
let cardCopy = card
if abs(offset.width) > 100 {
let index = cards.items.firstIndex(of: card)!
cards.items.remove(at: index)
if offset.width < -100 {
cards.items.insert(cardCopy, at: 0)
}
} else {
withAnimation {
offset = .zero
}
}
}
)
}
}
#Preview {
CardView(cards: CardC(), card: .example)
}
The issue is that when you swipe left, you're changing the offset of the card that's supposed to go to the bottom of the stack, but you're not resetting the offset after.
So in the .onEnded
of your DragGesture
, if swiping left, insert the card back and reset its offset:
if offset.width < -100 {
cards.items.insert(cardCopy, at: 0)
offset = .zero // <- here, reset the offset after inserting the card back in
}
Since you're using a model, your insertion/removal functions should be part of the model, not defined in the gesture. The gesture should simply call the respective functions as needed.
So your @Observable
could look something like this:
@Observable
class CardStack {
var cards: [ItemCard] = [.example, .example2, .example3, .example4, .example5]
func moveToBottom(_ card: ItemCard) {
guard let index = cards.firstIndex(of: card) else { return }
let removed = cards.remove(at: index)
cards.insert(removed, at: 0)
}
func popLast() {
_ = cards.popLast()
}
func resetCards() {
cards = [.example, .example2, .example3, .example4, .example5]
}
}
Then in your gesture's .onEnded
:
.onEnded { _ in
if offset.width > 100 {
//remove from stack
stack.popLast()
}
else if offset.width < -100 {
//move to back of stack
stack.moveToBottom(card)
offset = .zero // <- reset offset after moving card to bottom of stack
}
else {
withAnimation {
offset = .zero
}
}
}
Your .tinderSwipe
function forces the background to be defined three times. If ever you want to change any card background properties, you will have to update it accordingly. Instead, I suggest a simpler function that only returns a color based on swipe distance:
func cardColor(_ translation: Double) -> Color {
if translation < 0 {
return .red
} else if translation == 0 {
return .white
} else {
return .green
}
}
That you call in a single .background
modifier:
.background(cardColor(offset.width), in: .rect(cornerRadius: 25))
Here's the complete working code with the suggested changes:
import SwiftUI
struct ItemCard: Identifiable, Equatable {
let id = UUID()
var question: String
var answer: String
static func ==(lhs: ItemCard, rhs: ItemCard) -> Bool {
return lhs.id == rhs.id
}
#if DEBUG
static var example = ItemCard(question: "How many stars are in the milky way galaxy?", answer: "Just one you")
static var example2 = ItemCard(question: "What is your name?", answer: "Bugs Bunny")
static var example3 = ItemCard(question: "What is your favorite color?", answer: "Red")
static var example4 = ItemCard(question: "What is the largest building in the world?", answer: "The Burj Khalifa")
static var example5 = ItemCard(question: "Average daily human calory intake", answer: "2000kcal")
#endif
}
@Observable
class CardStack {
var cards: [ItemCard] = [.example, .example2, .example3, .example4, .example5]
func moveToBottom(_ card: ItemCard) {
guard let index = cards.firstIndex(of: card) else { return }
let removed = cards.remove(at: index)
cards.insert(removed, at: 0)
}
func popLast() {
_ = cards.popLast()
}
func resetCards() {
cards = [.example, .example2, .example3, .example4, .example5]
}
}
struct CardsContentView: View {
//Observables
@State private var stack = CardStack()
//Body
var body: some View {
VStack {
if stack.cards.isEmpty {
withAnimation {
Button("Restart game", role: .cancel, action: stack.resetCards)
.buttonStyle(.borderedProminent)
}
} else {
ZStack {
ForEach(Array(stack.cards.enumerated()), id: \.element.id) { index, card in
CardView(stack: stack, card: card)
.allowsHitTesting(index == stack.cards.count - 1)
}
}
.padding()
}
HStack {
Text("Cards in stack: \(stack.cards.count)")
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .center)
.onAppear(perform: stack.resetCards)
}
}
#Preview {
CardsContentView()
}
struct CardView: View {
//Parameters
@Bindable var stack: CardStack
let card: ItemCard
var removal: (() -> Void)? = nil
//State values
@State private var isShowingAnswer = false
@State private var offset = CGSize.zero
func cardColor(_ translation: Double) -> Color {
if translation < 0 {
return .red
} else if translation == 0 {
return .white
} else {
return .green
}
}
//Body
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(Color.gray.opacity(0.3))
.opacity(1.0 - abs(offset.width/50.0))
.background(cardColor(offset.width), in: .rect(cornerRadius: 25))
VStack(alignment: .center) {
Text(card.question)
.font(.largeTitle)
.foregroundStyle(.black)
if isShowingAnswer {
Text(card.answer)
.font(.title)
.foregroundStyle(.secondary)
}
}
.padding()
.multilineTextAlignment(.center)
}
.padding()
// .frame(width: 450, height: 250)
.opacity(2 - abs(offset.width/50.0))
.animation(.bouncy, value: offset)
.rotationEffect(.degrees(offset.width/5.0))
.offset(x: offset.width * 5)
.onTapGesture {
withAnimation {
isShowingAnswer.toggle()
}
}
.highPriorityGesture(
DragGesture()
.onChanged { drag in
offset = drag.translation
}
.onEnded { _ in
if offset.width > 100 {
//remove from stack
stack.popLast()
}
else if offset.width < -100 {
//move to back of stack
stack.moveToBottom(card)
offset = .zero // <- reset offset after moving card to bottom of stack
}
else {
withAnimation {
offset = .zero
}
}
}
)
}
}
#Preview {
CardView(stack: CardStack(), card: .example)
}