상세 컨텐츠

본문 제목

Swift TableView 헤더 안에 CollectionView로 “Milestone 필터” 만들기

카테고리 없음

by kimrindev 2025. 8. 13. 19:33

본문

 

GitHub Issues 목록에 Milestone별 필터링 UI를 붙이면서 겪은 고민–실패–해결 과정을 정리했습니다. 최종적으로는 TableView의 Header에 CollectionView를 넣는 구조로 정리했고, 단일 선택/부분 리로드/상태 동기화까지 흐름 위주로 설명하겠습니다.

 

 

 

1) 목표와 요구사항

  • Issue 목록을 Milestone 기준으로 필터링
  • 프리셋: “전체”, “없음”, 각 Milestone
  • 단일 선택만 허용
  • TableView Header에 CollectionView로 필터 구현

피그마 디자인상에서는 전체선택과 마일스톤이 없는 이슈에 대한 부분이 기재되어있지 않았는데 

필터링을 한다는 의미는 즉슨 모든 케이스에 대한 대처가 있어야 한다고 생각했기때문에 전체선택과 선택없음을 추가했습니다. 

또한 셀의 스크롤 방향이 좌우로 가야했기때문에 collectionView를 사용해야 한다고 판단했고 

높이는 고정이되 너비는 동적으로 할당해야 했습니다. 


2) 초기 설계 & 문제점

Cell 내부에 CollectionView 중첩

기본적으로 모든 View는 TableView안에서 돌아가게끔 하는것이 주된 목적이 였기때문에 Header는 정말 Tilte만

content는 Cell로 가게끔 구성을 했습니다. 

TableView
 └─ MilestoneTableViewCell
    └─ CollectionView
       └─ MilestoneCollectionViewCell

 

겉보기엔 간단하지만 실전에서는 문제가 컸습니다.

  • 데이터 전달 복잡: VC → TableViewCell → CollectionViewCell → Delegate(역콜백)
  • 동기화 어려움: 필터 선택 상태와 테이블 갱신 타이밍 엇갈림
  • 재사용 이슈: 셀 재사용 시 선택 표시가 뒤섞임
  • 이벤트/상태 전파 경로가 과도하게 길어서 체인이 길어질수록 어디서 상태가 끊겼는지 추적이 어렵고, 작은 변경도 다단계로 추후 관리가 복잡해졌습니다 (사용자 탭 → (Inner) CollectionViewCell → (Inner) Delegate → (Outer) TableViewCell → VC → 필터 상태 업데이트 → 데이터 소스 재구성 → tableView.reload... → (다시) 각 셀의 내부 컬렉션 상태 반영.)

3) 구조 개선

Header에 CollectionView  배치

헤더에 두면 VC와 헤더 의 관계로 경로가 한층 짧아진다고 생각했고, 스크롤 충돌면에서도 버벅임을 줄일수 있다고 생각했으며

선택이 바뀐 Cell만 reloadItem으로 변경하지 않아도 되기때문에 깜빡임과 불필요한 diff 연산이 줄어듭니다.

또한 Sticky Header처럼 셀이 스크롤 되더라도 필터링이 상단에 고정되어야 할 가능성이 있어서 그게 가능한 Header로 배치하였습니다.
(TableView의 타입을 변경하면됨 )

TableView
 ├─ Header (CollectionView - 필터)
 └─ Sections (Issue 목록)

 

선택 상태와 필터 로직을 Header에서 단일 책임으로 관리하니 훨씬 깔끔했습니다.

 

 


4) 데이터 모델

filtering의 대상인 issue에는 milestone이 있다면 객체로 들어오는 구조이고 milestone객체에는 고유 Id값이 있기때문에

필터링을 해야한다면 올바른 필터링을 하기위해서 문자열이 각각의 고유번호인 id로 필터링을 해야겠다 라는 생각을 했습니다. 

 

실제 API Docs를 보았을때 제공되는 id는 한자리의 숫자가 아니였고 6자리 정도의 숫자였기때문에 

Int 가 0인것은 전체선택 Int가 1인것은 마일스톤이 없는 이슈 라는것으로 설계를 했었는데

 

struct RepoMileStoneNameCellData {
    let id: Int   
    let name: String
}

 

하지만 추후에 id의 구성이 어떻게 바뀔지 모르는것이기때문이였어서 

아래와 같이 변경을하였습니다. 

struct RepoIssueFilterHeaderCellData {
	let id: Int?
	let name: String
	let all: Bool
	let none: Bool
	
	private init(id: Int?, name: String, all: Bool, none: Bool) {
		self.id = id; self.name = name; self.all = all; self.none = none
	}

	static func all(_ name: String = "전체선택") -> Self {
		.init(id: nil, name: name, all: true, none: false)
	}
	static func none(_ name: String = "마일스톤 없는 이슈") -> Self {
		.init(id: nil, name: name, all: false, none: true)
	}
	static func milestone(id: Int, name: String) -> Self {
		.init(id: id, name: name, all: false, none: false)
	}
}

 

그리고 실제 데이터가 들어와서 CellData를 전달해줄때  아래와 같은 방식으로 사용하니 훨씬더 직관적으로 의도를 파악할수 있었습니다.

	private func addDefaultMilestoneOption(with milestones:[GitHubMilestone]) -> [RepoIssueFilterHeaderCellData] {
		let milestoneCellData: [RepoIssueFilterHeaderCellData] = [
			RepoIssueFilterHeaderCellData.all(),
			RepoIssueFilterHeaderCellData.none()
		]
		let mileStoneMappingCellData: [RepoIssueFilterHeaderCellData] = milestones.map {
			RepoIssueFilterHeaderCellData.milestone(id: $0.id, name: $0.title)
		}
		return milestoneCellData + mileStoneMappingCellData
	}

 

 

 


5) 이벤트 처리 전략: Delegate

터치 이벤트 클릭시에 데이터의 흐름이 명확하고 상위인 ViewModel에서 관리하기가 편할것이라고 생각했기때문에

tableView(_:didSelectRowAt:) 대신에 선택하였는데 당연히 저 메서드 자체로 delegate 패턴을 쓸수 있겠지만 

추후 셀의 서브뷰에 터치이벤트가 생겨야 하는 점을 고려했을때에는 이 방법이 유지보수적으로 가치가있다고 생각했다. 

또한 선택된 셀의 데이터를 넘겨 주는 방식이 훨씬 복잡성을 낮출것이라고 생각했다. 

즉 셀은 이벤트만을 전달하고 상태와 렌더는 상위에서 결정할수 있는 구조라는 점이 델리게이트 패턴을 쓰도록 만들었다.

protocol RepoMileStoneNameCellDelegate: AnyObject {
    func touchUpInsideMilestoneButton(_ cellData: RepoMileStoneNameCellData)
}

 

 


6) 단일 선택 로직 & 부분 리로드

 

동일셀을 눌렀을때 불필요한 리렌더를 차단하기위해서 reloadItems 를 사용했고 

선택시 이전선택과 현재선택의 셀만 변경하는것이 reloadData보다 새로 그려야하는 부분이 적기때문에 성능적인 이점이 있다고 생각해서 사용하였다. 핵심은 이전/신규 인덱스만 리로드하여 반응성을 높이는 것.

 

	private func index(of data: RepoIssueFilterHeaderCellData) -> Int? {
		if data.all  { return item.firstIndex(where: { $0.all  }) }
		if data.none { return item.firstIndex(where: { $0.none }) }
		guard let id = data.id else { return nil }
		return item.firstIndex(where: { $0.id == id && !$0.all && !$0.none })
	}
	func touchUpInsideMilestoneButton(_ cellData: RepoIssueFilterHeaderCellData) {
		guard let newIndex = index(of: cellData) else { return }
		let oldIndex = selectedIndex
		selectedIndex = newIndex

		filteringColletcionView.reloadItems(at: [
			IndexPath(row: oldIndex, section: 0),
			IndexPath(row: newIndex, section: 0)
		])

		delegate?.touchUpInsideMilestoneButton(cellData)
	}

 

 


7) 시행착오

 

(1) 셀 크기 계산 오류

  • UICollectionViewFlowLayout.automaticSize로 적용했을때 View hirerachy에서 실제 padding값을 확인해본 결과 피그마에서 기대하는 사이즈와 동일하게 나오지 않았기때문에  수동 계산으로 전환하게 되었다. 
extension RepositoryIssueFilteringHeader: UICollectionViewDelegateFlowLayout {
    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        sizeForItemAt indexPath: IndexPath
    ) -> CGSize {
        let text = item[indexPath.row].name
        let textSize = text.size(withAttributes: [.font: UIFont.pretendard(.semiBold, size: 14)])
        let width = textSize.width + 48 // 좌우 패딩 24 * 2
        return CGSize(width: width, height: 40)
    }
}