UIScrollView
에는 스크롤 이벤트를 감지하고 필요한 동작을 수행할 수 있는 scrollViewDidScroll
메서드가 있다.
메인 화면에 구현한 UICollectionView
는 UIScrollView
를 상속받기 때문에 이 메서드를 사용하여 무한 스크롤을 구현하였다.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y // 현재 스크롤된 Y 좌표
let contentHeight = scrollView.contentSize.height // 스크롤 되어야 하는 컨텐츠의 총 높이
let height = scrollView.frame.size.height // 화면에 보이는 스크롤뷰의 높이
// 하단에 도달했을 때 새로운 데이터 로드하기
if offsetY > contentHeight - height - 100 {
fetchMoreData()
}
}
현재 스크롤 된 화면의 Y 좌표와 컨텐츠의 총 높이, 스크롤뷰의 높이를 계산해 화면 하단에 도달할 때쯤 새로운 데이터를 불러올 메서드인 fetchMoreData
를 호출하도록 코드를 작성하였다.
MainViewModel
에 새 데이터를 불러오는 fetchMorePokemonData
메서드를 작성할 건데,
원래 포켓몬 데이터를 불러오는 fetchPoekemonData
와 비슷하게 구현하고, 호출 될 때마다 offset
이 limit
만큼 더해져서, 계속해서 다음 페이지의 포켓몬 데이터를 불러오도록 할 것이다.
그전에 포켓몬 데이터가 더 없을때까지 스크롤 되어야 하므로 offset 값을 매우 큰 값을 넣어 포켓몬 데이터가 더이상 없을때 어떤 값이 나오는지 확인해 보았다.
위와 같이, 데이터가 없을 땐 results
가 빈배열이 반환된다. 따라서 .isEmpty
를 활용하면 될 것이다.
// 포켓몬 데이터 새로 불러오기
func fetchMorePokemonData() {
print("fetchMorePokemonData 호출됨")
guard !isFetching, hasMoreData else { return } // 추가 데이터가 없거나 중복 호출 방지
isFetching = true // 메서드 동작시 플래그 true
offset += limit // 오프셋 파라미터 증가
guard let url = URL(string: "https://pokeapi.co/api/v2/pokemon?limit=\(limit)&offset=\(offset)") else {
pokemonSubject.onError(NetworkError.invalidUrl)
isFetching = false // url 에러시 플래그 초기화하고 종료
return
}
NetworkManager.shared.fetch(url: url)
.subscribe(onSuccess: { [weak self] (pokemonList: PokemonList) in
// 새 데이터 여부 확인
if pokemonList.results.isEmpty {
self?.hasMoreData = false // 더이상 데이터 없을 시 플래그 false
} else {
// 기존 데이터에 새로운 데이터 추가
var currentData = (try? self?.pokemonSubject.value()) ?? []
currentData.append(contentsOf: pokemonList.results)
self?.pokemonSubject.onNext(currentData)
}
self?.isFetching = false // 새 데이터 호출 후 플래그 초기화
}, onFailure: { [weak self] error in
print("새 데이터 fetch 에러: \(error)")
self?.isFetching = false
}).disposed(by: disposeBag)
}
처음엔 그냥 작성했더니 스크롤 할 때마다 엄청나게 많이 호출 되길래, 플래그로 쓸 isFetching
라는 프로퍼티를 추가해 메서드가 동작 중일 땐 호출 되어도 건너뛰도록 guard문을 작성하였다.
그리고 hasMoreData
라는 프로퍼티를 이용해 더이상 불러올 데이터가 없을 경우를 체크하도록 하였다.
빌드 후 확인해보니, 스크롤 하지 않아도 새 데이터가 로드되는지 데이터가 이상하게 들어가는 오류 발생하였다.
print()
를 추가하여 호출되는 메서드를 확인해보니 스크롤 하지 않아도 처음 데이터를 추가하는 fetchPokemonData()
와, 스크롤 시 새 데이터를 불러오는 fetchMorePokemonData()
가 동시에 호출되었다.
// 스크롤 감지 메서드
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y // 현재 스크롤된 Y 좌표
let contentHeight = scrollView.contentSize.height // 스크롤 되어야 하는 컨텐츠의 총 높이
let height = scrollView.frame.size.height // 화면에 보이는 스크롤뷰의 높이
// 하단에 도달했을 때 새로운 데이터 로드하기
if offsetY > contentHeight - height - 100 {
fetchMoreData()
}
}
스크롤 감지 시 호출되는 scrollViewDidScroll
를 위와 같이 작성한 탓이다.
스크롤 할 때 새 데이터를 불러오는 메서드를 호출하는데, 100이라는 버퍼를 줘서 부드럽게 스크롤 할 수 있도록
offsetY > contentHeight - height - 100
라는 조건 설정한 것이다.
그런데 초기에 불러오는 데이터가 많지 않아서, 초기 컨텐츠가 충분히 크지 않아 빌드만 해도 조건이 성립해버리고, 이로 인해 초기 데이터 로드가 끝나기도 전에 새 데이터를 로드하게 되어 이상한 출력이 나오고만 것이다.
if offsetY > contentHeight - height - 100 && contentHeight > height {
fetchMoreData()
}
따라서 조건문을 위와 같이 수정하였다.
이렇게 하면 스크롤 할 때 새 데이터를 로드한다는 기존의 의도를 유지하면서, 컨텐츠 크기가 충분히 크지 않은 초기 상황에는 새 데이터 로드 메서드가 호출되지 않도록 하였다.
초기 데이터를 로드하는 fetchPokemonData
메서드와 새 데이터를 로드하는 fetchMorePokemonData
메서드를 보면
// 포켓몬 데이터 불러오기
func fetchPoekemonData() {
guard let url = URL(string: "https://pokeapi.co/api/v2/pokemon?limit=\(limit)&offset=\(offset)") else {
pokemonSubject.onError(NetworkError.invalidUrl)
return
}
NetworkManager.shared.fetch(url: url)
.subscribe(onSuccess: { [weak self] (pokemonList: PokemonList) in
self?.pokemonSubject.onNext(pokemonList.results)
}, onFailure: { [weak self] error in
self?.pokemonSubject.onError(error)
}).disposed(by: disposeBag)
}
// 포켓몬 데이터 새로 불러오기
func fetchMorePokemonData() {
print("fetchMorePokemonData 호출됨")
guard !isFetching, hasMoreData else { return } // 추가 데이터가 없거나 중복 호출 방지
isFetching = true // 메서드 동작시 플래그 true
offset += limit // 오프셋 파라미터 증가
guard let url = URL(string: "https://pokeapi.co/api/v2/pokemon?limit=\(limit)&offset=\(offset)") else {
pokemonSubject.onError(NetworkError.invalidUrl)
isFetching = false // url 에러시 플래그 초기화하고 종료
return
}
NetworkManager.shared.fetch(url: url)
.subscribe(onSuccess: { [weak self] (pokemonList: PokemonList) in
// 새 데이터 여부 확인
if pokemonList.results.isEmpty {
self?.hasMoreData = false // 더이상 데이터 없을 시 플래그 false
} else {
// 기존 데이터에 새로운 데이터 추가
var currentData = (try? self?.pokemonSubject.value()) ?? []
currentData.append(contentsOf: pokemonList.results)
self?.pokemonSubject.onNext(currentData)
}
self?.isFetching = false // 새 데이터 호출 후 플래그 초기화
}, onFailure: { [weak self] error in
print("새 데이터 fetch 에러: \(error)")
self?.isFetching = false
}).disposed(by: disposeBag)
}
위와 같이 작성 해놓았는데, url을 만든 뒤 NetworkManager의 fetch
메서드를 활용해 데이터를 불러오는 것이 같아서 코드 중복이라고 생각하였다.
isFetching
이나 hasMoreData
를 활용했던 것처럼,
플래그 변수를 활용하여 첫 데이터를 로드하는 상황인지, 스크롤 하여 새 데이터를 로드하는 상황인지 구분하면 두 메서드를 하나로 만들어 관리할 수 있을 것 같았다.
func fetchPoekemonData(reset: Bool) {
// 초기 데이터 로드(true)인지 추가 데이터 로드(false)인지 확인
if reset {
offset = 0
hasMoreData = true
}
guard !isFetching, hasMoreData else { return }
isFetching = true
guard let url = URL(string: "https://pokeapi.co/api/v2/pokemon?limit=\(limit)&offset=\(offset)") else {
pokemonSubject.onError(NetworkError.invalidUrl)
isFetching = false
return
}
NetworkManager.shared.fetch(url: url)
.subscribe(onSuccess: { [weak self] (pokemonList: PokemonList) in
guard let self = self else { return }
// 로드 된 데이터 있는지 확인
if pokemonList.results.isEmpty {
self.hasMoreData = false // 없을 경우 플래그 false
}
// 로드 된 데이터 있을 경우
else {
// 초기 데이터 로드 일 경우
if reset {
self.pokemonSubject.onNext(pokemonList.results)
}
// 스크롤 하여 추가 로드하는 경우
else {
var currentData = (try? self.pokemonSubject.value()) ?? []
currentData.append(contentsOf: pokemonList.results)
self.pokemonSubject.onNext(currentData)
}
}
// 오프셋 증가
self.offset += self.limit
// 데이터 로드 후 중복 호출 방지 플래그 false
self.isFetching = false
}, onFailure: { error in
print("데이터 로드 실패: \(error)")
self.isFetching = false
}).disposed(by: disposeBag)
}
fetchPoekemonData
에 Bool
타입의 파라미터를 받아 스크롤 하여 호출 할 때는 false를 넣어주고 새 데이터 호출일 때는 true를 넣어주도록 하여 상황을 구분짓게 만들었다.
이후 로직은 각각의 메서드에 작성했던 코드를 거의 그대로 이용하였다.
두 메서드를 하나로 합치고 나서 빌드 후 첫 화면에서의 오류는 수정되었다.
하지만 여전히 빠르게 스크롤 할 때는 이미지가 중복해서 나오고 있었고, 이를 해결하기 위해 UICollectionViewCell 클래스인 PokemonCell
을 약간 수정하였다.
import UIKit
import RxSwift
import RxCocoa
class PokemonCell: UICollectionViewCell {
static let id = "PokemonCell"
private var disposeBag = DisposeBag()
private var currentPokemonId: Int? // 현재 포켓몬의 id를 추적할 프로퍼티
// 포켓몬 이미지
private let imageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.backgroundColor = UIColor.cellBackground
imageView.layer.cornerRadius = 10
return imageView
}()
// 재사용 처리
override func prepareForReuse() {
super.prepareForReuse()
imageView.image = nil
currentPokemonId = nil // id 초기화도 추가
}
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configure(_ pokemonData: PokemonData, _ viewModel: MainViewModel) {
let pokemonId = pokemonData.id
currentPokemonId = pokemonId // 현재 포켓몬 id 저장
// 이미지 로딩
viewModel.fetchPokemonImage(pokemonData)
.observe(on: MainScheduler.instance)
.subscribe(onSuccess: { [weak self] image in
guard self?.currentPokemonId == pokemonId else { return } // 셀이 재사용 되지 않았는지 확인
self?.imageView.image = image
}, onError: { error in
print("이미지 에러: \(error)")
}).disposed(by: disposeBag)
}
private func setupUI() {
[
imageView
].forEach { contentView.addSubview($0) }
imageView.snp.makeConstraints {
$0.edges.equalTo(contentView)
}
}
}
currentPokemonId
를 추가해 이미지 뿐만 아니라 현재 포켓몬의 id
도 추적하고, prepareForReus
에서 nil
로 초기화 하도록 수정하였다.
셀에 이미지만 표시되는데 id도 굳이 추적하여 초기화 한 것은 스크롤 시 발생하는 문제 때문이다.
사용자가 스크롤을 할 때 셀이 재사용 되는데, 셀A가 포켓몬1의 이미지를 요청하고 있을 때 빠르게 스크롤을 할 경우 셀이 재사용 되어 같은 셀에 포켓몬2의 데이터가 나오게 될 수 있다.
따라서 id를 추적하여 셀이 요청한 포켓몬의 데이터와, 요청이 완료된 포켓몬 데이터의 id를 비교하는 것이다.
셀A가 포켓몬1의 이미지를 요청시, 비동기로 처리되며 currentPokemonId
와 pokemonId
에 포켓몬1의 id가 저장된다.
스크롤로 인해 같은 셀이 포켓몬2의 이미지를 요청하게 되면, currentPokemonId
와 pokemonId
에 포켓몬2의 id가 저장 되었다가, 포켓몬1의 이미지 로딩이 완료되면 pokemonId
는 포켓몬1의 id가 저장된다.
따라서 guard self?.currentPokemonId == pokemonId
가 false가 되어 포켓몬1의 이미지를 업데이트 하지 않게 된다.
스크롤 한다는 것은 다음 포켓몬의 정보를 보겠다는 의도이므로, 포켓몬1이 아닌 2의 정보, 즉 다음 포켓몬의 이미지가 업데이트 되도록 하는 것이다.
guard
문과 prepareForReuse
의 currentPokemonId = pokemonId
을 주석처리하고,
print
를 추가하여 정말로 currentPokemonId
와 pokemonId
달라져 셀 이미지 로드 문제가 발생하는지 확인해 보았다.
612번, 610번, 611번 포켓몬을 로드하다가 스크롤로 인해 601번, 602번, 610번 포켓몬 데이터를 로드하게 되어 false가 된 모습을 볼 수 있다.
같은 포켓몬 이미지가 로드된 셀들인데, 확인해보면
610번 셀에 제대로 데이터가 들어가 있지만 메인 화면에서는 610번 자리에 611번 이미지가 들어가 있는 것을 볼 수 있다.
이런식으로 내부적으로 데이터는 제대로 들어갔는데, 이미지 로드가 잘못 되어있을 때 id를 확인하는 안전장치를 만들어 이미지 재사용 문제를 해결한 것이다.
오늘은 무한 스크롤 구현 하나 하는데 시간을 다 쓰게 되어버렸다.
RxSwift의 기초적인 사용법은 익혔다고 생각했는데, 무한 스크롤 구현하느라 메서드를 추가했다가, 하나로 합치는 과정에서 헷갈려서 이런저런 코드를 썼다 지우기를 반복했다.
또, CollectionView도 많이 익숙해졌다고 생각했는데 스크롤 기능 하나 추가하니까 이미지가 이상하게 들어가서 어떻게 하면 해결할 수 있을지 찾느라 시간이 많이 걸렸다.
원래 Relay나 Subject는 조금 알아도 Kingfisher는 전혀 몰라서, Kingfisher 먼저 하고 Relay를 활용한 리팩토링을 하려고 했는데, 반대로 Relay를 먼저 활용해보면서 RxSwift에 대해 더 익혀야 할 것 같다.
꼬부기랑 파이리 한마리씩 뽑아주세요