Удаление элементов списка из списка SwiftUI

У SwiftUI есть довольно досадное ограничение, из-за которого сложно создать List или ForEach при получении привязки к каждому элементу для перехода к дочерним представлениям.

Наиболее часто предлагаемый подход, который я видел, - это перебрать индексы и получить привязку с помощью $arr[index](на самом деле, нечто подобное было предложено Apple, когда они удалилиBindingсоответствие Collection):

@State var arr: [Bool] = [true, true, false]

var body: some View {
   List(arr.indices, id: \.self) { index in
      Toggle(isOn: self.$arr[index], label: { Text("\(idx)") } )
   }
}

Это работает до тех пор, пока размер массива не изменится, а затем произойдет сбой с ошибкой времени выполнения индекса вне диапазона.

Вот пример, который вылетит:

class ViewModel: ObservableObject {
   @Published var arr: [Bool] = [true, true, false]
    
   init() {
      DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
         self.arr = []
      }
   }
}

struct ContentView: View {
   @ObservedObject var vm: ViewModel = .init()

   var body: some View {
      List(vm.arr.indices, id: \.self) { idx in
         Toggle(isOn: self.$vm.arr[idx], label: { Text("\(idx)") } )
      }
  }
}

Как правильно обрабатывать удаление из списка, сохраняя при этом возможность изменять его элементы с помощью привязки?

4 ответа

Решение

Используя идеи @pawello2222 и @Asperi, я придумал подход, который, как мне кажется, работает хорошо, но без излишней мерзости (все еще хакерский).

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

Итак, я создал новое представление оболочки, которое создает привязку к элементу массива внутри себя (что, похоже, исправляет порядок недействительности / обновления состояния в соответствии с наблюдением @pawello2222) и передает привязку в качестве параметра для закрытия содержимого.

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

struct Safe<T: RandomAccessCollection & MutableCollection, C: View>: View {
   
   typealias BoundElement = Binding<T.Element>
   private let binding: BoundElement
   private let content: (BoundElement) -> C

   init(_ binding: Binding<T>, index: T.Index, @ViewBuilder content: @escaping (BoundElement) -> C) {
      self.content = content
      self.binding = .init(get: { binding.wrappedValue[index] }, 
                           set: { binding.wrappedValue[index] = $0 })
   }
   
   var body: some View { 
      content(binding)
   }
}

Использование:

@ObservedObject var vm: ViewModel = .init()

var body: some View {
   List(vm.arr.indices, id: \.self) { index in
      Safe(self.$vm.arr, index: index) { binding in
         Toggle("", isOn: binding)
         Divider()
         Text(binding.wrappedValue ? "on" : "off")
      }
   }
}

Похоже, твой Toggle обновляется до List (возможно, ошибка, исправленная в SwiftUI 2.0).

Вы можете извлечь свою строку в другое представление и проверить, существует ли еще индекс.

struct ContentView: View {
    @ObservedObject var vm: ViewModel = .init()

    var body: some View {
        List(vm.arr.indices, id: \.self) { index in
            ToggleView(vm: self.vm, index: index)
        }
    }
}

struct ToggleView: View {
    @ObservedObject var vm: ViewModel
    let index: Int
    
    @ViewBuilder
    var body: some View {
        if index < vm.arr.count {
            Toggle(isOn: $vm.arr[index], label: { Text("\(vm.arr[index].description)") })
        }
    }
}

Таким образом ToggleViewбудет обновляться послеList.

Если вы сделаете то же самое, но внутри ContentView он все равно выйдет из строя:

ContentView {
    ...
    @ViewBuilder
    func toggleView(forIndex index: Int) -> some View {
        if index < vm.arr.count {
            Toggle(isOn: $vm.arr[index], label: { Text("\(vm.arr[index].description)") })
        }
    }
}

SwiftUI 2.0

По результатам тестирования с Xcode 12 / iOS 14 - сбой не воспроизводится

SwiftUI 1.0+

Сбой происходит из-за болтающихся привязок к удаленным элементам (предположительно, из-за неправильного порядка аннулирования / обновления). Вот безопасный обходной путь. Протестировано с Xcode 11.4 / iOS 13.4

struct ContentView: View {
    @ObservedObject var vm: ToggleViewModel = .init()

    var body: some View {
        List(vm.arr.indices, id: \.self, rowContent: row(for:))
    }

    // helper function to have possibility to generate & inject proxy binding
    private func row(for idx: Int) -> some View {
        let isOn = Binding(
            get: {
                // safe getter with bounds validation
                idx < self.vm.arr.count ? self.vm.arr[idx] : false
            },
            set: { self.vm.arr[idx] = $0 }
        )
        return Toggle(isOn: isOn, label: { Text("\(idx)") } )
    }
}

Если кому-то интересно, я объединил Safe Solution от New dev с ForEach:

      struct ForEachSafe<T: RandomAccessCollection & MutableCollection, C: View>: View where T.Index: Hashable {
    private let bindingArray: Binding<T>
    private let array: T
    private let content: (Binding<T.Element>) -> C

    init(_ bindingArray: Binding<T>, _ array: T, @ViewBuilder content: @escaping (Binding<T.Element>) -> C) {
        self.bindingArray = bindingArray
        self.array = array
        self.content = content
    }

    var body: some View {
        ForEach(array.indices, id: \.self) { index in
            Safe(bindingArray, index: index) {
                content($0)
            }
        }
    }
}
Другие вопросы по тегам