Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 | 31 |
Tags
- Xcode
- Observable
- Human interface guide
- 클린 코드
- 리펙터링
- UITextView
- 리팩토링
- tableView
- swiftUI
- MVVM
- clean architecture
- combine
- ribs
- Refactoring
- uiscrollview
- Protocol
- swift documentation
- RxCocoa
- rxswift
- collectionview
- 애니메이션
- uitableview
- map
- 리펙토링
- ios
- SWIFT
- Clean Code
- UICollectionView
- 스위프트
- HIG
Archives
- Today
- Total
김종권의 iOS 앱 개발 알아가기
[iOS - swift] 3. 스크롤되는 PagerView 구현 방법 - Tab과 Pager 스크롤 싱크 맞추기 본문
UI 컴포넌트 (swift)
[iOS - swift] 3. 스크롤되는 PagerView 구현 방법 - Tab과 Pager 스크롤 싱크 맞추기
jake-kim 2023. 6. 20. 01:101. 스크롤되는 PagerView 구현 방법 - 상단 TabView 구현하기
2. 스크롤되는 PagerView 구현 방법 - 하단 PagerView 구현하기
3. 스크롤되는 PagerView 구현 방법 - Tab과 Pager 스크롤 싱크 맞추기 v
PagerView 형태
- 상단에는 TabView
- UIScrollView안에 UIStackView를 넣어서 구현하고 각 tap 이벤트는 뷰의 tag를 사용하면 인덱스를 구할 수 있음
- 하단에는 PagerView
- 페이지 기능을 쉽게 사용하기 위해서 UICollectionView를 사용하여 구현
- 주의) UIPageViewController를 사용하지 않음 - UIPageViewController안에 내장된 UIScrollView의 형태는 내부 content 크기만큼 있는게 아닌 페이지의 크기만큼만 존재하므로, 스크롤 했을 때 제대로된 contentOffset을 가져오기가 힘듦
Tab과 Pager 싱크 맞추기
- 이전 글에서 상단 TabView와 하단 PagerView를 구현했는데, 각각 있는 UIScrollView를 사용하여 싱크 맞추기가 필요
1. 스크롤되는 PagerView 구현 방법 - 상단 TabView 구현하기
2. 스크롤되는 PagerView 구현 방법 - 하단 PagerView 구현하기
- 싱크 맞추기
- 상단 tabView에서 특정 아이템 선택 -> 하단의 스크롤도 그 아이템으로 이동
- 하단 pagerView에서 스크롤 -> 상단의 스크롤과 underlineView가 그 아이템으로 이동
- 방법?
- 각 스크롤뷰의 didScroll에서 스크롤되는 contentOffset값을 실시간으로 가져와서 맞추어주는 방법
TabView에서 스크롤 이벤트처리
- tabView에서는 didTap 클로저로 선택된 아이템의 인덱스를 넘겨주고 있으므로 이것을 받아서 하단의 pagerView에 적용
// TabView.swift
var didTap: ((Int) -> Void)?
@objc private func tapItem(sender: UITapGestureRecognizer) {
guard let tag = sender.view?.tag else { return }
didTap?(tag)
}
- 스크롤 처리 - tabView와 pagerView를 가지고 있는 viewController에서 수행
- tabView에서 didTap 이벤트가 들어오면 pagerView와 tabView를 스크롤 이동시키고, tabView안에 있는 underlineView도 이동시킴
- scroll(to:), syncUnderlineView(index:underlineView:) 구현부는 해당 글 가장 아래에서 계속..
// ViewController.swift
tabView.didTap = { [weak self] index in
guard let self else { return }
pagerView.scroll(to: index)
tabView.scroll(to: index)
tabView.syncUnderlineView(index: index, underlineView: tabView.highlightView)
}
PagerView에서의 스크롤 처리
- 스크롤 할 때마다(didScroll), 전체 스크롤 크기 중 얼마나 스크롤 되었는지 ratio 값 넘기기
- -> ratio값을 받아서 상단 tabView도 스크롤할때 사용
- 스크롤이 끝날때(DidEndDecelerating), index 값 넘기기
- -> UICollectionViewController에서 isPagingEnabled를 활성화하면 스크롤이 튕기는 현상이 있으므로 스크롤이 끝난 시점 (didEndDecelerating) 에 한번 더 스크롤을 맞춰주기 위함
- 정확하게 원하는 뷰로 스크롤 되기 위해서는 index값을 받아서 특정 뷰로 스크롤 되도록 하는 scrollRectToVisible(_:animated:_)를 사용
- 전역변수에 scrollingByUser를 선언하고, 이 값은 사용자가 드래그하여 스크롤 되었는지 setContentOffset 설정으로 스크롤 되었는지 판단할 때 사용
- tabView에서 인덱스 값이 변경되었을때 pagerView에다가 setContentOffset으로 스크롤을 맞춰주는데 이 때 pagerView의 didScroll에서 또다시 tabView에 싱크를 맞춰달라고 부르므로 중복실행이 되지 않도록 사용자가 스크롤한 경우만 스크롤 처리하도록 사용
extension PagerView: UICollectionViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollingByUser else { return }
let ratioX = scrollView.contentOffset.x / scrollView.contentSize.width
didScroll?(ratioX)
}
// 핵심: 손으로 드래깅 시작
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
scrollingByUser = true
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
scrollingByUser = false
let widthPerPage = scrollView.contentSize.width / Double(items.count)
let pageIndex = Int(scrollView.contentOffset.x / widthPerPage)
didEndScroll?(pageIndex)
}
}
스크롤 처리 구현
- 스크롤 처리는 하나의 기능 단위로 취급할 수 있으므로 protocol로 만들어서 구현
- protocol 지향 프로그래밍이 좋은 이유는 이전 포스팅 글 참고
protocol ScrollFitable: AnyObject {
}
- ScrollFitable 프로토콜을 tabView, pagerView에서 각각 준수하고 있고, 이를 사용하는 viewController쪽에서는 특정 값을 받아서 이 프로토콜에게 scroll을 맞춰달라고 요청
(사용하는쪽)
// ViewController.swift
private func handleScroll() {
tabView.syncUnderlineView(index: 0, underlineView: tabView.highlightView)
tabView.didTap = { [weak self] index in
guard let self else { return }
pagerView.scroll(to: index)
tabView.scroll(to: index)
tabView.syncUnderlineView(index: index, underlineView: tabView.highlightView)
}
pagerView.didScroll = { [weak self] ratioX in
guard let self else { return }
tabView.scroll(to: ratioX)
tabView.syncUnderlineView(ratio: ratioX, underlineView: tabView.highlightView)
}
pagerView.didEndScroll = { [weak self] index in
guard let self else { return }
tabView.syncUnderlineView(index: index, underlineView: tabView.highlightView)
}
}
- ScrollFitable에 필요한 프로퍼티와 함수 정의
- scrollView: setContentOffset이나 scrollRectToVisible(_:animated:)로 스크롤을 피팅 시킬 때 사용
- countOfItems: 아이템의 갯수를 알아야 아이템 하나의 갯수를 알아낼 수 있으므로 선언
- tabContentViews: 스크롤 될 위치를 결정할 때 해당 뷰의 rect값을 알면 scrollRectToVisible(_:animated:)로 쉽게 스크롤 시킬 수 있으므로 뷰도 가지고 있도록 구성
- scroll(to index:): tabView에서 특정 아이템이 탭 되었을때 index값을 가져오는데 이 때 index값을 가지고 pagerView를 스크롤 시킬 때 사용
- scroll(to ratio:): pagerView에서 스크롤 될 때 전체에 비해서 지금 얼마나 스크롤 되었는지 ratio값을 알 수 있는데 이 값을 가지고 tabView를 스크롤 할 때 사용
protocol ScrollFitable: AnyObject {
var scrollView: UIScrollView { get }
var countOfItems: Int { get }
var tabContentViews: [UIView] { get }
func scroll(to index: Int)
func scroll(to ratio: Double)
}
- tabView, pagerView에서 각각 해당 프로토콜 준수
extension TabView: ScrollFitable {
var tabContentViews: [UIView] {
contentLabels
}
var scrollView: UIScrollView {
tabScrollView
}
var countOfItems: Int {
dataSource?.count ?? 0
}
}
extension PagerView: ScrollFitable {
var scrollView: UIScrollView {
collectionView
}
var countOfItems: Int {
items.count
}
}
- ScrollFitable에 extension으로 필요한 함수를 구현
import UIKit
private struct AssociatedKeys {
static var lastWidthKey = "lastWidth"
}
// Use this protocol to fitting scroll
protocol ScrollFitable: AnyObject {
var scrollView: UIScrollView { get }
var countOfItems: Int { get }
var tabContentViews: [UIView] { get }
func scroll(to index: Int)
func scroll(to ratio: Double)
}
extension ScrollFitable {
var lastWidth: Double {
get {
return objc_getAssociatedObject(self, &AssociatedKeys.lastWidthKey) as? Double ?? 0
}
set {
objc_setAssociatedObject(self, &AssociatedKeys.lastWidthKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
var tabContentViews: [UIView] {
[]
}
/* 스크롤 처리 2단계로 하는 이유, 핵심
- didScroll 델리게이트에서 넘어올때는 실수 값을 받아서 x좌표의 레이아웃을 정의
- DidEndDecelerating 델리게이트에서 넘어올때는 정수 값을 받아서 width값도 업데이트
(나누는 이유: didScroll에서도 정수 값을 받아서 업데이트 하면 스크롤이 진행될때 정수이므로 실시간으로 스크롤이 안됨)
- lastWidth를 따로 두는 이유도, didScroll에서 width값도 같이 업데이트 되는데 이 값은 이전값으로 놓기 위함
*/
func scroll(to index: Int) {
if index < tabContentViews.count {
scrollView.scroll(rect: tabContentViews[index].frame, animated: true)
} else {
let offset = getStartOffset(index: index)
scrollView.setContentOffset(offset, animated: true)
}
}
func scroll(to ratio: Double) {
let rect = getTargetRect(ratio: ratio)
scrollView.scroll(rect: rect, animated: true)
}
// 핵심: 뷰 기준으로 해야 width값을 뷰의 동적 사이즈 대응이 가능
func syncUnderlineView(index: Int, underlineView: UIView) {
guard index < tabContentViews.count else { return }
let targetLabel = tabContentViews[index]
lastWidth = targetLabel.frame.width
underlineView.snp.remakeConstraints {
$0.width.equalTo(targetLabel)
$0.height.equalTo(8)
$0.leading.equalTo(targetLabel)
$0.bottom.equalToSuperview()
}
UIView.animate(
withDuration: 0.2,
delay: 0,
options: .curveLinear,
animations: { self.scrollView.layoutIfNeeded() }
)
}
// 핵심: 뷰 기준으로 정하면 안됨 (view기준으로하면 뚝뚝 끊기는 스크롤이 되므로 실수값으로 해야함)
func syncUnderlineView(ratio: Double, underlineView: UIView) {
let leading = scrollView.contentSize.width * ratio
underlineView.snp.remakeConstraints {
$0.width.equalTo(lastWidth)
$0.height.equalTo(8)
$0.leading.equalTo(leading)
$0.bottom.equalToSuperview()
}
UIView.animate(
withDuration: 0.3,
delay: 0,
animations: { self.scrollView.layoutIfNeeded() }
)
}
private func getStartOffset(index: Int) -> CGPoint {
let totalWidth = scrollView.contentSize.width
let widthPerItem = totalWidth / Double(countOfItems)
let startOffsetX = widthPerItem * Double(index)
return .init(
x: startOffsetX,
y: scrollView.contentOffset.y
)
}
private func getTargetRect(ratio: Double) -> CGRect {
let totalWidth = scrollView.contentSize.width
let rect = CGRect(
x: totalWidth * ratio,
y: scrollView.frame.minY,
width: scrollView.frame.width,
height: scrollView.frame.height
)
return rect
}
}
// 핵심: 스크롤을 원하는 곳의 중앙에 위치시킴 (https://ios-development.tistory.com/1262)
private extension UIScrollView {
func scroll(rect: CGRect, animated: Bool) {
let origin = CGPoint(
x: rect.origin.x - (frame.width - rect.size.width) / 2,
y: rect.origin.y - (frame.height - rect.size.height) / 2
)
let rect = CGRect(origin: origin, size: frame.size)
scrollRectToVisible(rect, animated: animated)
}
}
(완성)
* 전체 코드: https://github.com/JK0369/ExPagerView
'UI 컴포넌트 (swift)' 카테고리의 다른 글
Comments