Home > Back-end >  Animation and array subscripting SwiftUI
Animation and array subscripting SwiftUI

Time:08-04

I'm trying to do a very simple drag and drop list that is animated (Not a classic List(), custom). That means the user can drag around the items in the list. However, I seem to have a problem with animations when accessing them through two arrays and a calculated index (see DropDelegate structs at the end of question and link to video: https://imgur.com/a/ZlGZIyW). To explain what I mean, here is the Model and ViewModel of an example:

struct Shelf: Identifiable {
    var books: [Book]
    var id: Int
}

struct Book: Identifiable {
    var name: String
    var id: Int
}

class ViewModel: ObservableObject {
    @Published var shelves: [Shelf]
    @Published var currentShelf: Shelf?
    @Published var currentBook: Book?
    
    init() {
        let bookArray = [Book(name: "Romance", id: 1), Book(name: "Western", id: 2), Book(name: "Hemingway", id: 3), Book(name: "Cars", id: 4)]
        shelves = [Shelf(books: bookArray, id: 1), Shelf(books: bookArray, id: 2), Shelf(books: bookArray, id: 3), Shelf(books: bookArray, id: 4)]
    }
}

There are two views, one renders a list from an array of Shelf which is a variable of the ViewModel, the other a list from an array of Book which is a variable of each Shelf struct, as can be seen from the model. The two Views which use the animations:

struct ContentView: View {
    @EnvironmentObject var viewModel: ViewModel
    var body: some View {
        NavigationView {
            ScrollView {
                VStack {
                    ForEach($viewModel.shelves) { $shelf in
                        NavigationLink(destination: ShelfDetailView(shelf: $shelf)) {
                            Text("Shelf \(shelf.id)")
                                .font(.headline)
                                .background(Color.mint)
                                .cornerRadius(12)
                                .padding()
                                .onDrag({
                                    viewModel.currentShelf = shelf
                                    return NSItemProvider(contentsOf: URL(string: "\(shelf.id)")!)! // <--- ??
                                })
                                .onDrop(of: [.url], delegate: ShelfDropViewDelegate(shelf: shelf, viewModel: viewModel))
                        }
                        
                    }
                }
            }
        }
    }
}

struct ShelfDetailView: View {
    @Binding var shelf: Shelf
    @EnvironmentObject var viewModel: ViewModel
    var body: some View {
        ScrollView {
            VStack {
                ForEach(shelf.books) { book in
                    Text(book.name)
                        .font(.headline)
                        .background(Color.indigo)
                        .cornerRadius(12)
                        .padding()
                        .onDrag({
                            viewModel.currentBook = book
                            return NSItemProvider(contentsOf: URL(string: "\(book.id)")!)!
                        })
                        .onDrop(of: [.url], delegate: BookDropViewDelegate(book: book, viewModel: viewModel, shelfId: shelf.id))
                }
            }
        }
    }
}

Now, each view has its separate DropDelegate. In both delegates, only the dropEntered function differs, ShelfDropViewDelegate accesses only one array whilst BookDropViewDelegate accesses two and as such uses two indexes. Yet this causes that the former has smooth animations, whilst the latter has no animation and only reflects changes. Can someone explain how this works exactly? Much thanks. The code I posted is all that is required for a MRE

struct ShelfDropViewDelegate: DropDelegate {
    var shelf: Shelf
    var viewModel: ViewModel
    
    func performDrop(info: DropInfo) -> Bool {
        return true
    }

    func dropEntered(info: DropInfo) {

        let fromIndex = viewModel.shelves.firstIndex { (shelf) -> Bool in
            return shelf.id == viewModel.currentShelf?.id
        } ?? 0

        let toIndex = viewModel.shelves.firstIndex { (shelf) -> Bool in
            return shelf.id == self.shelf.id
        } ?? 0

        if fromIndex != toIndex {
            withAnimation (.default) {
                let fromShelf = viewModel.shelves[fromIndex]
                viewModel.shelves[fromIndex] = viewModel.shelves[toIndex]
                viewModel.shelves[toIndex] = fromShelf
            }

        }
    }

    func dropUpdated(info: DropInfo) -> DropProposal? {
        return DropProposal(operation: .move)
    }
}


struct BookDropViewDelegate: DropDelegate {
    var book: Book
    var viewModel: ViewModel
    var shelfId: Shelf.ID
    var shelfIndex: Array<Shelf>.Index
    
    init(book: Book, viewModel: ViewModel, shelfId: Shelf.ID) {
        self.book = book
        self.viewModel = viewModel
        self.shelfId = shelfId
        shelfIndex = viewModel.shelves.firstIndex { (shelf) -> Bool in
            return shelfId == shelf.id
        } ?? 0
    }
    
    func performDrop(info: DropInfo) -> Bool {
        return true
    }

    func dropEntered(info: DropInfo) {

        let fromIndex = viewModel.shelves[shelfIndex].books.firstIndex { (book) -> Bool in
            return book.id == viewModel.currentBook?.id
        } ?? 0

        let toIndex = viewModel.shelves[shelfIndex].books.firstIndex { (book) -> Bool in
            return book.id == self.book.id
        } ?? 0

        if fromIndex != toIndex {
            withAnimation (.default) {
                let fromShelf = viewModel.shelves[shelfIndex].books[fromIndex]
                viewModel.shelves[shelfIndex].books[fromIndex] = viewModel.shelves[shelfIndex].books[toIndex]
                viewModel.shelves[shelfIndex].books[toIndex] = fromShelf
            }

        }
    }

    func dropUpdated(info: DropInfo) -> DropProposal? {
        return DropProposal(operation: .move)
    }
}

CodePudding user response:

Tested with Xcode 13.4 / iOS 15.5

Actually the issue is because list in ShelfDetailView.body does not depend on viewModel, but the fix is much simpler - instead of animating via viewModel implicitly, add explicit animation per shelf.books value, like

struct ShelfDetailView: View {
    // ...

    var body: some View {
        ScrollView {
            VStack {
                ForEach(shelf.books) { book in
                    // ...
                }
            }
            .animation(.default, value: shelf.books)   // << here !!
        }
    }
}

make Bool equatable so that compiled

struct Book: Identifiable, Equatable {   // << here !!
    var name: String
    var id: Int
}

and just remove withAnimation from BookDropViewDelegate.

*Note: actually the same can be done shelves for code consistency.

  • Related