Your Second iOS & SwiftUI App

Nov 4 2021 · Swift 5.5, iOS 15, Xcode 13

Part 3: Managing Rows

27. Delete & Move Rows

Episode complete

Play next episode

Next
About this episode

Leave a rating/review

See forum comments
Cinema mode Mark complete Download course materials
Previous episode: 26. Swipe Actions Next episode: 28. Conclusion

Get immediate access to this and 4,000+ other videos and books.

Take your career further with a Kodeco Personal Plan. With unlimited access to over 40+ books and 4,000+ professional videos in a single subscription, it's simply the best investment you can make in your development career.

Learn more Already a subscriber? Sign in.

Transcript: 27. Delete & Move Rows

To recap, we’re going to let users delete books from their library in two ways: first, the swipe action we already set up, and second, via an edit button.

We can make one library method to handle both cases, but seeing where the second case needs to be implemented will help us write that method. So! In Section View…

…we’ll use a modifier on our ForEach view filled with book rows: onDelete. Put it right before the label style is set.

        🟩.onDelete(perform: { indexSet in
          /*@START_MENU_TOKEN@*//*@PLACEHOLDER=Code@*/ /*@END_MENU_TOKEN@*/
        })
        .labelStyle...
      }

You don’t need this “perform” here; you can use trailing closure syntax as usual in SwiftUI.

          BookRow(book: $0)
        }
        .onDelete { indexSet in

        }
      }

So, what is this indexSet that onDelete gives us? In theory, onDelete could be providing you multiple integers, to represent what rows to delete.

In practice, for the time being, SwiftUI doesn’t support multitouch for deletion. So you’ll probably always be working with a single index. With that information in our brains, head back to Library.swift

And let’s write the delete method to deal with a deletion indexSet, regardless of how many rows it represents.

First thing, change this from deleteBook to deleteBooks

func deleteBook🟩s🟥() {

Then add an IndexSet parameter. We’ll call it “offsets”, for reasons which will become apparent in a moment…

func deleteBooks(🟩atOffsets offsets: IndexSet) {

We’ll also always have a section to work with.

func deleteBooks(atOffsets offsets: IndexSet🟩, section: Section) {

Our ForEach in SectionView iterates through the Book array value of a Section key in sortedBooks. So, the indexes we get, here, will match the indexes from sortedBooks

      sortedBooks[section]
      // TODO: Remove Image

To delete the matching books, start typing “remove at”, to get autocomplete for the method we need: “remove at offsets”.

sortedBooks[section]?.remove(atOffsets: <#T##IndexSet#>)

Now, pass it the parameter from deleteBooks.

remove(atOffsets: offsets)

At this point, we’ll have removed the right books from sortedBooks, but they’ll be hanging around in booksCache.

We can fix that by forwarding that deletion along to booksCache via sortedBooks set accessor, which, as you can see from this error, we need to write anyway! To start on that, we’ll also need to specify this part we already have as the get

  var sortedBooks: [Section: [Book]] 🟩{
    get {
      ...
    }
    set {

    }
  }🟥

This is going to be nearly what we did in the sortBooks method! So copy and paste this bit

set {
🟩booksCache =
      sortedBooks
      .sorted { $1.key == .finished }
      .flatMap { $0.value }

But here, we’ll assign to sortedBooks’ new value.

    set {
      booksCache = newValue
    }

That will take care of deleting the books themselves, but we should also delete their images. To do that, start by copying the books cache before deletion occurs.

  func deleteBooks(atOffsets offsets: IndexSet, section: Section?) {
    🟩let booksBeforeDeletion = booksCache

After deletion, we’ll use the “difference” method, to figure out the changes between the new state of the cache, and the old one.

      sortedBooks.remove(atOffsets: offsets)
    }

    🟩booksCache.difference(from: booksBeforeDeletion)
  }

This returns a collection of changes. And we can loop through them.

    for change in booksCache.difference(from: booksBeforeDeletion) {

    }
  }

Now let’s switch on the change, to see what it might be.

    for change in booksCache.difference(from: booksBeforeDeletion) {
      switch change {
      
      }
    }

Let Xcode do the work for you here.

    for change in booksCache.difference(from: booksBeforeDeletion) {
      switch change {
      case .insert(offset: let offset, element: let element, associatedWith: let associatedWith):
        <#code#>
      case .remove(offset: let offset, element: let element, associatedWith: let associatedWith):
        <#code#>
      }
    }

“Remove” is really the only case that we should be encountering here. So delete the “insert” option.

      switch change {
      case .remove(offset: let offset, element: let element, associatedWith: let associatedWith):
        <#code#>
      }

And we can change to “if case” syntax instead of a switch, considering we’re only doing something for this one case.

      if case .remove(offset: let offset, element: let element, associatedWith: let associatedWith)💰 = change {
        <#code#>
      }

It’s only “element” that we’ll actually need, so clean up the rest of that, with underscores.

if case .remove(_, let element, _) = change {

And let’s rename “element” to what it actually is. “deleted book”.

(_, let deletedBook, _)

Set that book’s image to nil, and you’ll be done with the delete books method!

      if case .remove(_, let deletedBook, _) = change {
        uiImages[deletedBook] = nil
      }

Now go back to SectionView…

…and jump to the Delete swipe action.

We don’t have automatic access to an indexSet for deleted items from here, so we’ll need to look up the index of the current book.

We can do that by finding the first index in this sections’ books that has an ID that matches the book for this row, and if something has gone horribly wrong, just return.

Now we can call deleteBooks. Turn the integer we have into an index set with this initializer and just pass along the section. Maybe wrap it in withAnimation as well.

            .swipeActions(edge: .trailing) {
              Button(role: .destructive) {
                guard let index = books.firstIndex(where: {$0.id == book.id})
                else { return }
                
                withAnimation {
                  library.deleteBooks(atOffsets: .init(integer: index), section: section)
                }

You can do the same in onDelete, but that already gives you the index set you need, so it’s more straightforward.

        .onDelete { indexSet in
            withAnimation {
                  library.deleteBooks(atOffsets: .init(integer: index), section: section)
                }
        }

The swipe action would work, right now, but we don’t yet have an edit button.

You only need one more line of code to get that working, but it’s a pretty powerful line! Up in ContentView, add a toolbar to your List…

        ForEach(Section.allCases, id: \.self) {
          SectionView(section: $0)
        }
      }
      🟩.toolbar(content: <#T##() -> ToolbarContent#>)
      .navigationTitle("My Library")

…passing in the initializer for EditButton.

.toolbar(content: 🟩EditButton.init)

Now when you live preview…If you swipe from right to left, you’ll now get a delete button, on any row.

You can tap that button… Or, you can just swipe farther, to avoid having to tap. And for the alternative, tap the edit button and enter edit mode!

That brings in a button for each row. Tap that button, and then you can hit the button on the other side, to delete your books.

What you can’t do, in Edit mode, is swipe left, to do the same.

But, you can hit Done… And get right back to it!

The other thing Edit Mode is great for, is moving rows. Going off of “onDelete”, you might guess that the modifier for that is “onMove”!

        .onDelete { indexSet in
          ...
        }
        .onMove { indices, newOffset in

        }
      }

A difference is that you won’t just have an IndexSet to work with. You’ll also have an additional Int. indices, here, refers to the original indices of the rows you’re moving.

Again, as with deletion, multitouch isn’t supported (yet). So it’s probably only going to be a set with one number.

What’s definitely one number is the newOffset. That’s where the row (or potentially, rows) are going to end up. * To work with these parameters, let’s add another library method below deleteBooks.

This one will be “move books”.

  }

  func moveBooks(
  
  ) {

  }

  /// Load, save, or delete an image corresponding to a book's title and author.

It’ll take an “old offsets” IndexSet…

  func moveBooks(
    oldOffsets: IndexSet
  ) {

…and a “new offset” Int.

  func moveBooks(
    oldOffsets: IndexSet, newOffset: Int
  ) {

And we’ll always have a section for this, as well.

  func moveBooks(
    oldOffsets: IndexSet, newOffset: Int,
    section: Section
  ) {

Again, you’ll be working with the sorted books for that section…

    section: Section
  ) {
    sortedBooks[section]
  }

…but you won’t be calling “re-move”. This time, it’s just “move”.

  ) {
    sortedBooks[section]?.move(fromOffsets: oldOffsets, toOffset: newOffset)
  }

And that’s that. Now back in SectionView…

The move method will do all the hard work for you. You just need to call your library method with the appropriate three arguments.

        .onMove { indices, newOffset in
          library.moveBooks(
            oldOffsets: indices, newOffset: newOffset,
            section: section
          )
        }

If you go into Edit Mode now, you’ll have icons on the right, to indicate that you can grab the rows, and reorder them!

You can move a cell within that section, but if you try to move it into another one… It gracefully doesn’t let you.