끝없이 스크롤
지난 시간에 놓쳤던 두 가지 구현을 다시 살펴보겠습니다.
- 필요 시 스피너(인디케이터) 표시(API 호출)
- 사용자가 컬렉션 보기의 맨 아래로 스크롤하는지 확인
방금 프레임을 만들고 문제 # 1을 완료했습니다.
표시할 뷰를 만들고 스크롤 위치를 찾아 원하는 순간에 표시합니다.
새로운 재사용 가능 보기
하단에 스피너를 표시하는 것은 ReusableView를 첨부하여 구현합니다.
RMFoorterLoadingCollectionReusableView를 생성해봅시다.
길더라도 항상 자세하게 쓰는 습관을 들이세요. 셀과 유사하게 만들고 배경색을 지정합니다.
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() {
}
}
생성된 뷰를 컬렉션 뷰에 등록해 봅시다.
등록할 때 작동하려면 elementKindSectionFooter 요소를 포함해야 합니다.
final class RMCharacterListView: UIView {
...
private let collectionView: UICollectionView = {
...
collectionView.register(RMFoorterLoadingCollectionReusableView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: RMFoorterLoadingCollectionReusableView.identifier)
return collectionView
}()
...
}
이제 데이터 소스를 뷰 모델에 주입하고 출력해야 합니다.
이렇게 하려면 바닥글을 반환하고 바닥글의 크기를 전달해야 합니다.
먼저 컬렉션 뷰에서 viewForSupplementaryElementOfKind를 생성합니다.
이 함수의 결과는 0이 될 수 없다는 점에 유의해야 합니다.
반환 유형은 항상 UICollectionReusableView로 설정됩니다!
따라서 nil인 경우 가드를 사용하여 일반 형식을 반환해 봅시다.
- 이것은 나중에 오류가 발생하지만 전체 흐름을 이해하기 쉽게 만들자.
다음으로 바닥글의 크기를 설정해야 합니다.
“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가 나타납니다.
테스트 및 디버깅
그러나 이는 우리가 의도한 디자인이 아닙니다.
로직이 항상 스크롤 뷰의 맨 아래에 배치하기 때문입니다.
또한 이전에 생성한 viewForSupplementaryElementOfKind의 보호 문에 shouldShowLoadMoreIndicator를 추가해 보겠습니다.
그리고 shouldShowLoadMoreIndicator가 무조건 false를 반환하도록 하여 테스트해 봅시다.
public var shouldShowLoadMoreIndicator: Bool {
return false // apiInfo?.next !
= nil
}
...
guard kind == UICollectionView.elementKindSectionFooter, shouldShowLoadMoreIndicator else {
return UICollectionReusableView()
}
폭발합니다.
기록을 남기기 위해 쓴 글이지만 참고하시는 분이 계시다면 한 번 폭발해 보시길 권합니다.
그리고 로그를 보고 어떤 메시지가 발생하는지 확인하는 것이 좋습니다.
어쨌든 로그에 따르면 문제는 보기를 제대로 대기열에서 빼지 않고 인스턴스화했기 때문입니다.
viewForSupplementaryElementOfKind의 결과는 nil일 수 없다는 것을 기억하십니까? 가디언에 문제가 있으면 문제로 처리하고 커밋해야 하는데 UICollectionReusableView 로 인스턴스를 생성하고 처리하기 때문에 폭발하는 상황이다.
이를 해결하려면 관점을 조금 바꿔야 합니다.
- 가드에서 리턴을 제거하고 이를 fatalerror로 교체하십시오.
- 핸들 shouldShowLoadMoreIndicator는 viewForSupplementaryElementOfKind 메서드가 아니라 referenceSizeForFooterInSection에 있습니다.
즉, 주어진 상황에서 뷰를 안팎으로 토글(toggle)하는 것이 아니라, 필요하지 않을 때는 collection 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를 원래 상태로 복원하고 스피너를 만듭니다.
스피너에 의해 생성
스피너는 우리가 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 메서드입니다.
여기에 보호 기능이 있는 바닥글을 첨부하고 바닥글의 애니메이션을 시작합니다.
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 – 현재 위치 결정
컬렉션 보기의 맨 아래로 스크롤했는지 감지하는 코드가 아직 없으므로 바닥글과 스피너가 계속 작동합니다.
이를 해결하기 위해서는 스크롤링 관점에서 파악해야 합니다.
우리는 scrollViewDidScroll을 대리인으로 선언하여 만들었습니다.
여기서 우리는 세 가지 속성을 만듭니다.
- 콘텐츠 오프셋의 y 좌표
- 콘텐츠의 양
- ScrollView의 높이
ScrollView의 높이와 Content의 높이가 다르기 때문에 혼동하기 쉽습니다.
실제로 값을 인쇄할 때
- 스크롤 뷰 프레임 크기는 약 619.3입니다.
(iPhone 14 pro 기준) - 최종 콘텐츠 보기 높이는 약 2922입니다.
여기서는 변경되는 오프셋 좌표만 중요합니다.
스크롤을 끝까지 내리면 2303 정도의 숫자가 표시됩니다.
- 2922 – 619는 무엇입니까?
이 기술을 사용하면 사용자가 보고 있는 보기가 다운되었는지 여부를 알 수 있습니다.
실제 구현에는 약간의 생각이 필요합니다.
totalContentHeight – totalScrollViewFixedHeight 에 도달하려면 상당히 아래로 내려가야 할 것입니다.
바닥글 높이를 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는 아무리 줄여도 변경되지 않습니다.
그러나 특히 이 두 가지는 스크롤 뷰(여기서는 스크롤 뷰를 상속하는 컬렉션 뷰)를 통해 크기를 동적으로 변경하는 속성을 가지고 있습니다.
따라서 데이터 추가 등으로 collection view의 크기가 변경되면 작업을 다시 수행하게 됩니다.
문제가 있습니다.
발자국에서 알 수 있듯이 짧은 시간에 많은 수의 검색이 이루어집니다.
변수를 사용하여 확인하는 방법을 사용하자
- RxSwfit 또는 Combine을 사용할 수 있다고 생각합니다.
extension RMCharacterListViewViewModel: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard shouldShowLoadMoreIndicator, !
isLoadingMoreCharacters else {
return
}
if offset >= (totalContentHeight - totalScrollViewFixedHeight - 120) {
...
isLoadingMoreCharacters = true
}
}
}
실제로 검색하는 함수를 적용하여 다음 함수를 호출하고 이 함수에서 true로 변경합니다.
extension RMCharacterListViewViewModel: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
...
if offset >= (totalContentHeight - totalScrollViewFixedHeight - 120) {
fetchAdditionalCharacters()
}
}
}
지금까지 무한 스크롤을 구현하기 위해 고려해야 할 모든 사항을 살펴보았습니다.
표현 대신 요청을 하여 데이터 처리를 수행할 수 있습니다.
다음으로 컬렉션 뷰에서 데이터 업데이트가 가장 어려웠습니다.
끝