UIKit, Combine으로 리액티브하게 무한 스크롤 배너 구현하기

peppermint100
PEPPERMINT100
Published in
14 min readAug 24, 2024

대 다수의 앱의 홈화면 상단에 있는 무한 스크롤 배너를 구현해보려고 한다.

이번 포스팅의 기능 구현에서는 Storyboard로 레이아웃을 잡고, 따로 서드파티 라이브러리 없이 데이터 바인딩을 위해 Combine을 사용할 예정이다.

설계

일단 유명한 앱인 크림에 배너를 가져오자면 대강 이런식이다. 나는 우측 하단에 페이지가 넘어 갈 때마다 1/3, 2/3, 3/3 이런 표시를 해줄 예정이다.

코드를 작성하기 이전에 간단히 설계를 해보자면

ViewController
- collectionView
- bannerIndexLabel
ViewModel
- @Published bannerIndex
View
- BannerCollectionViewCell

이 정도가 필요할 것으로 예상된다. VC 안에 CollectionView로 배너를 옆으로 넘기도록 구현하고, 배너가 넘어가는 건 ViewModel의 bannerIndex를 바인딩해서 최대한 단방향 플로우로 만들 예정이다.

UI

먼저 컬렉션뷰 하나를 만들어준다. 그리고 isPagingEnabled를 활성화시켜주고, ViewController에 IBOutlet으로 프로퍼티를 생성해준다.

class ViewController: UIViewController {

@IBOutlet var bannerCollectionView: UICollectionView!

override func viewDidLoad() {
super.viewDidLoad()
setupCollectionView()
}
}

extension ViewController {

private func setupCollectionView() {
bannerCollectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "default")
bannerCollectionView.delegate = self
bannerCollectionView.dataSource = self
bannerCollectionView.showsHorizontalScrollIndicator = false
let layout = createLayout()
bannerCollectionView.collectionViewLayout = layout
}

private func createLayout() -> UICollectionViewFlowLayout {
let layout = UICollectionViewFlowLayout()
layout.minimumLineSpacing = 0
layout.minimumInteritemSpacing = 0
layout.scrollDirection = .horizontal
layout.itemSize = CGSize(width: bannerCollectionView.frame.width, height: 150)
return layout
}
}

extension ViewController: UICollectionViewDelegate {
}

extension ViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return 3
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "default", for: indexPath)
let colors: [UIColor] = [.red, .blue, .green, .brown, .gray]
cell.backgroundColor = colors.randomElement()
return cell
}
}

컬렉션 뷰를 세팅해주고, dataSource, delegate를 연결해준 후 flowLayout으로 가로 스크롤도 세팅해준다.

그리고 cell의 itemSize를 컬렉션뷰를 꽉 채우게 해준다.

컬렉션 뷰의 세팅이 끝났으면 이제 커스텀 셀을 만들어서 셀에 연결해준다.

커스텀 셀 안에는 UIImageView 하나를 놓고 Contraint를 Cell에 꽉 차게 설정해준다.

import UIKit

class BannerCollectionViewCell: UICollectionViewCell {

static let identifier = String(describing: BannerCollectionViewCell.self)

@IBOutlet var bannerImage: UIImageView!

override func awakeFromNib() {
super.awakeFromNib()
}
}

extension BannerCollectionViewCell {
func configure(with image: UIImage?) {
bannerImage.image = image
}
}

배너 셀 관련 코드도 간단하게 작성해준다. Cell에 이미지를 세팅할 수 있도록 해준다.

private func setupCollectionView() {
let nib = UINib(nibName: BannerCollectionViewCell.identifier, bundle: nil)
bannerCollectionView.register(nib, forCellWithReuseIdentifier: BannerCollectionViewCell.identifier)
bannerCollectionView.delegate = self
bannerCollectionView.dataSource = self
bannerCollectionView.showsHorizontalScrollIndicator = false
let layout = createLayout()
bannerCollectionView.collectionViewLayout = layout
}

그리고 nib 파일로부터 컬렉션뷰 셀을 등록하도록 코드를 수정해준다.

또 셀에 들어갈 이미지를 세팅하기 위해 ViewModel을 만들어준다.

import Foundation
import Combine

final class ViewModel {

var imageNames: [String] = []
@Published var bannerIndex = 0

// 여기서 배너 정보를 가져오는 코드를 작성.
// 이 글에서는 간단하게 작성
func fetchImageNames() {
imageNames = ["Image1", "Image2", "Image3"]
}
}
extension ViewController: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return vm.imageNames.count
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: BannerCollectionViewCell.identifier, for: indexPath) as? BannerCollectionViewCell else {
return UICollectionViewCell()
}
let image = UIImage(named: vm.imageNames[indexPath.row])
cell.configure(with: image)
return cell
}
}

컬렉션뷰의 DataSource도 위 뷰모델에 맞게 작성해준다.

@IBOutlet var bannerIndexLabel: UILabel!

private func setupLabel() {
bannerIndexLabel.layer.cornerRadius = 10
bannerIndexLabel.clipsToBounds = true
}

label도 추가해서 백그라운드 컬러, 텍스트 컬러를 주고 모서리를 둥글게 만들어서 배너 위치를 가리킬 수 있도록 한 후 컬렉션 뷰의 trailing, bottom에 제약조건을 걸어준다.

UI는 이정도면 됐고, 이제 남은 작업은 스크롤 할 때 bannerIndexLabel이 1/3 -> 2/3 -> 3/3 이 순서로 변하게 하고, 3/3에서 우측으로 스크롤 하면 1/3이 되도록, 1/3에서 좌측으로 스크롤하면 3/3이 되도록 무한 스크롤 기능을 구현하면 된다.

배너 텍스트

제목에 거창하게 리액티브라는 단어를 사용했으므로 이제 Combine을 좀 사용해보자.

extension ViewController: UICollectionViewDelegate {

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let currentIndex = Int(scrollView.contentOffset.x / bannerCollectionView.frame.width)
vm.bannerIndex = currentIndex
}
}

이 코드가 가장 키가 되는 부분이다.

CollectionViewDelegate에 scrollViewDidEndDecelerating 함수로 현재 배너 인덱스를 계산해준다. 현재 컬렉션뷰의 x offset과 컬렉션뷰 넓이를 확인해서 현재 인덱스를 가져올 수 있다. bannerIndex를 여기서 수정해준다.

처음에 말했던 vm.bannerIndex를 scrollViewDelegate에서만 조정하여 단방향 플로우를 유지해서 이미지 배열을 조정하는 곳이 이 곳 밖에 없도록 해야 한다. 만약 이곳 저곳에서 bannerIndex를 건드리면 사이드 이펙트가 생길 위험이 높다.

  private func bind() {
vm.bannerIndexPublisher.sink { [weak self] bannerIndex in
self?.bannerIndexLabel.text = "\(bannerIndex + 1)/\(self?.vm.imageNames.count ?? 0)"
}
.store(in: &cancelBag)
}

바인딩을 통해서 bannerLabel을 업데이트 해줄 수 있도록 하면 된다.

무한 스크롤

이제 첫 번째 배너에서 우측으로 가면 마지막 배너로, 마지막 배너에서 좌측으로 가면 첫 번째 배너로 돌아가도록 코드를 수정해보자.

무한 스크롤을 구현하는 전략은 아래와 같다.

  • 가져온 이미지가 3장이라고 하자 -> [1, 2, 3]
  • 가져온 이미지 앞 뒤로 마지막 이미지와 처음 이미지를 넣어서 새로운 이미지 배열을 만들어 준다. [3, 1, 2, 3, 1]
  • 컬렉션뷰셀의 마지막이나 처음 셀에 도달할 때 마지막이면 처음 셀로, 처음 셀이면 마지막 셀로 사용자를 이동시킨다(티 안나게)

이렇게 하면, 감쪽같이 무한 스크롤을 구현할 수 있다.

여기서는 새로운 프로퍼티가 필요한데, visibleBannerIndex다. 이제 이미지 배열이 원래보다 늘어날 것이므로 유저에게는 가짜 배너 인덱스를 보여주어야 한다.

final class ViewModel {

private var cancelBag = Set<AnyCancellable>()
@Published var imageNames: [String] = []
@Published var visibleImageNames: [String] = []
@Published var bannerIndex = 0
var bannerIndexPublisher: Published<Int>.Publisher { $bannerIndex }

func fetchImageNames() {
let fetchedImages = ["Image1", "Image2", "Image3"]
if fetchedImages.count > 1 {
imageNames = [fetchedImages.last!] + fetchedImages + [fetchedImages.first!]
} else {
imageNames = fetchedImages
}
visibleImageNames = fetchedImages
}
}

뷰모델을 다음과 같이 수정하여, 가짜 이미지 배열을 만들어준다.

private func bind() {
vm.bannerIndexPublisher.sink { [weak self] bannerIndex in
guard let self = self else { return }
var visibleBannerIndex = 0
if bannerIndex == 0 {
visibleBannerIndex = vm.visibleImageNames.count
} else if bannerIndex == vm.imageNames.count - 1 {
visibleBannerIndex = 1
} else{
visibleBannerIndex = bannerIndex
}
self.bannerIndexLabel.text = "\(visibleBannerIndex)/\(self.vm.visibleImageNames.count)"
self.bannerCollectionView.scrollToItem(at: IndexPath(row: visibleBannerIndex, section: 0), at: .centeredHorizontally, animated: false)
}
.store(in: &cancelBag)
}

이렇게 배너 인덱스를 구독하여 배너 인덱스 레이블, visibleBannerIndex, 스크롤 위치를 조정해준다. 이 부분이 중요하다.

참고로 scrollToItem의 animated 속성을 false로 줘야 스크롤을 유저 몰래 이동시킬 수 있다.

override func viewDidLoad() {
super.viewDidLoad()
setupCollectionView()
setupLabel()
vm.fetchImageNames()
bind()
vm.bannerIndex = 1
}

viewDidLoad에서 최초 배너를 두 번째(index 1)로 바꿔주는 것도 잊지 말자.

이렇게 하면 무한 스크롤이 구현된다.

전체 코드는 여기에서 확인할 수 있다.

https://github.com/peppermint100/uikit-walkthrough/tree/master/AutuScrollingBanner

--

--

peppermint100
PEPPERMINT100

기억하기 위해 또는 잊어버리기 위해 작성하는 블로그입니다.