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
- tableView
- UITextView
- Xcode
- Human interface guide
- HIG
- 애니메이션
- SWIFT
- collectionview
- swift documentation
- 클린 코드
- combine
- ribs
- RxCocoa
- UICollectionView
- Clean Code
- 스위프트
- 리펙터링
- Refactoring
- map
- clean architecture
- uitableview
- rxswift
- 리팩토링
- Observable
- ios
- Protocol
- 리펙토링
- uiscrollview
- swiftUI
- MVVM
Archives
- Today
- Total
김종권의 iOS 앱 개발 알아가기
[iOS - swift] Nested Scroll (이중 스크롤) 구현 방법 본문
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 스크롤
- 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
}
'UI 컴포넌트 (swift)' 카테고리의 다른 글
Comments