SALE: Save 50% on all my books and bundles >>

Adding and deleting cards

Paul Hudson    @twostraws   

Everything we’ve worked on so far has used a fixed set of sample cards, but of course this app only becomes useful if users can actually customize the list of cards they see. This means adding a new view that lists all existing cards and lets the user add a new one, which is all stuff you’ve seen before. However, there’s an interesting catch this time that will require something new to fix, so it’s worth working through this.

First we need some state that controls whether our editing screen is visible. So, add this to ContentView:

@State private var showingEditScreen = false

Next we need to add a button to flip that Boolean when tapped, so find the if differentiateWithoutColor || accessibilityEnabled condition and put this before it:

VStack {
    HStack {
        Spacer()

        Button(action: {
            self.showingEditScreen = true
        }) {
            Image(systemName: "plus.circle")
                .padding()
                .background(Color.black.opacity(0.7))
                .clipShape(Circle())
        }
    }

    Spacer()
}
.foregroundColor(.white)
.font(.largeTitle)
.padding()

We’re going to design a new EditCards view to encode and decode a Card array to UserDefaults, but before we do that I’d like you to make the Card struct conform to Codable like this:

struct Card: Codable {

Now create a new SwiftUI view called “EditCards”. This needs to:

  1. Have its own Card array.
  2. Be wrapped in a NavigationView so we can add a Done button to dismiss the view.
  3. Have a list showing all existing cards.
  4. Add swipe to delete for those cards.
  5. Have a section at the top of the list so users can add a new card.
  6. Have methods to load and save data from UserDefaults.

We’ve looked at literally all that code previously, so I’m not going to explain it again here. I hope you can stop to appreciate how far this means you have come!

Replace the template EditCards struct with this:

struct EditCards: View {
    @Environment(\.presentationMode) var presentationMode
    @State private var cards = [Card]()
    @State private var newPrompt = ""
    @State private var newAnswer = ""

    var body: some View {
        NavigationView {
            List {
                Section(header: Text("Add new card")) {
                    TextField("Prompt", text: $newPrompt)
                    TextField("Answer", text: $newAnswer)
                    Button("Add card", action: addCard)
                }

                Section {
                    ForEach(0..<cards.count, id: \.self) { index in
                        VStack(alignment: .leading) {
                            Text(self.cards[index].prompt)
                                .font(.headline)
                            Text(self.cards[index].answer)
                                .foregroundColor(.secondary)
                        }
                    }
                    .onDelete(perform: removeCards)
                }
            }
            .navigationBarTitle("Edit Cards")
            .navigationBarItems(trailing: Button("Done", action: dismiss))
            .listStyle(GroupedListStyle())
            .onAppear(perform: loadData)
        }
    }

    func dismiss() {
        presentationMode.wrappedValue.dismiss()
    }

    func loadData() {
        if let data = UserDefaults.standard.data(forKey: "Cards") {
            if let decoded = try? JSONDecoder().decode([Card].self, from: data) {
                self.cards = decoded
            }
        }
    }

    func saveData() {
        if let data = try? JSONEncoder().encode(cards) {
            UserDefaults.standard.set(data, forKey: "Cards")
        }
    }

    func addCard() {
        let trimmedPrompt = newPrompt.trimmingCharacters(in: .whitespaces)
        let trimmedAnswer = newAnswer.trimmingCharacters(in: .whitespaces)
        guard trimmedPrompt.isEmpty == false && trimmedAnswer.isEmpty == false else { return }

        let card = Card(prompt: trimmedPrompt, answer: trimmedAnswer)
        cards.insert(card, at: 0)
        saveData()
    }

    func removeCards(at offsets: IndexSet) {
        cards.remove(atOffsets: offsets)
        saveData()
    }
}

That’s almost all of EditCards complete, but before we can use it we need to add some more code to ContentView so that it shows the sheet on demand and calls resetCards() when dismissed.

Add this sheet() modifier to the end of the outermost ZStack in ContentView:

.sheet(isPresented: $showingEditScreen, onDismiss: resetCards) {
    EditCards()
}

As well as calling resetCards() when the sheet is dismissed, we also want to call it when the view first appears, so add this modifier below the previous one:

.onAppear(perform: resetCards)

So, when the view is first shown resetCards() is called, and when it’s shown after EditCards has been dismissed resetCards() is also called. This means we can ditch our example cards data and instead make it an empty array that gets filled at runtime.

So, change the cards property of ContentView to this:

@State private var cards = [Card]()

To finish up with ContentView we need to make it load that cards property on demand. This starts with the same code we just added in EditCard, so put this method into ContentView now:

func loadData() {
    if let data = UserDefaults.standard.data(forKey: "Cards") {
        if let decoded = try? JSONDecoder().decode([Card].self, from: data) {
            self.cards = decoded
        }
    }
}

And now we can add a call to loadData() in resetCards(), so that we refill the cards property with all saved cards when the app launches or when the user edits their cards:

func resetCards() {
    timeRemaining = 100
    isActive = true
    loadData()        
}

Now go ahead and run the app. We’ve wiped out our default examples, so you’ll need to press the + icon to add some of your own.

Even though all of our code is correct, the result is unlikely to be what you expected: when you press + you see a new screen slide in, and it’s totally blank. We designed a nice list with two sections, a navigation view, a navigation bar item, and more, but all we’re getting is a blank screen.

What’s happening here has in fact been happening since our very first project, but it’s possible you haven’t noticed: when you rotate a NavigationView to landscape, you get a blank view. This isn’t a bug, and in fact it’s SwiftUI trying to be helpful.

You see, when running in landscape mode on some iPhones, iOS allows two views to sit side by side, where the left-hand view determines what the right-hand view is showing. We can customize how the two views work when moving between portrait and landscape, but SwiftUI’s default is to show only the right-hand view – the detail view – and in our case we don’t actually have one, which is why we see a blank screen.

To fix this we need to tell the NavigationView that it should only ever show one view at a time, which means it won’t try to show a non-existent detail view. Add this modifier to the NavigationView in EditCard:

.navigationViewStyle(StackNavigationViewStyle())

Now when you run the app everything should work as intended. Honestly this should really be the default setting, because the current default is thoroughly confusing. Regardless, that’s our app complete – good job!

Hacking with Swift is sponsored by RevenueCat

SPONSORED Building in-app subscriptions are hard – especially cross-platform. RevenueCat makes it simple. With their native SDKs, you can implement a custom subscription model for your app in hours, not months.

Explore the docs to learn more

Sponsor Hacking with Swift and reach the world's largest Swift community!

Cascable unleashes the power of your camera and unlocks powerful workflows for shooting, managing, and geotagging your photos.

BUY OUR BOOKS
Buy Pro Swift Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift (Vapor Edition) Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Server-Side Swift (Kitura Edition) Buy Beyond Code

Was this page useful? Let us know!

Average rating: 3.0/5