[iOS] Compositional Layout의 visibleitemsinvalidationhandler 활용

kyuchulkim
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개의 타입을 리턴하고 있다. 차례대로 봐보면 다음과 같다.
  1. [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을 제공한다.
contentOffset, env.container
  • offset.x은 오른쪽으로 스크롤 할 때마다 스크롤 offset값이 늘어나는 걸 볼 수있다.
  • env.container.contentSize.width는 현재 보여지는 콜렉션뷰의 width값을 찍어내는 걸 볼 수 있다.

CollectionView 페이징 시 마커 이동 구현 (visibleItems indexPath 활용)

CollectionView 스크롤 시 맵의 마커도 동일한 데이터로 이동
  • 하단의 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에서 섹션을 스크롤할 때 마다 효과를 주도록 했다.
  1. intersectedRect는 아이템과 스크롤된 화면 사이의 교차하는 영역을 계산
  2. percentVisible는 아이템이 화면에서 얼마나 보이는지를 나타내는 비율을 계산 intersectedRect.width는 아이템과 화면사이의 교차하는 영역의 너비이고, item.frame.width는 현재 아이템의 전체 너비다.
  3. scale 은 아이템의 스케일을 계산한다. 스케일은 0.7로 시작하고 percentVisible 값에 따라 증가하는데, percentVisible가 1.0인 경우(아이템이 완전히 보일 때) 스케일은 1.0이 된다. 그리고 percentVisible가 작을수록 스케일이 작아지며 아이템이 더 작아진다. 즉, 스케일이 0.7에서 시작하여 최대 0.3만큼 더 증가하도록 한다.
  4. 최종적으로 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의 scrollViewWillBeginDeceleratingscrollViewWillEndDragging를 활용하는 거 같았다. (이러한 문제가 있어서 그런가..)
  • 그래서 최종 결과물은 다음과 같다. 화면에 셀이 한개씩 보여지는 디자인이 뭔가 불편하긴 하지만, 스크롤이 가능하다는 디자인을 추가로 넣어서 해결해야겠다. 🥲
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으로 설정해서 화면에 셀 한개만 보이도록 설정했다.

--

--