Notice
Recent Posts
Recent Comments
Link
관리 메뉴

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

[iOS - swift] Nested Scroll (이중 스크롤) 구현 방법 본문

UI 컴포넌트 (swift)

[iOS - swift] Nested Scroll (이중 스크롤) 구현 방법

jake-kim 2023. 1. 18. 22:43

Nested Scroll이란?

* more, less 스크롤 방향의 기준: 새로운 콘텐츠로 스크롤링하면 more, 이전 콘텐츠로 스크롤링하면 less

ex) more scroll 한다는 의미: 손가락을 아래에서 위로 올려서 새로운 콘텐츠를 확인한다

  • 1.outer scroll을 more 스크롤
    • 만약 outer scroll을 more scroll 다 했으면, child scroll을 more scroll

  • 2.outer scroll을 less 스크롤
    • 만약 inner scroll이 less 스크롤 할게 남아 있다면 inner scroll을 less 스크롤

outer scroll을 해도 inner scroll이 먼저 동작

  • 3. inner scroll을 less 스크롤
    • inner scroll을 모두 less scroll한 경우, outer scroll을 less scroll

  • 4. inner scroll을 more 스크롤 할 경우?
    • more scroll이 아직 more 스크롤할게 남아 있다면, innerScroll을 그대로 두고 outer scroll을 more 스크롤

구현 아이디어

  • outerScroll과 innerScroll의 contentOffset을 적절히 변경해주어서 구현
  • 주의) 스크롤 될 때마다, outer scroll, inner scroll의 각 isScrollEnabled를 false 혹은 true로 바꾸는 방법은 사용하면 안됨
    • 스크롤 되는 도중에 isScrollEnabled를 변경해주어도 스크롤이 끝난 다음에 적용되므로 끊기는 현상이 발생하므로 isScrollEnabled은 건들지 않기

구현 방법

* 코드로 UI 구현 편의를 SnapKit, Then 프레임워크 사용

  • UI 준비
    • UIScrollView가 전체를 감싸는 형태
    • UIScrollView 위에 UIStackView가 있고, 여기에 UILabel, UITableView 삽입

import UIKit
import Then
import SnapKit

class ViewController: UIViewController {
    private let outerScrollView = UIScrollView()
    private let stackView = UIStackView().then {
        $0.axis = .vertical
        $0.spacing = 20
    }
    private let headerView = UIView().then {
        $0.backgroundColor = .systemGreen
    }
    private let titleLabel = UILabel().then {
        $0.numberOfLines = 0
        $0.font = .systemFont(ofSize: 22, weight: .regular)
        $0.textColor = .black
        $0.text = text1
    }
    private let tableView = UITableView(frame: .zero).then {
        $0.allowsSelection = false
        $0.backgroundColor = UIColor.clear
        $0.bounces = true
        $0.showsVerticalScrollIndicator = true
        $0.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
    }
    
    var innerScrollView: UIScrollView {
        tableView
    }
}
  • 필요한 프로퍼티 선언
    • items는 테이블뷰에서 사용
    let items = (0...31).map(String.init)
  • UI들의 레이아웃 정의
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.addSubview(outerScrollView)
        outerScrollView.addSubview(stackView)
        stackView.addArrangedSubview(titleLabel)
        stackView.addArrangedSubview(tableView)
        
        outerScrollView.snp.makeConstraints {
            $0.edges.equalTo(view.safeAreaLayoutGuide)
        }
        stackView.snp.makeConstraints {
            $0.edges.width.equalToSuperview()
        }
        tableView.snp.makeConstraints {
            $0.height.equalTo(400)
        }
        
        outerScrollView.delegate = self
        tableView.delegate = self
        tableView.dataSource = self
    }
  • 테이블 뷰 데이터소스
extension ViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        items.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = "#\(indexPath.row)"
        cell.detailTextLabel?.text = items[indexPath.row]
        return cell
    }
}

nested scroll 구현

extension ViewController: UITableViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
    	// 여기서 nested scroll 구현
    }
}
  • 먼저 outer scroll, inner scroll 등 어떤것인지 구분하기 위한 프로퍼티 준비
// more, less 스크롤 방향의 기준: 새로운 콘텐츠로 스크롤링하면 more, 이전 콘텐츠로 스크롤링하면 less
// ex) more scroll 한다는 의미: 손가락을 아래에서 위로 올려서 새로운 콘텐츠를 확인한다

let outerScroll = outerScrollView == scrollView
let innerScroll = !outerScroll
let moreScroll = scrollView.panGestureRecognizer.translation(in: scrollView).y < 0
let lessScroll = !moreScroll

// outer scroll이 스크롤 할 수 있는 최대값 (이 값을 sticky header 뷰가 있다면 그 뷰의 frame.maxY와 같은 값으로 사용해도 가능)
let outerScrollMaxOffsetY = outerScrollView.contentSize.height - outerScrollView.frame.height
let innerScrollMaxOffsetY = innerScrollView.contentSize.height - innerScrollView.frame.height
  • outer scroll만 고려 (child scroll의 스크롤은 비활성화한 상태)
guard outerScroll else { return }

1. outer scroll을 more 스크롤

  • 만약 outer scroll을 more scroll 다 했으면, child scroll을 more scroll
if outerScroll && moreScroll {
    guard outerScrollMaxOffsetY < outerScrollView.contentOffset.y + Policy.floatingPointTolerance else { return }
    innerScrollingDownDueToOuterScroll = true
    defer { innerScrollingDownDueToOuterScroll = false }
    
    // innerScrollView를 모두 스크롤 한 경우 stop
    guard innerScrollView.contentOffset.y < innerScrollMaxOffsetY else { return }

    innerScrollView.contentOffset.y = innerScrollView.contentOffset.y + outerScrollView.contentOffset.y - outerScrollMaxOffsetY
    outerScrollView.contentOffset.y = outerScrollMaxOffsetY
}

2. outer scroll을 less 스크롤

  • 만약 inner scroll이 less 스크롤 할게 남아 있다면 inner scroll을 less 스크롤
if outerScroll && lessScroll {
    guard innerScrollView.contentOffset.y > 0 && outerScrollView.contentOffset.y < outerScrollMaxOffsetY else { return }
    innerScrollingDownDueToOuterScroll = true
    defer { innerScrollingDownDueToOuterScroll = false }
    
    // outer scroll에서 스크롤한 만큼 inner scroll에 적용
    innerScrollView.contentOffset.y = max(innerScrollView.contentOffset.y - (outerScrollMaxOffsetY - outerScrollView.contentOffset.y), 0)
    
    // outer scroll은 스크롤 되지 않고 고정
    outerScrollView.contentOffset.y = outerScrollMaxOffsetY
}

3. inner scroll을 less 스크롤

  • inner scroll을 모두 less scroll한 경우, outer scroll을 less scroll
    • extension에 lastOffsetY라는 stored property 추가 방법은 이전 포스팅 글 참고
if innerScroll && lessScroll {
    defer { innerScrollView.lastOffsetY = innerScrollView.contentOffset.y }
    guard innerScrollView.contentOffset.y < 0 && outerScrollView.contentOffset.y > 0 else { return }
    
    // innerScrollView의 bounces에 의하여 다시 outerScrollView가 당겨질수 있으므로 bounces로 다시 되돌아가는 offset 방지
    guard innerScrollView.lastOffsetY > innerScrollView.contentOffset.y else { return }
    
    let moveOffset = outerScrollMaxOffsetY - abs(innerScrollView.contentOffset.y) * 3
    guard moveOffset < outerScrollView.contentOffset.y else { return }
    print(moveOffset)
    
    outerScrollView.contentOffset.y = max(moveOffset, 0)
}

private struct AssociatedKeys {
    static var lastOffsetY = "lastOffsetY"
}

extension UIScrollView {
    var lastOffsetY: CGFloat {
        get {
            (objc_getAssociatedObject(self, &AssociatedKeys.lastOffsetY) as? CGFloat) ?? contentOffset.y
        }
        set {
            objc_setAssociatedObject(self, &AssociatedKeys.lastOffsetY, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }
}

4. inner scroll을 more 스크롤

  • outer scroll이 아직 more 스크롤할게 남아 있다면, innerScroll을 그대로 두고 outer scroll을 more 스크롤
if innerScroll && moreScroll {
    guard
        outerScrollView.contentOffset.y + Policy.floatingPointTolerance < outerScrollMaxOffsetY,
        !innerScrollingDownDueToOuterScroll
    else { return }
    // outer scroll를 more 스크롤
    let minOffetY = min(outerScrollView.contentOffset.y + innerScrollView.contentOffset.y, outerScrollMaxOffsetY)
    let offsetY = max(minOffetY, 0)
    outerScrollView.contentOffset.y = offsetY
    
    // inner scroll은 스크롤 되지 않아야 하므로 0으로 고정
    innerScrollView.contentOffset.y = 0
}

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

Comments