
무한 스크롤
지난번에 하다가 말았던 구현 두 가지를 다시 생각해 보자
- 원하는 때(API call)에 스피너(인디케이터)를 출력시키기
- User가 컬렉션 뷰의 최하단까지 스크롤하는지 위치 판단
1번 이슈의 틀만 잡아놓고 끝났었다. 출력할 View를 만들고 스크롤의 위치를 감지하고 원하는 순간에 나오게 할 것이다.
새로운 ReusableView
최하단에서 스피너를 출력하는 건 ReusablView라는 걸 붙여서 구현한다.
RMFoorterLoadingCollectionReusableView을 생성하자. 항상 길어도 자세하게 적는 습관을 가지자. Cell과 유사하게 만들고 배경색을 주자.
final class RMFoorterLoadingCollectionReusableView: UICollectionReusableView {
static let identifier = "RMFoorterLoadingCollectionReusableView"
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .blue
}
required init?(coder: NSCoder) {
fatalError("unsupported")
}
private func addConstraints() {
}
}
만들어진 View를 컬렉션 뷰에 등록하자. register 할 때는 elementKindSectionFooter 요소를 넣어줘야 작동한다.
final class RMCharacterListView: UIView {
...
private let collectionView: UICollectionView = {
...
collectionView.register(RMFoorterLoadingCollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: RMFoorterLoadingCollectionReusableView.identifier)
return collectionView
}()
...
}
이제 데이터 소스를 뷰모델에 넣어서 출력해줘야 한다. 이를 위해서 Footer를 반환하도록 해야 하고, Footer의 사이즈를 알려줘야 한다.
먼저, 컬렉션 뷰에서 viewForSupplementaryElementOfKind를 만들어 줄 거다. 여기서 중요한 점이 이 함수의 결과는 nil이 될 수가 없다는 것이다. 반환형이 항상 UICollectionReusableView로 고정되어 있다!
그러므로 guard를 써서 nil이면 일반적인 형태를 반환하도록 하자.
- 이건 나중에 에러가 날거지만 전반적인 흐름 파악을 위해 그냥 하자
다음으로 footer의 사이즈를 만들어줘야 한다. referenceSizeForFooterInSection을 찾아서 너비는 컬렉션뷰 너비, 높이는 100으로 하드코딩해 주자.
extension RMCharacterListViewViewModel: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
...
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
guard kind == UICollectionView.elementKindSectionFooter else {
return UICollectionReusableView()
}
let footer = collectionView.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: RMFoorterLoadingCollectionReusableView.identifier, for: indexPath)
return footer
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
return CGSize(width: collectionView.frame.width, height: 100)
}
...
}

이제 저 영역에 스피너를 넣고 배경색을 지우면 적절한 UI가 나온다.
Test & Debugging
그런데 로직상 항상 스크롤뷰의 하당에 붙어 있으니까 우리가 의도한 디자인이 아니다. 아까 전에 만든 viewForSupplementaryElementOfKind의 guard문에 shouldShowLoadMoreIndicator도 넣어버리자.
그리고 shouldShowLoadMoreIndicator를 무조건 false를 리턴하게 해서 테스트해 보자
public var shouldShowLoadMoreIndicator: Bool {
return false // apiInfo?.next != nil
}
...
guard kind == UICollectionView.elementKindSectionFooter, shouldShowLoadMoreIndicator else {
return UICollectionReusableView()
}
터진다. 기록용으로 남기는 글이긴 하지만 누군가가 이걸 참고로 하고 있다면 한번 터지는걸 직접 보는 걸 권유한다. 그리고 로그를 보고 어떤 메시지가 출력되는지를 보면 좋다. 아무튼, 로그에는 적절하게 대기열에서 뷰를 빼지 않고 인스턴스화로 인한 문제라고 한다.
아까 viewForSupplementaryElementOfKind의 결과가 nil이 될 수 없다고 한 게 기억나는가? guard로 문제가 생겼으면 문제로 처리하고 넘겨야 하는데 UICollectionReusableView로 인스턴스를 만들어서 처리하니까 터지는 상황이다.
해결하기 위해서는 관점을 조금 바꿔야 한다.
- Guard에서 return을 빼고 fatalerror로 바꾸기
- shouldShowLoadMoreIndicator를 viewForSupplementaryElementOfKind 메서드에서 처리하지 말고 referenceSizeForFooterInSection에서 처리하기
즉, 특정 상황에서 View를 드러내고 사라지게 하는 것이 아니라 높이를 조절해서 필요 없으면 높이가 0인 상태로 컬렉션뷰 최하단에 달려있고 필요하면 자신의 크기를 늘리는 방식을 사용하는 것이다. 이번 포스팅의 핵심은 개인적으로 이 부분이라 생각한다.
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
guard kind == UICollectionView.elementKindSectionFooter else {
fatalError("Unsupported")
}
...
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
guard shouldShowLoadMoreIndicator else {
return .zero
}
return CGSize(width: collectionView.frame.width, height: 100)
}
정상적으로 작동이 될 것이다. 이제 shouldShowLoadMoreIndicator를 원래 상태로 돌려주고 스피너를 만들어주자.
Spinner 제작
spinner는 우리가 characterListview에서 만든 걸 기억하고 있을 것이다. 복붙 해서 최대한 활용하자.레이아웃을 정했다면 VM으로 가서 나오는 순간을 정의해 보자.
final class RMFoorterLoadingCollectionReusableView: UICollectionReusableView {
...
private let spinner: UIActivityIndicatorView = {
let spinner = UIActivityIndicatorView(style: .large)
spinner.hidesWhenStopped = true
spinner.translatesAutoresizingMaskIntoConstraints = false
return spinner
}()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .systemBackground
addSubview(spinner)
addConstraints()
}
...
private func addConstraints() {
NSLayoutConstraint.activate([
spinner.widthAnchor.constraint(equalToConstant: 100),
spinner.heightAnchor.constraint(equalToConstant: 100),
spinner.centerXAnchor.constraint(equalTo: centerXAnchor),
spinner.centerYAnchor.constraint(equalTo: centerYAnchor)
])
}
public func startAnimating(){
spinner.startAnimating()
}
}
다시 주목해야 할 곳은 viewForSupplementaryElementOfKind 메서드다.
이곳에서 guard와 함께 footer를 붙이게 하고 foorter의 애니메이션을 시작해 준다.
func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
guard kind == UICollectionView.elementKindSectionFooter,
let footer = collectionView.dequeueReusableSupplementaryView(
ofKind: kind,
withReuseIdentifier: RMFoorterLoadingCollectionReusableView.identifier,
for: indexPath) as? RMFoorterLoadingCollectionReusableView else {
fatalError("Unsupported")
}
footer.startAnimating()
return footer
}

2번 이슈 - 현재 위치 판단
아직 컬렉션뷰 최하단까지 스크롤했다는 걸 감지하는 코드가 없어서 footer와 스피너는 계속 작동한다. 이를 해결하기 위해서는 스크롤 뷰 관점에서 파악해봐야 한다. 우리는 scrollViewDidScroll을 델리게이트 선언하여 만들어 둔적이 있다.
여기서는 세 가지의 프로퍼티를 만드는데
- Content Offset의 y 좌표
- Content의 높이
- ScrollView의 높이
혼동하기 쉬운 건 ScrollView의 높이와 Content의 높이는 같지 않다.
값을 실제로 출력해서 보면
- 스크롤뷰의 프레임 사이즈는 약 619.3 (아이폰 14 pro 기준)
- 컨텐트 뷰의 최종 높이는 약 2922가 나온다.
이제 여기서 혼자 변화하는 offset 좌표가 중요하다. 만약 화면을 끝까지 내린다면 약 2303이라는 수치가 나타난다.
- 2922 - 619은 뭘까?
이 기법으로 현재 유저가 보고 있는 View가 최하단인지 알 수가 있다.
실제로 구현하는 건 조금 생각이 필요하다. 아마 그냥 totalContentHeight - totalScrollViewFixedHeight가 되는 위치에 도달하려면 상당히 깊숙하게 내려야 한다. 왜냐면 아직 footer 높이가 100으로 추가적으로 잡혀 있기 때문이다. 조금 더 쉽게 로딩 상태에 도달하게 20을 더해서 120을 빼준다.
extension RMCharacterListViewViewModel: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard shouldShowLoadMoreIndicator else {
return
}
let offset = scrollView.contentOffset.y
let totalContentHeight = scrollView.contentSize.height
let totalScrollViewFixedHeight = scrollView.frame.size.height
if offset >= (totalContentHeight - totalScrollViewFixedHeight - 120) {
print("Should start fetching more")
}
}
}
여기서 totalContentHeight, totalScrollViewFixedHeight은 아무리 내려도 변하지 않았다. 하지만 정확하게 저 둘은 스크롤뷰(여기선 스크롤뷰를 상속한 컬렉션 뷰)에 의해서 동적으로 크기가 변하는 특성을 가지고 있다. 때문에, 만약 컬렉션뷰의 크기가 데이터의 추가 등으로 변화한다면 알아서 연산이 다시 이뤄진다.
문제가 하나 있다. print를 찍어봤으니 알겠지만 이렇게 할 시 짧은 시간에 fetch이 어마어마하게 많이 수행될 것이다. 변수를 이용하여 체크하는 방식을 사용하자
- RxSwfit나 Combine을 써도 될 거 같긴 하다
extension RMCharacterListViewViewModel: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard shouldShowLoadMoreIndicator, !isLoadingMoreCharacters else {
return
}
if offset >= (totalContentHeight - totalScrollViewFixedHeight - 120) {
...
isLoadingMoreCharacters = true
}
}
}
실제로 fetch 하는 함수까지 적용한다면 아래 함수를 호출하고 true로 바꿔주는 건 저 함수에서 실행하자
extension RMCharacterListViewViewModel: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
...
if offset >= (totalContentHeight - totalScrollViewFixedHeight - 120) {
fetchAdditionalCharacters()
}
}
}
이상으로 무한 스크롤 구현을 위해 고려해야 하는 점을 모두 알아봤다. 우리는 print 대신 request를 수행함으로써 데이터 처리를 할 수 있게 된다. 다음은 제일 어려웠던 컬렉션 뷰에 데이터 갱신하기다.
끝
'Swift > Rick & Morty' 카테고리의 다른 글
| [iOS] Rick&Morty - #12 Image Loader (0) | 2023.03.09 |
|---|---|
| [iOS] Rick&Morty - #11 Pagination (0) | 2023.03.08 |
| [iOS] Rick&Morty - #9 Character Detail View (0) | 2023.03.06 |
| [iOS] Rick&Morty - #8 Showing Characters (0) | 2023.03.05 |
| [iOS] Rick&Morty - #7 CollectionViewCell (0) | 2023.03.04 |