How to make a flashcard app with Swift UI
In this tutorial you will make Animalization, a flashcard app for memorizing the names of animals. After building this app, you will know how to organize an MVVM app, and how to create any simple animation you can think of.
Part 1: A New Hope
File Structure
Create a fresh Swift UI app named Animalization. That’s the best possible name for this app. Do not change it. Setup the file structure to follow the MVVM pattern. Create folders for Data as well.
Cards
Take the card in flashcard very literally. Let’s make the card views first.
Step 1: Data
Get the images for the cards.
https://www.liyicky.com/downloads/animalization
Take those and pop them into the assets file. Move the JSON files too while you’re at it.
Let’s setup our Codable models. This will allow us to turn those json into usable code objects. Make two new files in the model folder called Animal.swift and Deck.swift.
// Animal.swift
struct Animal: Codable {
var id: Int
var filename: String
var photographer: String
var name: String
var answers: [String]
}
// Deck.swift
struct Deck: Codable {
var id: Int
var name: String
var animalIds: [Int]
var animals: [Animal] {
Constants.animals.filter { animalIds.contains($0.id) }
}
}
Create an extension on Bundle that takes generics and lets you avoid writing the same code over and over.
// BundleExtension.swift
import Foundation
extension Bundle {
func decode<T: Codable>(_ file: String) -> T {
// 1. Locate the JSON file
guard let url = self.url(forResource: file, withExtension: nil) else {
fatalError("Failed to locate \(file) in bundle.")
}
// 2. Create a property for the data
guard let data = try? Data(contentsOf: url) else {
fatalError("Failed to load \(file) from bundle.")
}
// 3. Create a decoder
let decoder = JSONDecoder()
// 4. Create a property for the decoded data
guard let decodedData = try? decoder.decode(T.self, from: data) else {
fatalError("Failed to decode \(file) from bundle.")
}
// 5. Return the ready-to-use data
return decodedData
}
}
Now we can use that in our Constants file. Except we don’t have a Constants file yet.
// Constants.swift
enum Constants {
static let animals: [Animal] = Bundle.main.decode("cardData.json")
static let decks: [Deck] = Bundle.main.decode("decks.json")
}
With this, you now have a list of animals and decks you can access anywhere in the app. In a production app with waay more data, you wouldn’t do this, but we can get away with it for this little app.
Step 2: Front Card View
Now the data is all taken care of. Let’s build the cards.
// FrontCardView.swift
import SwiftUI
struct FrontCardView: View {
let animal: Animal
var body: some View {
ZStack {
animalImage
imageText
}.card()
}
}
struct FrontCardView_Previews: PreviewProvider {
static var previews: some View {
FrontCardView(animal: Constants.animals.first!)
}
}
extension FrontCardView {
private var animalImage: some View {
Image(animal.filename)
.resizable()
.scaledToFill()
.frame(maxWidth: Constants.cardWidth, maxHeight: Constants.cardHeight)
}
private var imageText: some View {
VStack {
Spacer()
photographerText
photographerName
}.padding()
}
private var photographerName: some View {
HStack {
Text(animal.photographer)
.foregroundColor(.white)
.fontWeight(.semibold)
.font(.callout)
Spacer()
}
}
private var photographerText: some View {
HStack {
Text("Photographer")
.foregroundColor(.white)
.fontWeight(.light)
.font(.footnote)
Spacer()
}
}
}
Notice the code()
modifier. Let’s write that next.
// CardModifier.swift
import SwiftUI
struct CardModifer: ViewModifier {
func body(content: Content) -> some View {
content
.frame(width: Constants.cardWidth, height: Constants.cardHeight)
.background(RoundedRectangle(cornerRadius: 15).fill(.white))
.clipShape(RoundedRectangle(cornerRadius: 15))
.shadow(color: .black.opacity(0.3), radius: 5, x: 0, y: 1)
}
}
extension View {
func card() -> some View {
modifier(CardModifer())
}
}
Add card height and width to the constants.
// Constants.swift
enum Constants {
...
static let cardHeight: CGFloat = 400
static let cardWidth: CGFloat = 300
}
Step 3: Back Card View
Lastly, let’s build the back of the cards.
// BackCardView.swift
import SwiftUI
struct BackCardView: View {
let animal: Animal
var body: some View {
VStack {
Text(animal.name)
.foregroundColor(.black)
.font(.largeTitle)
.fontWeight(.black)
}
.card()
}
}
struct BackCardView_Previews: PreviewProvider {
static var previews: some View {
BackCardView(animal: Constants.animals.first!)
}
}
Build the Menu
The ass and titties of our app are looking good. Let’s build the place were we can see them.
For the fun of it, let’s make a cool app logo. This logo will choose some random animals, make mini-cards for them, and animate them onto the screen.
Step 1: Mini Cards
Make a new view called MiniCard.
// MiniCard.swift
import SwiftUI
struct MiniCard: View {
let animal: Animal
var body: some View {
FrontCardView(animal: animal)
.minicard()
}
}
struct MiniCard_Previews: PreviewProvider {
static var previews: some View {
MiniCard(animal: Constants.animals.first!)
}
}
Add a new modifier to the CardModifier file for minicard()
.
// CardModifier.swift
struct MiniCardModifer: ViewModifier {
func body(content: Content) -> some View {
content
.frame(width: Constants.minicardWidth, height: Constants.minicardHeight)
.background(RoundedRectangle(cornerRadius: 15).fill(.white))
.clipShape(RoundedRectangle(cornerRadius: 15))
.shadow(color: .black.opacity(0.3), radius: 5, x: 0, y: 1)
}
}
extension View {
func minicard() -> some View {
modifier(MiniCardModifer())
}
}
And two new constants.
// Constants.swift
struct Constants {
...
static let minicardHeight: CGFloat = 200
static let minicardWidth: CGFloat = 150
}
Step 2: Fan the cards
Now let’s make a view that fans out 3 cards.
// CardFan.swift
import SwiftUI
struct CardFan: View {
var animals: [Animal]
var body: some View {
ZStack {
images
appTitle
}
}
}
struct CardFan_Previews: PreviewProvider {
static var previews: some View {
CardFan(animals: [Constants.animals[0], Constants.animals[1], Constants.animals[2]])
}
}
extension CardFan {
private var images: some View {
HStack {
MiniCard(animal: animals[0])
.offset(x: UIScreen.main.bounds.width * 0.2, y: 20)
.rotationEffect(Angle(degrees: -10.0), anchor: .bottom)
MiniCard(animal: animals[1])
MiniCard(animal: animals[2])
.offset(x: -UIScreen.main.bounds.width * 0.2, y: 20)
.rotationEffect(Angle(degrees: 10.0), anchor: .bottom)
}
.frame(width: UIScreen.main.bounds.width * 0.8)
}
private var appTitle: some View {
ZStack {
Text(Constants.appTitle)
.foregroundStyle(
.white.gradient.shadow(
.inner(color: .black.opacity(0.9), radius: 1)
)
)
.fontWeight(.heavy)
.font(.system(size: 50))
}
}
}
Step 3: Content View Model
Now let’s make the menu. We will make it a little flashy by adding a simple “splash” like animation. Start with making the View Model.
// ContentViewModel.swift
import SwiftUI
final class ContentViewModel: ObservableObject {
@Published var splashScreenState = SplashScreenState.on
init() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation(.easeInOut) {
self.splashScreenState = .off
}
}
}
}
enum SplashScreenState {
case on, off
}
So here we have a timer for 0.5 seconds that will switch the splash state to .off
.
Let’s see it in action.
// ContentView.swift
import SwiftUI
struct ContentView: View {
@StateObject var vm = ContentViewModel()
let miniAnimals: [Animal] = [
Constants.animals.randomElement()!,
Constants.animals.randomElement()!,
Constants.animals.randomElement()!
]
var body: some View {
NavigationStack {
VStack {
CardFan(animals: miniAnimals)
.scaleEffect(vm.splashScreenState == .on ? 0.5 : 1)
if vm.splashScreenState == .on {
splashScreen
} else {
mainScreen
}
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
extension ContentView {
private var splashScreen: some View {
VStack {
ProgressView()
}
}
private var mainScreen: some View {
VStack {
Spacer()
}
}
}
Step 4: Animation Cranimation
In the code above, you can see how easy animation is. How do we go about flipping the flash card around? I recommend you challenge yourself here and design a view that can flip around and show the FrontCardView and the BackCardView when clicked.
Had a go? Here is what I came up with.
// LiyAnimation.swift
import SwiftUI
final class LiyAnimation {
let animation: AnimationType
let duration: Double
let next: LiyAnimation?
let delay: Double
var completion: () -> Void
init(_ animation: AnimationType = .easeInOut, duration: Double, next: LiyAnimation? = nil, delay: Double = 0, completion: @escaping () -> Void) {
self.animation = animation
self.duration = duration
self.next = next
self.delay = delay
self.completion = completion
}
enum AnimationType {
case easeIn,
easeOut,
easeInOut,
spring
}
func play() {
switch animation {
case .easeInOut:
withAnimation(.easeInOut(duration: duration).delay(delay)) {
completion()
}
case .easeIn:
withAnimation(.easeIn(duration: duration).delay(delay)) {
completion()
}
case .easeOut:
withAnimation(.easeOut(duration: duration).delay(delay)) {
completion()
}
case .spring:
let interpolatedSpeed = 1 / duration
withAnimation(.interpolatingSpring(stiffness: 30, damping: 8).speed(interpolatedSpeed).delay(delay)) {
completion()
}
}
if let nextAni = next {
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
nextAni.play()
}
}
}
func playAfter(duration: Double) {
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
self.play()
}
}
}
Let’s break down this code. LiyAnimation is a class that createswithAnimation()
code blocks. It also takes another LiyAnimation called next
and a closure. The class only has 1 method called play()
. play()
will animate any code in the closure and then it will play another animation after the first is finished.
We can use LiyAnimation in our ContentViewModel like this:
// ContentViewModel.swift
init() {
let splashAnimation = LiyAnimation(.spring, duration: 1) {
self.splashScreenState = .off
}
splashAnimation.playAfter(duration: 1.5)
}
Let’s use it to flip around a card now. Make a new view called AnimalCard.swift
// AnimalCard.swift
import SwiftUI
struct AnimalCardView: View {
let animal: Animal
@State var flipped = false
@State var rotation: CGFloat = 0
var body: some View {
ZStack {
FrontCardView(animal: animal)
.rotation3DEffect(.degrees(rotation), axis: (x: 0, y: 1, z: 0))
.opacity(flipped ? 0 : 1)
BackCardView(animal: animal)
.rotation3DEffect(.degrees(rotation + 180), axis: (x: 0, y: 1, z: 0))
.opacity(flipped ? 1 : 0)
}
.onTapGesture {
flip()
}
.padding()
}
func flip() {
let secondTurn = LiyAnimation(duration: 0.2, next: nil) {
rotation += 90
print("finishing turn")
}
let flipViews = LiyAnimation(duration: 0.01, next: secondTurn) {
flipped.toggle()
}
let firstTurn = LiyAnimation(duration: 0.2, next: flipViews) {
rotation += 90
print("halfway turned")
}
firstTurn.play()
}
}
struct AnimalCardView_Previews: PreviewProvider {
static var previews: some View {
AnimalCardView(animal: Constants.animals[15])
}
}
This is just a test view. If it flips, we can move on to part 2 where we will build the rest of the app.
Part 2: Electric Boogaloo
Step 1: Deck View
Let’s create the deck view. This view will take a ‘deck’ of flashcards and let you cycle through them. Create the DeckView the the DeckViewModel.
// DeckView.swift
import SwiftUI
struct DeckView: View {
let deck: Deck
@StateObject var vm = DeckViewModel()
var body: some View {
ScrollView {
VStack {
ForEach(deck.animals) { animal in
Text(animal.name)
}
}
}
}
}
struct DeckView_Previews: PreviewProvider {
static var previews: some View {
DeckView(deck: Constants.decks.first!)
}
}
This gives us an error. We need to make animal conform to Identifiable.
// Animal.swift
extension Animal: Identifiable {}
Now we should be getting a list of all our animals in the deck.
Make the DeckViewModel as well.
// DeckViewModel.swift
import SwiftUI
final class DeckViewModel: ObservableObject {
}
Step 2: Pick a deck
Let’s set it up so that you can select a deck from the ContentView.
// ContentView.swift
...
private var mainScreen: some View {
VStack {
deckList
Spacer()
}
}
private var deckList: some View {
VStack {
ForEach(Constants.decks) { deck in
NavigationLink(destination: DeckView(deck: deck)) {
Text(deck.name)
.padding()
.frame(maxWidth: UIScreen.main.bounds.width * 0.8)
.background(.ultraThickMaterial)
.cornerRadius(15)
.shadow(color: .black.opacity(0.3), radius: 1, x: 0, y: 1)
}
}
}
}
Step 2: Pick a card
Pretty good work so far. Let’s replace the animal names with good looking cards. We need to be able to use the deck
inside the view model. Let’s write some methods in there.
// DeckViewModel.swift
import SwiftUI
final class DeckViewModel: ObservableObject {
private var deck: Deck? = nil
@Published var cards = [Animal]()
func setupDeck(_ deck: Deck) {
self.deck = deck
for card in deck.animals {
cards.append(card)
}
}
}
// DeckView.swift
struct DeckView: View {
let deck: Deck
@StateObject var vm = DeckViewModel()
var body: some View {
VStack {
ZStack {
cardPile
}
}
.navigationTitle(deck.name)
.navigationBarTitleDisplayMode(.large)
.onAppear {
vm.setupDeck(deck)
}
}
}
struct DeckView_Previews: PreviewProvider {
static var previews: some View {
DeckView(deck: Constants.decks.first!)
}
}
extension DeckView {
private var cardPile: some View {
ZStack {
ForEach(vm.cards.indices, id: \.self) { index in
animalCard(animal: vm.cards[index], isTopCard: index == vm.cards.count-1)
}
}
}
private func animalCard(animal: Animal, isTopCard: Bool) -> some View {
cardSides(animal: animal, isTopCard: isTopCard)
.transition(.slide)
.zIndex(isTopCard ? 999 : 0)
.allowsHitTesting(isTopCard ? true : false)
}
private func cardSides(animal: Animal, isTopCard: Bool) -> some View {
ZStack {
FrontCardView(animal: animal)
BackCardView(animal: animal)
}
}
}
Pretty useless. The ZStack is making all of the cards pile directly on top of each other. Also, the answer is already shown on the top. Let’s flip the cards around and add some styling to it.
// Animal.swift
...
let randomOffset = Double.random(in: -10.0...10.0)
// DeckViewModel.swift
...
@Published var flipped = false
@Published var rotation: CGFloat = 0
// DecKView.Swift
cardSides(animal: animal, isTopCard: isTopCard)
...
.offset(x: isTopCard ? 0 : animal.randomOffset, y: isTopCard ? 0 : animal.randomOffset)
.rotationEffect(.degrees(isTopCard ? 0 : animal.randomOffset))
// DeckView.swift
...
FrontCardView(animal: animal)
.rotation3DEffect(.degrees(isTopCard ? vm.rotation : 0), axis: (x: 0, y: 1, z: 0))
.opacity(isTopCard ? vm.flipped ? 0 : 1 : 1)
BackCardView(animal: animal)
.rotation3DEffect(.degrees(isTopCard ? vm.rotation + 180 : 0), axis: (x: 0, y: 1, z: 0))
.opacity(isTopCard ? vm.flipped ? 1 : 0 : 0)
Sweet. In the next part, we will write code that let’s us use that flip()
method.
Part 3: Making the Flash Cards work
At this point, we have done good work. We have a splash, menu, decks, and the cards of the decks are showing up. Let’s write the code that will let use play with them.
Since this is a game, the app will work like this:
Click a deck -> Show cards -> Show multiple choice answers -> Click answer -> Flip card to reveal correct answer -> Animate to the next card
Step 1: Next Please
Let’s make the cards cycle.
// DeckViewModel.swift
func nextCard() {
LiyAnimation(duration: Constants.nextCardAnimationLength) {
self.cards.remove(at: self.cards.count-1)
self.flipped = false
self.rotation = 0.0
}.playAfter(duration: 0.1)
}
// Constants.swift
...
static let nextCardAnimationLength = 1.0
// DeckView.swift
...
Button("Next Card") {
vm.nextCard()
}
.padding(.vertical, 20)
Step 2: Answer buttons
Instead of a next button, we should show the potential answers for each card. These are included in the json.
We will make buttons for each of these answers, and also hide the correct answer, the name of the animal, amongst them.
// DeckViewModel.swift
...
@Published var answers = [String]()
var topCard: Animal {
cards.isEmpty ? Constants.animals.first! : cards[cards.count-1]
}
...
func setupAnswers() {
self.answers = []
var newAnswers = topCard.answers
newAnswers.append(topCard.name)
for answer in newAnswers.shuffled() {
self.answers.append(answer)
}
}
...
func nextCard() {
...
self.setupAnswers()
}
And use that in the view.
private var answerButtons: some View {
VStack {
HStack {
answerButton(animalName: vm.answers[0])
answerButton(animalName: vm.answers[1])
}
HStack {
answerButton(animalName: vm.answers[2])
answerButton(animalName: vm.answers[3])
}
}
.padding()
}
private func answerButton(animalName: String) -> some View {
Button {
//
} label: {
Text(animalName)
.padding()
.background(.ultraThickMaterial)
.cornerRadius(7)
}
}
// Add the answerButtons to the VStack
...
VStack {
ZStack {
cardPile
}
answerButtons
}
Our app crashes now. We have a strange race going on. The buttons are trying to load answers from the topCard before cards even exist. To solve this, we will use states.
Controlling our app with states will allow us to do some powerful things. Let’s take a look.
// DeckViewModel.swift
var deckState: DeckState = .loading
...
enum DeckState {
case loading, playing, submitting, finished
}
func setupDeck(_ deck: Deck) {
self.deck = deck
for card in deck.animals {
cards.append(card)
}
self.deckState = .loading
setupAnswers()
self.deckState = .playing
}
Then modify the answerButtons
with an if statement. We need to attribute this with @ViewBuilder
.
// DeckView.swift
@ViewBuilder
private var answerButtons: some View {
if vm.deckState == .playing || vm.deckState == .submitting {
VStack {
HStack {
answerButton(animalName: vm.answers[0])
answerButton(animalName: vm.answers[1])
}
HStack {
answerButton(animalName: vm.answers[2])
answerButton(animalName: vm.answers[3])
}
}
.padding()
}
}
private func answerButton(animalName: String) -> some View {
Button {
// do something later
} label: {
Text(animalName)
.padding()
.background(.ultraThickMaterial)
.cornerRadius(7)
}
.disabled(vm.deckState != .playing)
}
Our app no longer crashes because Swift UI isn’t creating those buttons until our state is .playing
and we set the state to playing once the answers are already generated. Very useful!
Let’s add a function called submitAnswer(_ animalName: String)
that will check if the player guessed the correct answer and then swipe to the next card.
// DeckViewModel.swift
func submitAnswer(_ animalName: String) {
deckState = .submitting
nextCard()
}
Then make it so nextCard()
changes the state to playing.
// DeckViewModel.swift
func nextCard() {
LiyAnimation(duration: Constants.nextCardAnimationLength) {
self.deckState = .playing
self.cards.remove(at: self.cards.count-1)
self.flipped = false
self.rotation = 0.0
self.setupAnswers()
}.playAfter(duration: 0.1)
}
Step 3: Flipping the script
Pretty cool but what does that state really do? Since the state changes instantly, .loading
.playing
and .submitting
all seem to do the same thing. Now we will add animations, and during those animations (1 second or so) we want certain things to happen. For example, while the card is flipping around, we don’t want the player to be able to accidentally push an answer button.
In order to do that, while the animations are playing we will change the state to .submitting
. Inside nextCard()
the animation callback will set the state back to .playing
. Since the buttons are disabled unless the state is .submitting
they will be grayed out for the duration of the animation.
Go ahead and add the flip()
method to submitAnswer()
.
// Constants.swift
...
static let cardFlipAnimationLength: Double = 0.4
static var halfFlipAnimationLength: Double { Constants.cardFlipAnimationLength / 2 }
// DeckViewModel.swift
func submitAnswer(_ animalName: String) {
deckState = .submitting
flip()
nextCard()
}
func flip() {
let secondTurn = LiyAnimation(.spring, duration: Constants.halfFlipAnimationLength, next: nil) {
self.rotation += 90
}
let flipViews = LiyAnimation(.spring, duration: 0.01, next: secondTurn) {
self.flipped.toggle()
}
let firstTurn = LiyAnimation(.spring, duration: Constants.halfFlipAnimationLength, next: flipViews) {
self.rotation += 90
}
firstTurn.play()
}
Why is this broken? For anyone looking for a good challenge try to solve this bug before moving on. Read the code. Breath the code. Become the code. You will find the answer.Hint: All this bug takes is a little time to solve.
There we go. The flashcard game is starting to look great. If you look closely, you will see that the buttons are grayed out during the flip animation. Controlling your app with states isn’t only powerful, it is absolutely imperative.
— — — — — — — — — — — — — — — — — — — — —
Had a go at the bug? Here was the problem. We now need to add a delay before we flip to the next card.
func nextCard() {
LiyAnimation(duration: Constants.nextCardAnimationLength) {
self.deckState = .playing
self.cards.remove(at: self.cards.count-1)
self.flipped = false
self.rotation = 0.0
self.setupAnswers()
}.playAfter(duration: Constants.nextCardAnimationLength) // here
}
Part 4:
So we’ve gotten the app to load a deck of cards and cycle through them. Here is were we will add the last of the features to make it into a real game.
Step 1: Right or Wrong?
Let’s create another animation that surfaces whether the player got the answer right.
// Constants.swift
static let flashAnimationLength: Double = 0.33
// DeckViewModel.swift
...
@Published var passing = false
@Published var failing = false
...
func submitAnswer(_ animalName: String) {
deckState = .submitting
let answerWasCorrect = animalName == topCard.name
flip()
flash(passing: answerWasCorrect)
nextCard()
}
func flash(passing: Bool) {
let flashOff = LiyAnimation(duration: Constants.flashAnimationLength, delay: Constants.flashAnimationLength) {
if passing { self.passing = false } else { self.failing = false }
}
let flashOn = LiyAnimation(duration: Constants.flashAnimationLength, next: flashOff, delay: Constants.flashAnimationLength) {
if passing { self.passing = true } else { self.failing = true }
}
flashOn.play()
}
// DeckView.swift
VStack {
ZStack {
cardPile
flashMarks
}
answerButtons
}
...
@ViewBuilder
private var flashMarks: some View {
if vm.deckState == .submitting {
ZStack {
Image(systemName: "checkmark.circle")
.foregroundColor(.green)
.fontWeight(.thin)
.font(.system(size: 200))
.opacity(vm.passing ? 1 : 0)
Image(systemName: "x.circle")
.foregroundColor(.red)
.fontWeight(.thin)
.font(.system(size: 200))
.opacity(vm.failing ? 1 : 0)
}
}
}
Step 2: End Screen
Now let’s build the last big feature. The Game Over screen. Here we will see how we did in the game. I recommend for you to stop and try to build your own game over screen.
Here is what I came up with:
Write a new struct called DeckStatistic.
// DeckStatistic.swift
struct DeckStatistic {
let id = UUID()
let animal: Animal
let wasCorrect: Bool
}
extension DeckStatistic: Identifiable {}
Now let’s use that in our DeckViewModel.
// DeckViewModel.swift
...
var stats = [DeckStatistic]()
...
func submitAnswer(_ animalName: String) {
deckState = .submitting
let answerWasCorrect = animalName == topCard.name
let statistic = DeckStatistic(animal: topCard, wasCorrect: answerWasCorrect)
stats.append(statistic)
flip()
flash(passing: answerWasCorrect)
nextCard()
}
...
func checkForEndGame() {
if cards.isEmpty {
deckState = .finished
}
}
...
func nextCard() {
LiyAnimation(duration: Constants.nextCardAnimationLength) {
self.deckState = .playing
self.cards.remove(at: self.cards.count-1)
self.flipped = false
self.rotation = 0.0
self.setupAnswers()
self.checkForEndGame()
}.playAfter(duration: Constants.nextCardAnimationLength)
}
And finally, add one more computed property that will surface these statistics once the game is finished.
// DeckView.swift
...
VStack {
ZStack {
cardPile
flashMarks
}
endScreen
answerButtons
}
...
@ViewBuilder
private var endScreen: some View {
if vm.deckState == .finished {
ScrollView {
VStack {
ForEach(vm.stats) { statistic in
HStack {
Text(statistic.animal.name)
.font(.title)
Image(systemName: statistic.wasCorrect ? "checkmark.circle.fill" : "x.circle.fill")
.foregroundColor(statistic.wasCorrect ? .green : .red)
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
Step 3: Last bit of flair
I want to add the last bit of sexiness. Let’s have the cards animate onto the screen when the game starts.
func setupDeck(_ deck: Deck) {
self.deckState = .loading
self.deck = deck
var delay = 0.0
for card in deck.animals {
LiyAnimation(.spring, duration: Constants.setupDuration) {
self.cards.append(card)
}.playAfter(duration: delay)
delay += 0.2
}
LiyAnimation(duration: Constants.setupDuration) {
self.setupAnswers()
self.deckState = .playing
}.playAfter(duration: delay)
}
Conclusion
There we go! We made a pretty good looking flash card app with only a few views and models. From here we could build a sophisticated app. Some things could be improved. The app has a big optimization problem as well. You can see it start to chug when loading more than 10 or so views.
Check out the completed app here:
Clap if you liked this app so I know and I’ll make a part 2 with more features. In the next tutorial we could fix the memory leak, add a stats screen, and maybe build a simple API for leader boards!
P.S.
I’m broke, jobless and starting from zero trying to get a job as a iOS developer. Subscribe to my blog if you wanna root on, or if you’re in the same boat and wanna pursue this goal together.
Thanks for reading. Hope you’re having good weather over there. 🇺🇸🐶🇯🇵 ではまた