SwiftUI로 TableView를 만들 때 List()를 사용하기도 하지만 ForEach를 통해서 구현하는 빈도가 더 많은 것 같습니다. 이번에는 단순한 리스트 뿐만 아니라 안에 있는 데이터를 삭제할 수 있는 리스트를 구현해보록 하겠습니다. 텍스트 옆에 데이터를 삭제할 수 있는 버튼을 구현해서 누르면 해당 텍스트가 배열에서 삭제되는 로직으로 구현해보겠습니다.
Table의 ViewModel이 모든 데이터를 가지고 있으면서 Cell에는 index만 전달하고 이 index를 활용해서 ViewModel에서 직접 데이터를 가져오는 방식으로 구현 해봅시다.
import SwiftUI
// 상위 View
struct Table: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
VStack {
//✅ 인덱스를 전달하기 위해 숫자 range 사용
ForEach(0..<viewModel.names.count, id: \.self) { index in
Cell(index: index, viewModel: viewModel)
}
}
}
}
// 상위 View의 ViewModel
class ViewModel: ObservableObject {
@Published var names = ["Kim", "Lee", "Park", "Choi"]
}
// 하위 View
struct Cell: View {
let index: Int
@ObservedObject var viewModel: ViewModel
init(index: Int, viewModel: ViewModel) {
self.index = index
self.viewModel = viewModel
print("init cell of \(viewModel.names[index])")
}
var body: some View {
HStack {
Text(viewModel.names[index])
Button("Delete") {
_ = viewModel.names.remove(at: index)
}
}
}
}
삭제 버튼을 누르자마자 Index out of range 에러가 뜨게 됩니다. 좀 더 자세히 보면 SwiftUI는 화면을 그리고 위해서 3번 인덱스의 데이터를 참조하려고 하는데 우리가 데이터를 삭제했기 때문에 names 배열에는 3번 인덱스의 element가 없어서 발생하는 에러입니다.
인덱스를 사용해서 데이터를 전달하는 방법을 사용하면 에러가 나게 됩니다.
요즘 사이드 프로젝트 (일본어 단어장)을 만들면서 이와 비슷한 이슈를 많이 다루게 되었는데요. SwiftUI의 동작 원리에 대해서 이해할 수 있는 시간이 되고 있습니다. 몇 가지 경험을 토대로 이 에러가 발생하는 원인을 추측해보겠습니다.
🧪 가설 : SwiftUI에 전달된 viewModel.names.count의 값이 변하지 않았다?
먼저 SwiftUI가 화면을 다시 그리는 경우는 @State (@Binding, @Published 포함)으로 선언된 변수에 명시적으로 새로운 값이 할당되었을 때입니다. 즉 @State 변수만이 SwiftUI에 화면에 표시할 데이터가 바뀌었다는 신호를 보낼 수 있는 것이죠.
우리가 ForEach를 통해 Cell을 선언한 부분을 한번 살펴 봅시다. 우리는 Cell의 갯수를 viewModel.names.count 개라고 SwiftUI에게 알려준 셈입니다. 즉 SwiftUI는 Cell의 갯수를 4개로 알고 있고 이는 “0..<4”로 선언한 것과 동일하게 인식 할 수도 있습니다. 즉 SwiftUI는 아래와 같이 선언한 것으로 받아들일 수도 있다는 말입니다.
ForEach(0..<4, id: \.self) { index in
Cell(index: index, viewModel: viewModel)
}
이때 삭제를 하게 되면 당연히 SwiftUI 입장에서는 3번 인덱스에 대한 데이터를 요구하고 index out of range에러가 발생하게 되겠습니다. 즉 names가 @Published로 선언되었음에도 불구하고 names.count는 SwiftUI에 바뀌어서 전달되지 않는다는 가설입니다.
🚫 하지만 위 가설에 대한 실험은 아래 코드로 반박될 수 있습니다. 아래 코드는 Cell 안에서 index로 데이터를 참조하지 않고 그냥 index를 그대로 텍스트에 출력하는 경우 입니다. 즉 위 코드에서 문제를 일으킨 subscript 부분만 삭제한 것이죠.
우리의 예상대로라면 삭제 버튼을 눌러도 Cell의 갯수는 그대로 유지되어야 합니다. 삭제가 되지 않아야 하죠. viewModel.names.count가 변경되었다는 것이 SwiftUI에게 전달 되지 않아야하니까요.
import SwiftUI
// 상위 View
struct Table: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
VStack {
Text("names.count = \(viewModel.names.count)")
ForEach(0..<viewModel.names.count, id: \.self) { index in
Cell(index: index, viewModel: viewModel)
}
}
}
}
// 상위 View의 ViewModel
class ViewModel: ObservableObject {
@Published var names = ["Kim", "Lee", "Park", "Choi"]
}
// 하위 View
struct Cell: View {
let index: Int
@ObservedObject var viewModel: ViewModel
init(index: Int, viewModel: ViewModel) {
self.index = index
self.viewModel = viewModel
}
var body: some View {
HStack {
Text("\(index)")
Button("Delete") {
_ = viewModel.names.remove(at: index)
}
}
}
}
하지만 실행결과는 멀쩡하게 Cell이 삭제되고 viewModel.count도 갱신되는 것을 볼 수 있습니다.
⭐️ 실제로 SwiftUI는 @Published (@State, @Binding)으로 선언된 변수에 연결된 computed property 역시 변화할 때마다 View를 갱신해줍니다.
🤔 그렇다면 결론은 하나입니다. 삭제를 통해 데이터가 없어지고 SwiftUI에 @Published 변수가 변경되었다는 소식이 가기 전에 SwiftUI가 뷰를 그리는 것을 시도한다는 것입니다. 음… 왜 그럴까요… 좀 더 SwiftUI의 원리에 대한 공부 해봐야할 것 같습니다. 일단은 지금 당장의 문제부터 해결해봅시다.
이처럼 ForEach 문에서 index를 사용하는 것은 위험합니다. 그렇다면 ForEach에 전달하는 데이터를 숫자 range를 전달하는 배열 그 자체를 전달하면 됩니다.
이렇게 하면 삭제하는 로직을 만들어 줘야 합니다. 여기서는 그냥 원본 배열에서 똑같은 String 삭제를 했습니다만 실무에서는 각각 자료형의 고유한 id 값을 통해 삭제해주는 것이 안전합니다.
import SwiftUI
struct Table: View {
@ObservedObject var viewModel = ViewModel()
var body: some View {
VStack {
//✅ 배열 자체를 전달
ForEach(viewModel.names, id: \.self) { name in
Cell(name: name, viewModel: viewModel)
}
}
}
}
class ViewModel: ObservableObject {
@Published var names = ["Kim", "Lee", "Park", "Choi"]
func delete(_ name: String) {
names.removeAll { $0 == name }
}
}
struct Cell: View {
let name: String
@ObservedObject var viewModel: ViewModel
init(name: String, viewModel: ViewModel) {
self.name = name
self.viewModel = viewModel
print("init cell of \(name)")
}
var body: some View {
HStack {
Text(name)
Button("Delete") {
viewModel.delete(name)
}
}
}
}
이렇게 하면 우리가 원하는대로 삭제 버튼을 누르면 각각의 Cell이 삭제되는 것을 볼 수 있습니다.
참고로 Cell 1개를 지우더라도 그 Cell만 메모리에서 사라지는 것이 아니라 모든 Cell들이 다시 init을 하기 때문에 혹시 각각의 Cell을 init할 때 네트워크 통신 같은 무거운 작업을 한다면 조심해야 합니다.
하지만 onAppear에 정의된 코드는 Cell을 삭제해도 다시 실행되지 않습니다. 대신 onAppear의 경우에는 스크롤 뷰와 함께 사용할 때 Cell이 화면에서 벗어났다가 다시 보여지면 실행됩니다.
따라서 만드려는 화면의 특성에 따라서 init과 onAppear을 전략적으로 사용해야 합니다.