좌우로 무한적인 스크롤이 가능한 콜렉션 뷰를 만들어보자.
우선 콜렉션 뷰의 셀들을 나열할 실제 데이터 리스트가 11개라고 치자.
그럼 양 옆에 그 리스트 11개를 복제하여 양옆에 두면 첫번째 데이터에서 -1이 될때 데이터 리스트의 마지막으로 넘어가게 된다.
-1이 된 상태에서 애니메이팅을 false로 하여 실제 열번째 셀(11+11)으로 이동하게 하면 눈속임이 완성된다.
말로하면 어려우니 하단을 봐보자..
ex)
0 1 2 3 4 5 6 7 8 9 10 0 1 2 3 4 5 6 7 8 9 10 0 1 2 3 4 5 6 7 8 9 10
이런식의 데이터가 있으면 빨간색이 실체라 하고 다른색은 눈속임용이라 생각하자.
빨간색 0 에서 -1 이 되면 10으로 넘어가고 10에서 애니메이팅을 멈추면 빨간색 10으로 순간이동 시켜준다.
반대로
빨간색 10에서 +1 이 되면 0으로 넘어가고 0에서 애니메이팅을 멈추면 빨간색 0으로 순간이동 시켜준다.
이것을 잘 살려서 눈속임으로 만들어 보자.
만약에 스크롤링의 decelerate로 인해 양 끝에서 막힌다면 양 끝에 더더욱 많은 데이터들을 만들어 주면 되는 부분이다.
구현을 해보자.
import Foundation
import UIKit
import SnapKit
class InfiniteListView: UIView {
let headerLbl = UILabel()
let moreBtn = UIButton()
let collectionView: UICollectionView = {
// 커스텀 UICollectionViewFlowLayout
let flowLayout = HightlightListViewFlowLayout()
let cv = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
// 스크롤 감속 속도
cv.decelerationRate = .fast
cv.showsHorizontalScrollIndicator = false
return cv
}()
let leftBtn = UIButton()
let rightBtn = UIButton()
// let pageControl = UIPageControl()
var deviceSize: CGSize = .zero
// 가짜 데이터 배수 사이즈
var scrollTempSize = 10000
// 실제 현재 페이지
var currentPage: Int = 0
var dataList: [Data] = [Data()]
override init(frame: CGRect) {
super.init(frame: frame)
deviceSize = UIScreen.current?.bounds.size ?? .zero
viewConfigure()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
override func layoutSubviews() {
super.layoutSubviews()
cellLocationInit()
constraintConfigure()
}
private func cellLocationInit() {
// 양 옆에 복제된 가짜 데이터를 두고 가짜데이터를 포함한 리스트의 정가운데에 위치시킴
collectionView.scrollToItem(at: IndexPath(row: dataList.count * scrollTempSize/2, section: 0), at: .centeredHorizontally, animated: false)
currentPage = dataList.count * scrollTempSize/2
}
private func viewConfigure() {
headerLbl.text = "테스트"
headerLbl.textColor = .black
headerLbl.font = .systemFont(ofSize: 20, weight: .bold)
self.addSubview(headerLbl)
collectionView.backgroundColor = .clear
self.addSubview(collectionView)
collectionView.dataSource = self
collectionView.delegate = self
collectionView.contentInsetAdjustmentBehavior = .always
collectionView.register(ImgCollectionCell.self, forCellWithReuseIdentifier: ImgCollectionCell.reuseIdentifier)
leftBtn.setImage(UIImage(named: "icon_home_list_left_btn"), for: .normal)
leftBtn.addTarget(self, action: #selector(btnOnClick(_:)), for: .touchUpInside)
self.addSubview(leftBtn)
rightBtn.setImage(UIImage(named: "icon_home_list_right_btn"), for: .normal)
rightBtn.addTarget(self, action: #selector(btnOnClick(_:)), for: .touchUpInside)
self.addSubview(rightBtn)
// pageControl.isHidden = true
// pageControl.numberOfPages = colors.count
// pageControl.pageIndicatorTintColor = .red
// pageControl.addTarget(self, action: #selector(pageValueChanged(_:)), for: .valueChanged)
// pageControl.backgroundColor = .blue
// self.addSubview(pageControl)
}
private func constraintConfigure() {
headerLbl.snp.makeConstraints { make in
make.top.equalToSuperview()
make.leading.equalToSuperview().offset(20)
}
collectionView.snp.makeConstraints { make in
make.top.equalTo(headerLbl.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview()
make.height.equalTo(deviceSize.width)
make.bottom.equalToSuperview()
}
leftBtn.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.leading.equalToSuperview().offset(28)
make.width.height.equalTo(30)
}
rightBtn.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.trailing.equalToSuperview().offset(-28)
make.width.height.equalTo(30)
}
// pageControl.snp.makeConstraints { make in
// make.top.equalTo(collectionView.snp.bottom).offset(-16)
// make.centerX.equalToSuperview()
// }
}
func configureDataList(dataList: [Data]) {
if dataList.count > 0 {
self.dataList = dataList
DispatchQueue.main.async {
self.collectionView.reloadData()
}
}
}
@objc func btnOnClick(_ sender: UIButton) {
switch sender {
case leftBtn:
currentPage = currentPage - 1
log("currentPage1: \(self.currentPage)")
let preIndexPath = IndexPath(row: currentPage, section: 0)
DispatchQueue.main.async {
self.collectionView.scrollToItem(at: preIndexPath, at: .centeredHorizontally, animated: true)
}
break
case rightBtn:
currentPage = currentPage + 1
log("currentPage2: \(self.currentPage)")
let nextIndexPath = IndexPath(row: currentPage, section: 0)
DispatchQueue.main.async {
self.collectionView.scrollToItem(at: nextIndexPath, at: .centeredHorizontally, animated: true)
}
break
default:
break
}
}
func showOptionBtn() {
UIView.animate(
withDuration: 0.1,
delay: 0,
options: .curveEaseInOut,
animations: { [weak self] in
guard let self = self else { return }
self.leftBtn.alpha = 1.0
self.rightBtn.alpha = 1.0
},
completion: { [weak self] _ in
guard let self = self else { return }
self.leftBtn.isHidden = false
self.rightBtn.isHidden = false
})
}
func hideOptionBtn() {
UIView.animate(
withDuration: 0.1,
delay: 0,
options: .curveEaseInOut,
animations: { [weak self] in
guard let self = self else { return }
self.leftBtn.alpha = 0.0
self.rightBtn.alpha = 0.0
},
completion: { [weak self] _ in
guard let self = self else { return }
self.leftBtn.isHidden = true
self.rightBtn.isHidden = true
})
}
}
extension InfiniteListView: UICollectionViewDataSource {
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
// 무한 스크롤을 위한 왼쪽 오른쪽 배열을 더 만들어줌 (눈속임용)
return homeTerrianDataList.count * scrollTempSize
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeTerrianCollectionCell.reuseIdentifier, for: indexPath) as! HomeTerrianCollectionCell
cell.configureData(data: homeTerrianDataList[indexPath.item % homeTerrianDataList.count])
return cell
}
}
extension InfiniteListView: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
// 보여지는 셀들
// 왼쪽부터 0~2 인덱스
let visibleIndexPathList = collectionView.indexPathsForVisibleItems.sorted()
// 보여지는 셀 개수가 3개일경우만 클릭 모션 활성화
// 2개인 경우는 이미지 스와이프를 절반만 해논상태에서 클릭할 경우임.
if visibleIndexPathList.count == 3 {
// 클릭한 셀이
switch indexPath {
// 왼쪽 혹은 오른쪽 일 경우
case visibleIndexPathList[0], visibleIndexPathList[2]:
// 처음 혹은 마지막 부분일 경우 0.3초 이후에 다시 가운데 배열로 재배열 하도록 함. (눈속임)
self.collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
break
// 가운데(현재 셀) 일 경우
case visibleIndexPathList[1]:
// 현재 셀 클릭 액션 취하기
…..
break
default:
break
}
} else {
// 스와이핑 후 애니메이션 동작을 마무리 하도록 만들어줌.
self.collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
}
}
// 스크롤 중일 때 호출
func scrollViewDidScroll(_ scrollView: UIScrollView) {
// 보여지는 스크롤 뷰의 정 가운데의 x,y 값
let xPoint = scrollView.contentOffset.x + scrollView.frame.width / 2
let yPoint = scrollView.frame.height / 2
let center = CGPoint(x: xPoint, y: yPoint)
// 정 가운데 셀의 인덱스
if let indexPath = collectionView.indexPathForItem(at: center) {
// 페이지컨트롤 지정
// self.pageControl.currentPage = ip.row % colors.count
self.currentPage = indexPath.row
}
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
self.hideOptionBtn()
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
self.showOptionBtn()
}
// 스크롤 끝마친 후 호출
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
// 보여지는 스크롤 뷰의 정 가운데의 x,y 값
let xPoint = scrollView.contentOffset.x + scrollView.frame.width / 2
let yPoint = scrollView.frame.height / 2
let center = CGPoint(x: xPoint, y: yPoint)
// 정 가운데 셀의 인덱스
if let row = collectionView.indexPathForItem(at: center)?.row {
// 가운데 배열의 인덱스로 다시 눈속임으로 스크롤링하게끔 함 (무한스크롤을 위함)
collectionView.scrollToItem(at: IndexPath(item: row, section: 0), at: .centeredHorizontally, animated: false)
self.currentPage = row
}
}
}
HightlightListViewFlowLayout 의 경우는 해당 설명은 나의 다른 글에 있다. 최 하단 링크를 참고해보자.
import Foundation
import UIKit
class HightlightListViewFlowLayout: UICollectionViewFlowLayout {
let activeDistance: CGFloat = 0
// 하이라이트 된 셀 크기 확대 비율
let zoomFactor: CGFloat = 0.0
var deviceSize: CGSize = .zero
override init() {
super.init()
deviceSize = UIScreen.current?.bounds.size ?? .zero
// 가로 방향 스크롤링
scrollDirection = .horizontal
// 셀 간의 간격
minimumLineSpacing = 10
// 셀 사이즈
itemSize = CGSize(width: deviceSize.width - 40, height: deviceSize.width - 40)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// CollectionView가 해당 콘텐츠를 처음으로 표시할 때와 view가 변경되어 레이아웃이 무효화 될때 발생
// 콜렉션뷰의 사이즈 및 item의 위치 등을 결정하기 위한 초기 계산 등을 수행
// 즉 초기에 collectionview의 레이아웃을 설정할 때 호출
override func prepare() {
guard let collectionView = collectionView else { fatalError() }
let verticalInsets = (collectionView.frame.height - collectionView.adjustedContentInset.top - collectionView.adjustedContentInset.bottom - itemSize.height) / 2
let horizontalInsets = (collectionView.frame.width - collectionView.adjustedContentInset.right - collectionView.adjustedContentInset.left - itemSize.width) / 2
// 각 라인별 cell의 수를 제한 할 수 있도록 inset을 지정해줌
sectionInset = UIEdgeInsets(top: verticalInsets, left: horizontalInsets, bottom: verticalInsets, right: horizontalInsets)
super.prepare()
}
// CollectionView안의 모든 요소에 대한 Layout요소들을 리턴함.
// 현재 기준 보여지는 요소들만 해당함.
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let collectionView = collectionView else { return nil }
let rectAttributes = super.layoutAttributesForElements(in: rect)!.map { $0.copy() as! UICollectionViewLayoutAttributes }
let visibleRect = CGRect(origin: collectionView.contentOffset, size: collectionView.frame.size)
// Make the cells be zoomed when they reach the center of the screen
for attributes in rectAttributes where attributes.frame.intersects(visibleRect) {
let distance = visibleRect.midX - attributes.center.x
let normalizedDistance = distance / activeDistance
if distance.magnitude < activeDistance {
let zoom = 1 + zoomFactor * (1 - normalizedDistance.magnitude)
attributes.transform3D = CATransform3DMakeScale(zoom, zoom, 1)
attributes.zIndex = Int(zoom.rounded())
}
}
return rectAttributes
}
// 스크롤 시 스크롤이 중지되는 지점을 변경 할 수 있는 메서드
// proposedContentOffset: 스크롤이 자연스럽게 중지되는 값, velocity: 스크롤 속도
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = collectionView else { return .zero }
// Add some snapping behaviour so that the zoomed cell is always centered
let targetRect = CGRect(x: proposedContentOffset.x, y: 0, width: collectionView.frame.width, height: collectionView.frame.height)
guard let rectAttributes = super.layoutAttributesForElements(in: targetRect) else { return .zero }
var offsetAdjustment = CGFloat.greatestFiniteMagnitude
let horizontalCenter = proposedContentOffset.x + collectionView.frame.width / 2
for layoutAttributes in rectAttributes {
let itemHorizontalCenter = layoutAttributes.center.x
if (itemHorizontalCenter - horizontalCenter).magnitude < offsetAdjustment.magnitude {
offsetAdjustment = itemHorizontalCenter - horizontalCenter
}
}
return CGPoint(x: proposedContentOffset.x + offsetAdjustment, y: proposedContentOffset.y)
}
// Bounds에 변화가 있을때 마다, 함수를 호출할 지 결정함. -> InvalidateLayout(with:)을 호출.
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
// Invalidate layout so that every cell get a chance to be zoomed when it reaches the center of the screen
return true
}
override func invalidationContext(forBoundsChange newBounds: CGRect) -> UICollectionViewLayoutInvalidationContext {
let context = super.invalidationContext(forBoundsChange: newBounds) as! UICollectionViewFlowLayoutInvalidationContext
// invalidate된 셀이 크기를 재설정 해야할경우 No가 default, Yes일 경우 다시 계산
context.invalidateFlowLayoutDelegateMetrics = newBounds.size != collectionView?.bounds.size
return context
}
}
https://seonghooony.tistory.com/40
[Swift] 하이라이트 슬라이드 뷰 만들기 (UICollectionView)
스크롤 시 가운데 셀이 하이라이트 되고 양 옆 셀을 클릭 시 스크롤 되는 커스텀 콜렉션 뷰를 만들었다. flowLayout을 커스텀하는데 있어 이해하는 시간이 좀 필요했다. 3D적으로도 생각하다보니 더
seonghooony.tistory.com
'Swift' 카테고리의 다른 글
[Swift] 의존성 주입, DIContainer(IOC Container)만들기 (0) | 2024.02.15 |
---|---|
[Swift] WKWebView와 Javascript 사이 통신을 만들어보자 (0) | 2024.01.22 |
[Swift] 상단 탭바 페이지 뷰컨트롤러 만들기 (Upper Tab Page View) (0) | 2024.01.02 |
[Swift] UICollectionView 내부 내용에 따른 Cell 동적 높이 설정법 (0) | 2023.11.10 |
[Swift] NavigationController와 TabBarContoller 무엇으로 감쌀지? (0) | 2023.11.02 |