[iOS] Compositional Layout의 visibleitemsinvalidationhandler 활용
15 min readOct 30, 2023
- 이번에 프로젝트를 진행하면서 Compositional Layout의 visibleitemsinvalidationhandler를 여러방면에서 많이 사용해봤다.
- 이전 글인 Compositional에서 PageControl 구현에서도 사용했지만, 자세하게 알고쓰진 않았다. 한번 깊게 알아보고 싶었다.
- visibleitemsinvalidationhandler는 Compositional Layout의 현재 화면에 표시된 아이템을 감지하고 관리할 수 있게 도와주는 API이다.
visibleitemsinvalidationhandler
public typealias NSCollectionLayoutSectionVisibleItemsInvalidationHandler =
([NSCollectionLayoutVisibleItem], CGPoint, NSCollectionLayoutEnvironment) -> Void
open var visibleItemsInvalidationHandler: NSCollectionLayoutSectionVisibleItemsInvalidationHandler?
- visibleitemsinvalidationhandler를 찾아 들어가보면 NSCollectionLayoutSectionVisibleItemsInvalidationHandler라는 타입을 채택하고 있다.
- NSCollectionLayoutSectionVisibleItemsInvalidationHandler는 3개의 타입을 리턴하고 있다. 차례대로 봐보면 다음과 같다.
- [NSCollectionLayoutVisibleItem]
@MainActor
protocol NSCollectionLayoutVisibleItem
- 현재 섹션 화면에 표시된 아이템들을 보여주는 친구다. 배열로 되어있는거 보니, 현재 화면에 표시된 아이템들의 indexPath등을 활용 할 수 있다.
2. CGPoint → contentOffset
section.visibleItemsInvalidationHandler = { (visibleItems, **contentOffset**, env) in
print(**\\(contentOffset.x)", "\\(contentOffset.y)")**
- CollectionView의 스크롤 Offset를 제공한다.
- 만약 Horizontal Scroll 을한다면. contentOffset.x의 값은 0에서 점차 커지는 걸 알 수 있다.
3. NSCollectionLayoutEnvironment
@MainActor
protocol NSCollectionLayoutEnvironment
// visibleItemsInvalidationHandler
section.visibleItemsInvalidationHandler = { (visibleItems, contentOffset, **env**) in
print(env.container.contentSize.width)
print(env.container.contentSize.height)
- 레이아웃의 컨테이너 및 환경 특성에 대한 정보를 제공하는 데 사용되는 프로토콜이다.
- visibleItemsInvalidationHandler에서 쓰이는 NSCollectionLayoutEnvironment는 현재 컬렉션뷰의 환경 정보를 제공한다.
container
와traitCollection
를 제공하는데, 나는 주로container
를 개발할 때 많이 활용했다.- container.contentSize을 제공하기 때문에 현재 보여지는 콜렉션뷰의 width, height을 제공한다.
- offset.x은 오른쪽으로 스크롤 할 때마다 스크롤 offset값이 늘어나는 걸 볼 수있다.
- env.container.contentSize.width는 현재 보여지는 콜렉션뷰의 width값을 찍어내는 걸 볼 수 있다.
CollectionView 페이징 시 마커 이동 구현 (visibleItems indexPath 활용)
- 하단의 CollectionView를 스크롤 시 해당 Cell의 indexPath와 동일한 마커의 indexPath로 카메라가 이동되고, 더불어 마커가 선택되도록 구현해야 했다.
- 하단의 가게 정보 CollectionView는 Compositional로 구성했기 때문에 visibleitemsinvalidationhandler를 활용 할 수 있었다.
section.visibleItemsInvalidationHandler = { [weak self] (visibleItems, offset, env) in
visibleItems.forEach { item in
if let currentIndex = visibleItems.last?.indexPath.row {
self?.visibleItemsRelay.accept(currentIndex) // subject에 해당 값을 바인딩
}
}
- visibleItems 인자는 위에서 설명하듯, 현재 섹션 화면에 표시된 아이템들을 배열로 담아둔 친구다.
- visibleitemsinvalidationhandler는 이벤트가 발생할 때 마다 값을 업데이트해서 방출하는데,
visibleItems.last?.indexPath.row
를 활용하면 스크롤 될 때 마다 현재 보여지는 아이템의 indexPath 값을 방출한다. - 스크롤될 때 마다 화면에 보여지는 indexPath 값을 Subject에 담아서 viewModel의 input으로 활용할 수 있었다.
// VC
override func bind() {
let input = LocationViewModel
.Input(viewDidLoadEvent: Observable.just(()).asObservable(),
...
...
didScrollStoreCollectionView: visibleItemsRelay.asObservable().debounce(.milliseconds(250), scheduler: MainScheduler.instance))
- 위와 같이 viewModel의
didScrollStoreCollectionView
input에 스크롤 시 현재 보여지는 셀의 index 값을 가지고 있는 visibleItemsRelay를 초기화 해준다.
// VM
input.didScrollStoreCollectionView
.distinctUntilChanged()
.withUnretained(self)
.bind(onNext: { owner, percentVisible in
guard let index = percentVisible else { return }
let store = owner.storeList[index] // 셀 스크롤 시 index를 통해 현재 콜렉션뷰에 선택된 store
output.setCameraPosition.accept((store.y, store.x))
output.selectedMarkerIndex.accept(index)
})
.disposed(by: disposeBag)
- viewModel에서는 해당 스크롤 시 index 값을 토대로 mapView의 카메라 위치와 마커의 선택이 변경되도록 구현했다.
- viewModel의 storeList 배열에 셀 스크롤 시 index를 통해 현재 콜렉션뷰에 선택된 store을 찾을 수 있었다.
- 해당 store 정보를 output의 setCameraPosition (카메라 이동 output)과 selectedMarkerIndex (선택된 마커 index를 방출하여, 마커를 이동 시키는 output)에 accept 시켜줄 수 있었다.
페이징 시 셀 크기가 줄어들었다 늘어나는 Carousel view 만들기
- 그냥 페이징을 시키면 뭔가 다채로워보이지 않았다. 위처럼 양 옆으로 다음 셀이 조금 보이면서(페이징이 가능하다는 디자인 인식을 준다.), 페이징 시 현재 보여지는 셀의 크기는 커지고 보여지지 않는 셀의 크기는 점차 줄어드는 효과를 만들고 싶었다.
- 이러한 효과도 visibleitemsinvalidationhandler를 활용할 수 있었다.
private func createEpisodeLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
// groupSize widthDimension 조정
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.8), heightDimension: .fractionalHeight(1.0))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
- 먼저 해당 섹션의 groupSize의 width를 조정해야 한다.
- groupSize의 widthDimension를 .fractionalWidth(0.8)로 설정한 걸 볼 수 있는데, 그 이유는 만약 1.0으로 설정 시 셀이 collectionView의 width와 크기가 같게된다.
- 그렇게 되면 현재 셀의 양옆으로 살짝식 보여지는 다음 셀이 보여지지 않기 때문이다. (위의 영상 참고)
section.orthogonalScrollingBehavior = .groupPagingCentered // groupPaging
- 두번째는 orthogonalScrollingBehavior 설정이다. 페이징 시 정확히 중앙으로 오도록
groupPagingCentered
를 설정했다.
section.visibleItemsInvalidationHandler = { (visibleItems, offset, env) in
visibleItems.forEach { item in
// 1
let intersectedRect = item.frame.intersection(CGRect(x: offset.x, y: offset.y, width: env.container.contentSize.width, height: item.frame.height))
// 2
let percentVisible = intersectedRect.width / item.frame.width
// 3
let scale = 0.7 + (0.3 * percentVisible)
// 4
item.transform = CGAffineTransform(scaleX: 0.98, y: scale)
}
}
- visibleItemsInvalidationHandler에서 섹션을 스크롤할 때 마다 효과를 주도록 했다.
intersectedRect
는 아이템과 스크롤된 화면 사이의 교차하는 영역을 계산percentVisible
는 아이템이 화면에서 얼마나 보이는지를 나타내는 비율을 계산intersectedRect.width
는 아이템과 화면사이의 교차하는 영역의 너비이고,item.frame.width
는 현재 아이템의 전체 너비다.scale
은 아이템의 스케일을 계산한다. 스케일은 0.7로 시작하고percentVisible
값에 따라 증가하는데,percentVisible
가 1.0인 경우(아이템이 완전히 보일 때) 스케일은 1.0이 된다. 그리고percentVisible
가 작을수록 스케일이 작아지며 아이템이 더 작아진다. 즉, 스케일이 0.7에서 시작하여 최대 0.3만큼 더 증가하도록 한다.- 최종적으로
item.transform
속성을 사용하여 아이템이 커지고 줄어드는 효과를 적용한다. Y 축 방향으로scale
값을 적용하여 아이템의 세로 크기를 동적으로 조절한다.
Carousel 효과와 visibleItems indexPath를 같이 쓰면서 생긴 이슈
- 맵뷰에서도 스크롤 시 스크롤 방향으로 다음 셀이 보이며, 셀 크기가 줄어들었다 늘어나는 Carousel 효과를 적용하고 싶었다. 하지만 문제가 indexPath에 문제가 생겼다.
let currentIndex = visibleItems.last?.indexPath.row
- 위에서 설명했듯이, 스크롤 될 때 마다 현재 보여지는 아이템의 indexPath.row값을 페이징 시 넘겨주고 있었는데, 위의 사진에서 보듯 처음 화면에 보여지는 셀의 갯수는 2개이다.
- 스크롤이 가능하다는 디자인을 위해 두번쨰 셀도 살짝 보이도록 구현해놨는데 이렇게 구현 시 visibleItems은 2개가 된다.
let currentIndex = visibleItems.last?.indexPath.row
즉, 해당 코드가 2번째 보여지는 셀부터 시작하게되어 최종적으로 마지막 셀이 스크롤될 때 인덱스가 1 부족한 상태가 되어 버린다.- 문제를 해결하기 위해 현재 보여지는 셀의 scale이 가장 클 때 index를 넘기기 위한 노력을 해봤지만, 잘 구현이 되지 않았다 ㅠ
- 이러한 UI를 가진 다른 레포를 찾아봤는데, visibleItems을 활용하기 보단 UIScrollViewDelegate의 scrollViewWillBeginDecelerating나 scrollViewWillEndDragging를 활용하는 거 같았다. (이러한 문제가 있어서 그런가..)
- 그래서 최종 결과물은 다음과 같다. 화면에 셀이 한개씩 보여지는 디자인이 뭔가 불편하긴 하지만, 스크롤이 가능하다는 디자인을 추가로 넣어서 해결해야겠다. 🥲
section.visibleItemsInvalidationHandler = { [weak self] (visibleItems, offset, env) in
visibleItems.forEach { item in
let intersectedRect = item.frame.intersection(CGRect(x: offset.x, y: offset.y, width: env.container.contentSize.width, height: item.frame.height))
let percentVisible = intersectedRect.width / item.frame.width
if percentVisible >= 1.0 {
if let currentIndex = visibleItems.last?.indexPath.row {
self?.visibleItemsRelay.accept(currentIndex)
}
}
let scale = 0.5 + (0.5 * percentVisible)
item.transform = CGAffineTransform(scaleX: 0.98, y: scale)
}
}
- 페이징 시 현재 보여지는 셀의 크기는 커지고 보여지지 않는 셀의 크기는 점차 줄어드는 애니메이션을 설정하긴 했으나,
- groupSize의 fractionalWidth를 1.0으로 설정해서 화면에 셀 한개만 보이도록 설정했다.