티스토리 뷰
안녕하세요 Diana입니다.
오늘은 앨범 앱을 구현하며 수 많은 이미지를 한꺼번에 받아오는건 비효율적이라고 생각하여 무한스크롤 이라고 불리는 뷰를 구현해보려고 합니다.
시작하겠습니다.
✅ 무한스크롤 구현하기
우선 무한스크롤을 구현하기 앞서 비슷한 기능이 있는 앱들을 살펴보았습니다.
Snow, Foodie 그리고 다른 여러 앱들을 확인해본 결과 많은 이미지와 데이터들을 처리하는 앱의 경우 특정 개수의 이미지를 우선 보여준 뒤 스크롤하였을 때 추가적으로 이미지를 불러오도록 구현되어있었습니다.
추가적으로 데이터 로드 속도가 스크롤 속도를 따라오지 못하는 경우 Cell에서 기본 이미지(보통 회색)를 보여준 뒤 데이터가 로드 된 이후 Cell을 채워넣는 방식으로 구현되어 있었는데 이부분은 저는 서버에서 이미지를 받아오는게 아니기 때문에 생략하도록 하겠습니다.
우선 CollectionView에서는 CollectionView가 스크롤 되었을 때를 감지하는 여러 함수가 존재합니다.
그 중 이미지를 계속 추가해 나가기 위해서 저는 scrollViewDidLoad를 사용해보려고 합니다.
여기서 저희는 ScrollView의 ContentOffSet이라는 개념을 알아야 합니다.
ContentOffset은 ScrollView Bound의 좌측 상단 Point를 이야기 합니다.
우리가 스크롤을 하게 되면 CollectionView의 ViewPort가 변경되면서 ContentOffset이 변경되고 우리는 이 ContentOffSet의 Y 좌표를 감지하여 원하는 시점까지 스크롤 된 경우 다시 이미지 fetch 함수를 호출하여 이미지 호출 부하를 줄일 것입니다.
구현한 scrollViewDidLoad 함수는 아래와 같습니다.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let offsetY = scrollView.contentOffset.y
let contentHeight = collectionView.contentSize.height
let frameHeight = scrollView.frame.size.height
// 스크롤이 하단 근처인 경우 앨범 load 재호출
if offsetY > contentHeight - frameHeight * 1.2 {
viewModel.loadAlbum(mediaType: .image, targetSize: CGSize(width: 200, height: 200))
}
}
우선 그렇기 위해서는 fetchAlbumContents 함수를 변경해줄 필요가 있습니다.
func fetchAlbumContents(mediaType: PHAssetMediaType,
targetSize: CGSize,
offset: Int,
limit: Int) -> Observable<[AlbumInfo]> {
return Observable.create { observer in
DispatchQueue.global().async {
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
options.fetchLimit = offset + limit // 가져올 이미지 개수 설정
let fetchResult = PHAsset.fetchAssets(with: mediaType, options: options)
// 가져올 이미지 범위 설정
let startIndex = min(offset, fetchResult.count)
let endIndex = min(offset + limit, fetchResult.count)
var albumInfos: [AlbumInfo] = []
let imageOptions = PHImageRequestOptions()
imageOptions.isSynchronous = true
imageOptions.deliveryMode = .highQualityFormat
let group = DispatchGroup()
if startIndex < endIndex { // enumerateObjects 제거
for i in startIndex..<endIndex {
let asset = fetchResult.object(at: i)
group.enter()
self.imageManager.requestImage(for: asset,
targetSize: targetSize,
contentMode: .aspectFill,
options: imageOptions) { image, _ in
albumInfos.append(AlbumInfo(asset: asset,
thumbnail: image,
creationDate: asset.creationDate,
mediaType: asset.mediaType))
group.leave()
}
}
}
group.notify(queue: .main) {
observer.onNext(albumInfos)
observer.onCompleted()
}
}
return Disposables.create()
}
}
현재는 모든 앨범 사진을 받아오고 있기 때문에 PHFetchOptions에 fetchLimit을 사용하고 있지 않지만 이제는 초기 이미지 개수를 설정해줄 수 있도록 해당 속성을 사용해줍니다.
그리고 enumeratedObjects로 전체 for문을 돌리던 반복문을 제거하고 startIndex와 endIndex를 사용하여 requestImage를 할 범위를 설정해줍니다.
이렇게 하면 처음에는 1~99 까지의 이미지를 받아왔다면 그 다음엔 100~ 199 .. 의 순서대로 순차적으로 데이터를 가지고 올 수 있게 됩니다.
그리고 이렇게 추가된 offset과 limit은 viewModel에서 관리하며 수정된 viewModel 코드는 아래와 같습니다.
final class MainViewModel {
...
// 추가된 파라미터
private var currentOffset = 0
private let pageSize = 50
private var isLoading = false
...
func loadAlbum(mediaType: PHAssetMediaType, targetSize: CGSize) {
guard !isLoading else { return }
isLoading = true
albumFetcher.fetchAlbumContents(mediaType: mediaType, targetSize: targetSize, offset: currentOffset, limit: pageSize)
.subscribe(onNext: { [weak self] newItems in
guard let self = self else { return }
if !newItems.isEmpty {
self.albumContents.accept(newItems)
self.currentOffset += newItems.count
}
self.isLoading = false
}, onError: { [weak self] _ in
self?.isLoading = false
})
.disposed(by: disposeBag)
}
...
}
이렇게 ViewModel까지 수정한 뒤 MainViewController의 albumContents도 변경해줄 필요가 있습니다.
이전에는 한번에 이미지를 받아오고 끝이였기 때문에 albumInfos에 이미지를 넣어주고 끝이였지만 이제는 계속해서 이미지를 추가해줄 것이므로 append를 사용하여 사진들을 추가해줍니다.
viewModel.albumContents
.bind(onNext: { [weak self] newItems in
guard let self = self else { return }
self.albumInfos.append(contentsOf: newItems) // append를 통한 사진 추가
self.collectionView.reloadData()
})
.disposed(by: disposeBag)
이렇게 구현된 코드를 실행시켜보면 앱의 로딩 속도가 확연히 줄고 정상적으로 작동하는 것을 확인할 수 있습니다.


또한 CPU와 에너지 사용량을 확인해보면 스크롤하여 이미지를 받아올때만 사용량이 잠깐 늘어날 뿐 상당히 안정적이게 된 모습을 확인 할 수 있습니다.
여기서 더 자연스럽게 하기 위해 기능을 추가하려면 계속해서 추가할 수 있지만 앨범을 구현하기 위해서는 아직 많은 기능들이 남아있으므로 CollectionView관련 구현은 여기까지 하고 다음으로 넘어가보겠습니다.
'Swift' 카테고리의 다른 글
| 앨범 만들기 4편 - Transitioning Delegate 를 통한 화면전환 애니메이션 (1) | 2025.11.07 |
|---|---|
| 앨범 만들기 2편 - PHCachImageManager의 requestImage 알아보기 (0) | 2025.11.04 |
| 앨범 만들기 1편 - 앨범에 접근하여 사진 가져오기 (0) | 2025.11.03 |
| Swift - Opaque Type 이란? (1) | 2024.10.17 |
| Swift - private(set) 이란? (0) | 2024.09.19 |
- Total
- Today
- Yesterday
- 개발자코테
- 팁킷
- Algorithm
- 알고리즘
- private(set)
- boxedprotocoltype
- iphone
- opaquetype
- opaque
- boxedprotocol
- tipkit
- SwiftUI
- coredata
- Tuist
- 코어데이터
- tuist v4
- ios
- 스위프트
- tipview
- ViewBuilder
- capsulation
- kakaomapssdk
- 빅세타표기법
- swift
- PHCachImageManager
- Concurrency
- BoxedType
- reusablelist
- Xcode
- asymptoticnotation
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 |