관련 PR
문제 상황
위와 같은 뷰에서 각 cell의 체크 박스(버튼)의 토글 이벤트를 combine으로 활용하여 VC와 바인딩하려고 한다.
이 때,
위처럼, 버튼 클릭을 해도 토글이 되지 않고 다시 원상태로 돌아오는, 정확히 말하면 두번 토글되어 본래 상태로 돌아오는 문제가 발생했다.
VC에서 cell의 publisher를 구독하여 print 해보면 클릭하면 이벤트가 연속해서 2번 발생하는 상황이었다.
코드
- VC
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let cell = tableView.dequeueReusableCell( withIdentifier: PushAlarmPartTVC.className, for: indexPath) as? PushAlarmPartTVC else { return UITableViewCell() } cell.selectionStyle = .none let cellType = PartCategory.allCases[indexPath.item] let isOn = pushToggleList[indexPath.item] cell.initCell(index: indexPath.row, title: cellType.title, isOn: isOn) cell.partButtonTapped .receive(on: RunLoop.main) .sink { indexSelected in print(indexSelected) }.store(in: cancelBag) return cell }
- cell
private var index: Int? public lazy var partButtonTapped: Driver<(Int, Bool)> = { return self.stateButton.publisher(for: .touchUpInside) .map { self.stateButton.isSelected.toggle() return (self.index!, $0.isSelected) } .asDriver() }()
문제 발생 원인
원인을 발견하는데 시간이 오래 걸렸지만 정작 간단한 이유였다.
바로, tableView의 realoadData() 때문이었다.
해당 뷰는 뷰 로드 시점에 서버와 통신하여 사용자가 미리 체크한 요소의 정보를 받아와서 tableView를 reloadData() 하는 로직을 수행하고 있었다.
- VC
private func bindViewModels() {
let input = PushAlarmSettingViewModel.Input(viewDidLoad: Driver.just(()))
let output = self.viewModel.transform(from: input, cancelBag: self.cancelBag)
output.$pushSettingList
.compactMap { $0 }
.sink { model in
self.pushToggleList = model.pushSettingList
self.partListTableView.reloadData()
}.store(in: self.cancelBag)
}
realoadData()가 실행됨에 따라 cellForRowAt이 다시 동작하였고 cell마다 바인딩이 2번 발생한 것이다.
따라서 realadData가 없는 정적인 테이블뷰(콜렉션뷰)에서는 해당 문제가 발생하지 않는다.
해결 방법
- reloadData가 실행되어 cellForRowAt이 재실행될 때 기존에 바인딩 되어 있던 stream을 cancel 하면 된다.
- 이를 위해 각 stream을 구분할 수 있도록 cell의 개수만큼의 UUID 어레이를 만든다.
- UUID를 Key로 하고 AnyCancellable을 value로 하는 딕셔너리를 만든다.
- VC와 Cell의 바인딩이 생성되기 전에 그 cell의 UUID에 맞는 value가 딕셔너리에 이미 존재한다면 이를 cancel한다.
- 새로 구독을 하여 생기는 AnyCancellable을 앞서 만든 딕셔너리에 cell의 UUID를 키로 하여 저장한다.
- VC
private let cellIDs = (1...7).map({ _ in UUID() })
private var cellBindBag: [UUID: AnyCancellable] = [:]
... 생략 ...
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(
withIdentifier: PushAlarmPartTVC.className, for: indexPath)
as? PushAlarmPartTVC else { return UITableViewCell() }
cell.selectionStyle = .none
let cellType = PartCategory.allCases[indexPath.item]
let isOn = pushToggleList[indexPath.item]
cell.initCell(index: indexPath.item, title: cellType.title, isOn: isOn)
let id = cellIDs[indexPath.item]
cellBindBag[id]?.cancel()
cellBindBag[id] = cell.partButtonTapped
.receive(on: RunLoop.main)
.sink { [weak self] indexSelected in
guard let self = self else { return }
self.pushToggleList[indexSelected.0] = indexSelected.1
}
return cell
}
실행 결과
정상적으로 바인딩이 한 번만 이루어져 버튼이 잘 select 되는 것을 확인할 수 있다.
다른 해결 방법
1. Cell의 cancelBag을 사용한다. (정석적인 방법)
근본적인 원인
- 기존의 방식대로 cellForRowAt에서 구독을 할 때
.store(in self.cancelBag)
을 하게 되면 VC에 있는 cancelBag에 해당 cancellable을 저장한다는 의미이다. ⇒ 따라서 해당 구독은 VC의 생명주기와 같이간다. 하지만 우리는 cell의 생명주기와 구독이 같이 움직이기를 원한다. 이를 위해 cell에 cancelBag을 따로 만들고 해당 cancelBag에 구독을 저장한다.
class PushAlarmPartTVC: UITableViewCell {
// MARK: - Properties
var cancelBag = CancelBag()
...생략...
override func prepareForReuse() {
self.cancelBag = CancelBag()
}
}
- cell에도 cancelBag을 생성한다.
- prepareForReuse() 함수를 override하여 cancelBag을 초기화 시켜준다. (cancelBag 내부에 있는 AnyCancellable들을 전부 cancel() 시키는 역할)
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(
withIdentifier: PushAlarmPartTVC.className, for: indexPath)
as? PushAlarmPartTVC else { return UITableViewCell() }
cell.selectionStyle = .none
let cellType = PartCategory.allCases[indexPath.item]
let isOn = pushToggleList[indexPath.item]
cell.initCell(index: indexPath.item, title: cellType.title, isOn: isOn)
cell.partButtonTapped
.receive(on: RunLoop.main)
.sink { [weak self] indexSelected in
guard let self = self else { return }
print(indexSelected)
self.pushToggleList[indexSelected.0] = indexSelected.1
}.store(in: cell.cancelBag)
return cell
}
- VC의 cellForRowAt 함수에서는 위와 같이 cell.cancelBag에 해당 구독을 저장한다.
2. cell과의 바인딩을 위한 또다른 cancelBag을 생성하고 reloadData()를 호출하면 해당 cancelBag을 초기화 시켜준다.
public class PushAlarmSettingVC: UIViewController {
private var cancelBag = CancelBag() // 기존의 viewModel과 바인딩을 위한 cancelBag
private var cancelBagForCell = CancelBag() // cell과의 바인딩을 위한 cancelBag
public override func viewDidLoad() {
super.viewDidLoad()
someTask()
}
private func someTask() {
cancelBagForCell.cancel()
// cancel()는 set 내부의 AnyCancellable을 전부 cancel하도록 구현
myTableView.reloadData()
}
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(
withIdentifier: PushAlarmPartTVC.className, for: indexPath)
as? PushAlarmPartTVC else { return UITableViewCell() }
cell.selectionStyle = .none
let cellType = PartCategory.allCases[indexPath.item]
let isOn = pushToggleList[indexPath.item]
cell.initCell(index: indexPath.item, title: cellType.title, isOn: isOn)
cell.partButtonTapped
.receive(on: RunLoop.main)
.sink { [weak self] indexSelected in
guard let self = self else { return }
print(indexSelected)
self.pushToggleList[indexSelected.0] = indexSelected.1
}.store(in: cancelBagForCell)
return cell
}
}
문제의 원인이 VC와 cell의 생명주기가 달라서 였기 때문에 이를 이용한 해결책이다.
- cell과의 바인딩을 위한 또다른 cancelBag을 생성한다. (기존의 cancelBag은 viewModel과의 바인딩 정보가 있기 때문에 이를 함부로 건들면 안되기 때문이다.)
- cell의 publisher를 구독할 때 방금 만든 새 cancelBag에 store 한다.
- tableView의 reloadData()를 호출할 때 해당 cancelBag에 있는 요소들을 전부 cancel 시켜준다. (기존의 구독 cancel) ⇒ reload 되어 cellForRowAt이 재실행되고 새로운 구독이 생겨도 해당 구독이 유일하도록 함.
'iOS > Combine' 카테고리의 다른 글
[Combine] Cancellable 탐구 (with OpenCombine) (2) | 2023.11.22 |
---|---|
[Combine] Publisher와 Subscriber 그리고 Subscription (with OpenCombine) (1) | 2023.11.15 |
Combine in Practice (0) | 2023.10.24 |
Introducing Combine (1) | 2023.10.23 |
[iOS] SOPT - Swift 에러 핸들링 with Combine + Clean Architecture (0) | 2023.07.30 |
댓글