Как удалить ячейку из секции в CollectionView iOS swift?

collectionview
swift
ios
coredata

#1

Есть вот такой проект (прошу запускать на симуляторе iPhone SE (файл MainCollectionViewController.swift)). В нём после долго нажатия на ячейку (где показана еда) должно происходить удаление ячейки из коллекции. Приведенный код будет работать только в том случае, если количество секций равно 1 (т.е. у нас только 5 ячеек всего). Если количество секций больше 1 (т.е. предметов (ячеек) от 6 и больше), то будет выскакивать ошибка, и ячейка исчезнет только после перезапуска приложения. Ошибка выглядит вот так (попробуйте удалить Салат):

fault: Serious application error. An exception was caught from the delegate of NSFetchedResultsController during a call to -controllerDidChangeContent:. Invalid update: invalid number of items in section 0. The number of items contained in an existing section after the update (1) must be equal to the number of items contained in that section before the update (5), plus or minus the number of items inserted or deleted from that section (0 inserted, 0 deleted) and plus or minus the number of items moved into or out of that section (0 moved in, 0 moved out). with userInfo (null)

У меня есть предположение почему так происходит. Приложение пытается удалить ячейку с индексом [0,6] (Салат), тогда как его настоящий индекс равен [1,1], в чём можно легко убедиться, посмотрев на принты в строках кода 128 и 129 (они выводят 1 и 1). Даже написав такой код (остальные case’ы были опущены для удобства):

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
    let op: BlockOperation!

    switch type {
    case .delete:
        guard let indexPath = indexPath else { return }
        //выдает корректное положение предмета. Например [1,1]
        print("self.indx! \(self.indx!)")
        //выдает НЕкорректное положение предмета, если количество секций больше 1 штуки! Например выдает [0,6], тогда как должен выдавать [1,1]
        print("indexPath \(indexPath)")
        op = BlockOperation { (self.collectionView?.deleteItems(at: [self.indx!])) }


    blockOperations.append(op)
    items = controller.fetchedObjects as! [Item]
}

где self.indx - это корректное расположение ячейки (Салат - [1,1]), которое устанавливается в строке 61 (во время удаления из CoreData), не спасает ситуацию. Попробуйте удалить Салат и Редис (перезапуская приложение), а потом начните удалять предметы из первой секции. Вы увидите, что они удаляются в режиме реального времени (и с анимацией). Вопрос: как добиться такого и для предметов, расположенных в других секциях? Как исправить мою ошибку?


#2

Вам стоит еще раз проверить свою логику распределения ячеек по секциям, т.к. indexPath не может выводить некорректное положение. Он выводит лишь то значение, на которое вы запрограммировали его, следовательно, что-то не верно у вас в расчетах.


#3

Я изменил логику на такую

override func numberOfSections(in collectionView: UICollectionView) -> Int {
    print("call numberOfSections")
    
    if items.count % 5 == 0 {
        return items.count / 5
    }
    
    return items.count / 5 + 1
}

override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    //приведенный ниже алгоритм служит для выдачи корректного количества предметов в секции
    print("вызываем numberOfItemsInSection. Текущая секция равна \(section)")
    counter -= 5
    
    print("counter is \(counter)")
    
    if counter > 0 || counter == 0 {
        print("numberOfItemsInSection is (if) \(counter)")
        return 5
    //если меньше нуля, то в последней секции количество предметов будет равным остатку от деления общего количества предметов на 5 (результат всегда будет меньше 5, таким образом возможно отобразить количество предметов в одной секции равным 4,3,2,1
    } else {
        print("numberOfItemsInSection is (else) \(items.count % 5)")
        return items.count % 5
    }
}

counter обновляется после удаления айтема

    items = controller.fetchedObjects as! [Item]
    counter = items.count

К сожалению и так не помогает (


#4

а как тогда правильно запрограммировать индекс паз? (если вы будете запускать мой код на вашем маке (симулятор SE), то обновите методы, которые я привел в своем первом ответе)


#5

В данный момент пока нет такой возможности.
Можете на словах описать вашу логику и для чего вам это нужно?
Так же можете показать ваши данные?


#6

Суть такая: помните в старом дизайне эппл букс были деревянные полки? Так вот, я хочу сделать аналогичное. У нас есть, допустим, три полки. Когда юзер удаляет с первой полки предмет, то на место удаленного предмета встает тот предмет, который был справа от него. А так как в первой секции станет предметов на 1 меньше (а у нас их всегда должно быть 5 штук, если, конечно, предметов больше 5), то на первую полку должен перейти предмет из второй секции! Вот я это и пытаюсь сделать, чтобы предмет из второй секции попадал на место предмета из первой секции.Вот так все выглядит:


Удалите морковь. На его место должен встать Редис. Но вместо этого выскакивает ошибка (которую я привел в своем посте).
Логика заполнения следующая: есть некий счетчик (counter), который заполняется количеством предметов (всего). В каждой итерации цикла намберсОфАйтемсИнСекшон он уменьшается на 5. Это делается для того, чтобы узнать сколько предметов еще не показано. Если после уменьшения наш каунтер окажется меньше нуля, то это значит, что количество предметов у нас такое, которое при делении на 5 дает остаток. И используя деление % (которое показывает остаток), мы можем заполнить последнюю секцию не 5 предметами, а, например, 4,3,2,1 предметами.


#7

А как вам такая идея: перед обновлением CollectionView, разбивать ваш массив данных на части, так называемые chunks. Это подойдет если в каждой секции у вас должно быть одинаковое кол-во элементов, а в последней секции оставшиеся, если их меньше. Таким образом у вас будет общий массив, который будет содержать группы мелких массивов, таким образом получатся секции со своими элементами. При удалении элемента, будете использовать изначальный массив с данными и после удаления снова разбивать его на части и обновлять CollectionView. Таким образом у вас будет меньше кода, меньше гемороя с логикой, проще в реализации.

Приемер

extension Array {
    func chunked(into size: Int) -> [[Element]] {
        return stride(from: 0, to: count, by: size).map {
            Array(self[$0 ..< Swift.min($0 + size, count)])
        }
    }
}

// разбиваем массив на части по 5 элементов
let numbers = Array(1...15)
let result = numbers.chunked(into: 5)

Получится такой результат

[
    [1, 2, 3, 4, 5], // 1я секция
    [6, 7, 8, 9, 10], // 2я секция
    [11, 12, 13, 14, 15], // 3я секция
]

Удаление элемента со значением 5

numbers.remove(at: 4)
let result = numbers.chunked(into: 5)
// новый результат
[
        [1, 2, 3, 4, 6], // 1я секция
        [7, 8, 9, 10, 11], // 2я секция
        [12, 13, 14, 15], // 3я секция
]

#8

Спасибо за столь развернутый ответ. Думаете, что у меня ошибка всё же в методе numberOfItemsInSection? Просто, как мне кажется, моя логика тоже выглядит нормально…


#9

где именно ошибка я не знаю, но она точно в вашем коде. Ваша ошибка говорит о том что у вас после обновления CollectionView не сходится оригинальный массив с тем, что нужно отобразить.


#10

Понятно. То есть программа считает, что после удаления из первой секции предмета, количество предметов в первой секции меньше пяти?


#11

Т.е. у вас указано одно кол-во, а передается другое, поэтому и несастыковочка.


#12

Спасибо за подсказки! Когда у меня получится все решить, то я отпишусь тут :sweat_smile:


#13

На вашем месте я бы попробовал сделать через chunks, это делается быстро и вы сразу же должны увидеть результат.


#14

Понял, я обязательно попробую ваш алгоритм!


#15

Я сделал так, как вы сказали. К сожалению проблема всё та же =(
Вот ссылка на репозитории. Под //1,//2,//3,//4,//5 - код, который я добавил, чтобы выполнить ваш алгоритм. Данные для базы данных уже вшиты в файл Items.plist. Они автоматически добавятся в вашу локальную базу данных.


#16

Ради эксперимента, добавьте кнопку слева в NavigationBar, при нажатии выполните такой код:

items.remove(at: 4)
items2 = items.chunked(into: 5)
collectionView.reloadData()

И замените let item = items[indexPath.section * 5 + indexPath.row]
На let item = items2[indexPath.section][indexPath.row]
В методе cellForItemAt


#17

При нажатии на вашу кнопку все срабатывает. Но, так как релоад дата, то без анимации


#18

Это значит ошибка снова в вашем коде.
Я ниразу не работал с CoreData, так что мне сложно пока сказать где именно.

Исходя из самой ошибки, у вас порядок действий не верный. Получается CollectionView удаляет ячейку, ожидает получить обновленный массив данных, но он все еще прежний. Хотя тут скорее всего был бы просто пропуск последнего элемента.
Скорее всего у вас тогда обратная ситуация, когда CollectionView обновилось раньше, чем была удалена запись.

P.S. вам скорее всего нужно разобраться с моментом обновления CollectionView, а именно после удаления записи и получения нового списка данных.

P.S.S для моего примера с кнопкой, что бы была анимация, вместо reloadData()

collectionView.startAnimations()
collectionView.deleteItem(at: indexPath) // indexPath нужно сформировать
collectionView.endAnimations()

#19

У меня сначала обновляются данные в массивах, айтемс, а уже потом в performBatchUpdates идет работа с коллекцией. Вот код с массивами

func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChange anObject: Any, at indexPath: IndexPath?, for type: NSFetchedResultsChangeType, newIndexPath: IndexPath?) {
    let op: BlockOperation!

    switch type {
    case .insert:
        guard let newIndexPath = newIndexPath else { return }
        op = BlockOperation { (self.collectionView?.insertItems(at: [newIndexPath])) }
    case .delete:
        guard let indexPath = indexPath else { return }
        //выдает корректное положение предмета. Например [1,1]
        print("self.indx! \(self.indx!)")
        op = BlockOperation { (self.collectionView?.deleteItems(at: [self.indx!])) }
        //выдает НЕкорректное положение предмета!
        print("indexPath is \(indexPath)")
    case .update:
        guard let indexPath = indexPath else { return }
        op = BlockOperation { (self.collectionView?.reloadItems(at: [indexPath])) }
    case .move:
        guard let indexPath = indexPath, let newIndexPath = newIndexPath else { return }
        op = BlockOperation { (self.collectionView?.moveItem(at: indexPath, to: newIndexPath)) }
    }

    blockOperations.append(op)
    items = controller.fetchedObjects as! [Item]
    //5
    items2 = items.chunked(into: 5)
}

А вот перформБатчАпдейтс, где запускается код с коллекцией

func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
    collectionView?.performBatchUpdates({ [weak self] in
        guard let self = self else { return }
        self.blockOperations.forEach { $0.start() }
    }, completion: { (finished) in
        self.blockOperations.removeAll(keepingCapacity: false)
    })
}