Nested ScrollView - 프로 프로필 피드 탭 구현

2023. 3. 29. 10:49iOS/이슈

피드 기능이 추가 되면서 프로필에 유저가 작성한 피드 리스트를 추가하게 되었다. 스크롤 뷰안에 스크롤 뷰가 들어가게 되면서 자연스러운 유저의 스크롤 경험을 구현하기 어려웠다. 티빙과 같은 자연스러운 스크롤 뷰를 만들고 싶었다.

예시 티빙

처음엔 내부 테이블 뷰를 isScrollEnabled 속성을 false로 주어 컨텐츠 사이즈를 다 잡아버리는 방식으로 구현했었다.

그러다 아래의 글을 보게 되었고, 이 방식이 reusable한 cell을 전혀 활용하지 못한다는 것을 알게 되었다.

[참고1] 나이브한 테이블 뷰 - https://oleb.net/blog/2014/05/scrollviews-inside-scrollviews/

 

찾아보니 크게 두가지 방법이 있었다.

1. 스티키 헤더: [참고2]헤더 뷰를 만들고 스크롤에 따라 헤더 뷰 height 값을 조정하는 방법

2. Nested Scroll: [참고3]Outer와 Inner 스크롤 뷰를 두고 content offset값과 터치 이벤트를 받은 스크롤 뷰에 따라 조정하는 법

확장성을 고려했을 때 2번 방법이 더 나을 것 같아 2번 방법으로 개발하게 되었다.

스택오버플로우 여러개를 참조해봤는데 잘 안되다가 종권님의 포스트를 보고 해결되었다. 종권님은 신인 것 같다.

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    guard let innerScrollView = self.innerScrollView else { return }
    let outerScroll = self.outerScrollView == scrollView
    let innerScroll = !outerScroll
    
    // 방향을 결정한다.
    let moreScroll = scrollView.panGestureRecognizer.translation(in: scrollView).y < 0
    let lessScroll = !moreScroll
    
    let outerScrollMaxOffsetY = outerScrollView.contentSize.height - outerScrollView.frame.height
    let innerScrollMaxOffsetY = innerScrollView.contentSize.height - innerScrollView.frame.height
    
    // 1. outer && more
    if outerScroll && moreScroll {
      
      // outerScroll의 끝이 아니면 return 하여 계속 outerScroll 진행
      guard outerScrollMaxOffsetY < outerScrollView.contentOffset.y + 0.1 else { return }
      
      // innerScroll 시작
      innerScrollingDownDueToOuterScroll = true
      defer { innerScrollingDownDueToOuterScroll = false }
      
      // innerScrollView Content가 끝이 아닐 경우
      guard innerScrollView.contentOffset.y < innerScrollMaxOffsetY else { return }
      
      // innerScrollView가 스크롤 되지 않게 보정
      innerScrollView.contentOffset.y = innerScrollView.contentOffset.y + outerScrollView.contentOffset.y - outerScrollMaxOffsetY
      outerScrollView.contentOffset.y = outerScrollMaxOffsetY
    }
    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
    if innerScroll && lessScroll {
      defer { innerScrollView.lastOffsetY = innerScrollView.contentOffset.y }
      defer { outerScrollView.lastOffsetY = outerScrollView.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 + 54
      let moveOffset = abs(innerScrollView.contentOffset.y) * 3
//      guard moveOffset < outerScrollView.contentOffset.y else { return }
      let movedOuterOffset = outerScrollView.lastOffsetY - moveOffset
      outerScrollView.contentOffset.y = movedOuterOffset < 0 ? 0 : movedOuterOffset
    }
    
    // 4. inner scroll을 more 스크롤
    // outer scroll이 아직 more 스크롤할게 남아 있다면, innerScroll을 그대로 두고 outer scroll을 more 스크롤
    if innerScroll && moreScroll {
      guard
        outerScrollView.contentOffset.y < 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
    }
    
  }

 

결과물

참고 1 나이브한 테이블 뷰

 

Scroll Views Inside Scroll Views – Ole Begemann

Update June 30, 2014: Updated to reflect changes I made to OLEContainerScrollView’s public interface. The container scroll view now exposes a read-only contentView property. You should add any subviews you want managed by the container scroll view direct

oleb.net

참고 2 Dynamic Height Content Size

 

Dynamic height for a child view controller!

Hello, champ readers! This is a quick 📝 regarding the dynamic height adaptation of child controllers in a parent’s container view (UIView…

medium.com

참고 3 Stikey Header

 

당근마켓 클론코딩 - 1. UITableView Sticky header 구현하기

Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec 27 28 29 30 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 1 2 3 4 5 6 7 당근마켓 클론코딩 - 1 당근마켓 클론코딩 1주차 에서 이어지는 내용 + 스

iamcho2.github.io

참고 4 Nested ScrollView

 

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

Nested Scroll이란? * more, less 스크롤 방향의 기준: 새로운 콘텐츠로 스크롤링하면 more, 이전 콘텐츠로 스크롤링하면 less ex) more scroll 한다는 의미: 손가락을 아래에서 위로 올려서 새로운 콘텐츠를

ios-development.tistory.com