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.