Notice
Recent Posts
Recent Comments
Link
관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] 3. 스크롤되는 PagerView 구현 방법 - Tab과 Pager 스크롤 싱크 맞추기 본문

UI 컴포넌트 (swift)

[iOS - swift] 3. 스크롤되는 PagerView 구현 방법 - Tab과 Pager 스크롤 싱크 맞추기

jake-kim 2023. 6. 20. 01:10

1. 스크롤되는 PagerView 구현 방법 - 상단 TabView 구현하기

2. 스크롤되는 PagerView 구현 방법 - 하단 PagerView 구현하기

3. 스크롤되는 PagerView 구현 방법 - Tab과 Pager 스크롤 싱크 맞추기 v

구현한 PagerView

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 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)
    }
}

(완성)

구현한 PagerView

* 전체 코드: https://github.com/JK0369/ExPagerView

 

Comments