[iOS] - 무한 ScrollView 만들기


무한으로 스크롤링되는 페이징 뷰를 만들어보자.


https://medium.com/swift2go/custom-ui-master-class-infinite-paging-scroll-view-4be78d0de88f

Infinite Paging Scroll View

UseCase

이 무한으로 스크롤 되는 UI component는 사용자가 선택했으면 하는 미리 정해진 옵션이 적을 때 사용하면 좋다. 이런 옵션들을 화면의 굉장히 적은 영역을 사용해서 표시하고, 이미 선택된 기본 옵션이 있다고 해보자. 뷰를 탭해서 새로운 옵션을 선택하고, 이를 MVC 체인을 통해 전달할 것이다.

Illustion of Infinte Scrolling

먼저 무한 스크롤링처럼 보이게 어떻게 동작하는지에 대해 알아볼 것이다. 배열에 담긴 데이터를 나타내는 스크롤뷰가 가로로 무한으로 스크롤링 되는 형태를 가지게 될 것이다.

  1. image 먼저 스크롤 뷰의 content view에 개별 페이지로 표시하고 싶은 네 개의 요소들이 있고, 각각이 색과 숫자를 가지고 있다고 해보자. 일반적으로, 각 요소가 ‘page’를 갖게하기 위해 스크롤 뷰의 content size를 스크롤 뷰의 가로를 4배 한 값으로 설정할 것이다. 하지만 여기에서는 4번째 페이지에서 더 스크롤해서 첫 번째 페이지로 돌아오는 유일한 방법은 content offset을 다시 0으로 설정하는 방법밖에 없기 때문에 우리가 원하는대로 동작하지 않게 된다. 이런 방식으로 구현해서 4번째 페이지에서 첫 번째 페이지로 돌아가면 우리가 상상했던 것처럼 자연스러운 페이징 애니메이션을 구현할 수 없다.
  2. image 대신, 입력 데이터를 수정해서 첫번째와 마지막 요소가 각각의 끝으로 복사되게 할 수 있다. 이렇게 하면 이제 4개의 요소를 보여주기 위해 6개의 페이지를 가지게 된다. 이제, 4번째 요소에서 첫 번째 요소로 이동할 때 우리가 상상했던 페이징 애니메이션을 얻을 수 있다. (첫 번째 요소에서 네 번째 요소로 이동할 때도 마찬가지다.)
  3. image 페이징 애니메이션이 끝났을 때, 스크롤 뷰는 content view의 끝에 있는 첫 번째 요소를 화면에 출력하고 있을 것이다. 이제 우리는 content offset을 조정해서 같은 데이터지만 content view의 더 앞 쪽에 있는 요소를 보여주게 할 수 있다. 이런 식으로 offset을 변화시키면 사용자는 변화를 눈치챌 수 없다.

Step 1: Basic Setup

UIView를 상속한 InfiniteScrollView 커스텀 클래스를 생성하고, 프레임을 시각화하기 위해 배경 색을 회색으로 설정했다. 그리고 scrollViewtapView 이렇게 두 개의 프로퍼티를 정의했다. 스크롤 뷰는 scoll indicator가 제거된 상태이고, paging이 활성화 된 상태이다. 탭 뷰는 lazy 변수로 선언되어 tap gesture를 나중에 추가할 수 있도록 했다. 현재까지는 투명하게만 설정되어 있다. 이 하위 뷰들을 레이어링 해서 tapView가 우리의 view의 bound를 채우고, 이 사이즈의 반인 scroll view의 위에 위치하도록 할 것이다. 이제 사용자는 큰 뷰와 상호작용할 수 있고, 반면에 스크롤링은 더 작은 뷰에서 일어난다.

class InfiniteScrollView: UIView {

    let scrollView: UIScrollView = {
        let scroll = UIScrollView()
        scroll.backgroundColor = UIColor.red
        scroll.showsHorizontalScrollIndicator = false
        scroll.isPagingEnabled = true
        return scroll
    }()

    lazy var tapView: UIView = {
        let view = UIView()
        view.backgroundColor = UIColor.clear
        return view
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)

        self.backgroundColor = UIColor.gray

        setupSubviews()
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func setupSubviews() {
        scrollView.frame = CGRect(x: (self.bounds.width / 2),
                                  y: 0,
                                  width: (self.bounds.width / 2),
                                  height: self.bounds.height)
        self.addSubview(scrollView)

        tapView.frame = self.bounds
        self.addSubview(tapView)
    }
}

뷰 컨트롤러 안에서는 아래와 같이 infiniteScrollView를 생성해 화면에 추가한다.

//In the View Controller
self.scrollOptionsView = InfiniteScrollView(frame: CGRect(x: 0, y: 300, width: self.view.bounds.width, height: 40))
self.view.addSubview(scrollOptionsView)

Step 2: DataSource 수정과 ScrollView Content Layout

다음 단계는 infiniteScrollView에 데이터를 추가하고, 우리의 content view에 추가된 라벨들을 출력하는 것이다. 이를 위해서는 두 개의 프로퍼티가 필요하다. datasource는 public하게 String의 배열을 수용하고, _datasource는 private한 애로, input의 수정된 버전이고, 이전 섹션에서 말한 대로 배열의 맨 앞에는 마지막 요소를, 맨 끝에는 첫 번째 요소를 추가해줘야 한다.

다음으로, modifiyDatasourcesetupContentView라는 두 가지 메서드가 있다. 먼저 modifyDatasource라는 메서드는 didSet 프로퍼티 감시자를 갖고 있기 때문에, 데이터가 변할 때마다 우리의 커스텀 뷰가 업데이트 됨을 보장할 수 있다. 우리는 tempInput이라는 변수에 바인딩함으로써 datasource가 nil이 아님을 확인할 수 있고, 그 이후에 수가 둘 이상인지 확인한다. 만약 둘 이상이 아니라면, 스크롤할 데이터가 충분하지 않으므로 이럴 경우 메서드는 subview에 특별한 처리를 하지 않고 리턴한다. 우리의 data input과 count가 조건에 충족하는 상황이라면, 우리는 datasource를 수정할 수 있다. 우리는 이를 tuple로 첫번째와 마지막 요소를 삽입해서 처리해준다. 첫 번째 요소와 마지막 요소를 강제로 unwrapping하는 것은 우리가 이전에 배열이 이 값들을 이미 갖고 있다는 체크를 했기 때문에 안전하다. 그리고 우리는 tempInput의 마지막에 첫 번째 요소를 집어넣고, 마지막 요소를 맨 앞에 집어넣는다. 이를 입증하기 위해 print로 출력을 했고, 수정된 데이터로 _datasource를 설정했다.

그리고 _datasource의 프로퍼티 감시자가 setupContentView()를 해서 추가적인 설정을 해준다. 여기서 우리는 이미 이전 setup에서 이미 존재하고 있을 수 있는 subview들을 제거해준다. 그리고, _datasource를 unwrap하고 우리의 content size를 설정하기 위해 옵셔널 바인딩을 사용한다. 우리가 가로로 스크롤되는 페이징 형태를 원하기 때문에, content의 높이는 scrollView의 높이와 같아야 하고, 너비는 scrollView의 너비에 _datasource에 있는 요소의 수만큼 곱한 값이 되어야 할 것이다. 라벨을 올바른 자리에 넣기 위해서, i값을 사용해서 loop문 안에서 frame을 계산해준다. 우리는 이 i값을 사용해서 우리의 데이터 소스에서 적절한 string을 가져오고 label의 텍스트로 지정해준 후 scrollView에 라벨을 subview로 추가해준다. 라벨이 적절한 텍스트와 함께 화면에 추가되었다면, 우리는 content offset을 첫 번째 요소를 보여주게 설정할 수 있다.

class InfiniteScrollView: UIView {

    var datasource: [String]? {
        didSet {
            modifyDatasource()
        }
    }

    private var _datasource: [String]? {
        didSet {
            setupContentView()
        }
    }

    private func modifyDatasource() {
       guard var tempInput = datasource, tempInput.count >= 2 else { 
           return 
        }

        let firstLast = (tempInput.first!, tempInput.last!)
        tempInput.append(firstLast.0)
        tempInput.insert(firstLast.1, at: 0)

        print("_datasource set to: \(tempInput)")

        self._datasource = tempInput
    }

    private func setupContentView() {

       let subviews = scrollView.subviews
        for subview in subviews {
            subview.removeFromSuperview()
        }

        guard let data = _datasource else { return }

        self.scrollView.contentSize = CGSize(width: scrollView.frame.size.width * CGFloat(data.count),
                                             height: scrollView.frame.size.height)

        for i in 0..<data.count {
            var frame = CGRect()
            frame.origin.x = scrollView.frame.size.width * CGFloat(i)
            frame.origin.y = 0
            frame.size = scrollView.frame.size

            let label = UILabel(frame: frame)
            label.text = data[i]
            self.scrollView.addSubview(label)
        }
        let index = 1
        scrollView.contentOffset = CGPoint(x: (scrollView.frame.width * CGFloat(index)), y: 0)
    }

}



self.infiniteScrollView.datasource = ["option one", "option two", "option three", "option four"]

/*prints: 
_datasource set to: ["option four", "option one", "option two", "option three", "option four", "option one"]
*/

image

Step 3: Tap Gesture 추가와 Paging Logic

이제 scrollVIew의 요소들을 paging할 수 있어야 한다. 이를 위해 tapView 프로퍼티에 didReceiveTap(sender:)라는 selector 메서드를 가진 tapGesture를 추가해줬다. 이 메서드는 scrollView의 가로를 현재의 content offset x 값에 더함으로써 우리가 보고 싶어하는 다음 사각형을 만든다. 그리고 페이지가 넘어가는 효과는 scrollRectToVisible 메서드에서 구현한다.

그리고 이제 data source의 끝에 다다랐을 때 content offset을 설정하는 로직을 추가한다. 이 로직은 scroll animation이 끝났을 때 호출되어야 한다. 우리가 우리의 custom 클래스를 scrollView delegate로 설정한다면, scroll하는 애니메이션이 끝날 때마다 호출되는 scrollViewDidScroll 메서드를 오버라이딩할 수 있다. 그리고 scrollViewDidScroll에서, 우리는 우리의 data source의 bound 뒤에 있는 화면에 노출가능한 content의 위치를 알아내고, contentOffset을 사용해서 적절히 초기화 한다.

class InfiniteScrollView: UIView {

    lazy var tapView: UIView = {
        let view = UIView()
        view.backgroundColor = UIColor.clear
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didReceiveTap(sender:)))
        view.addGestureRecognizer(tapGesture)
        return view
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)

        self.backgroundColor = UIColor.gray
        scrollView.delegate = self
        setupSubviews()
    }

    @objc
    func didReceiveTap(sender: UITapGestureRecognizer) {
        let x = scrollView.contentOffset.x
        let nextRect = CGRect(x: x + scrollView.frame.width,
                              y: 0,
                              width: scrollView.frame.width,
                              height: scrollView.frame.height)

        scrollView.scrollRectToVisible(nextRect, animated: true)
    }
}

extension InfiniteScrollView: UIScrollViewDelegate {

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        guard _datasource != nil else { return }
        let x = scrollView.contentOffset.x
        if x >=  scrollView.frame.size.width * CGFloat(_datasource!.count - 1) {
            self.scrollView.contentOffset = CGPoint(x: scrollView.frame.size.width , y: 0)
        } else if x < scrollView.frame.width {
            self.scrollView.contentOffset = CGPoint(x: scrollView.frame.size.width * CGFloat(_datasource!.count - 2), y: 0)
        }
    }
}

간단히 정리하자면 이 scrollViewDidScroll은 스크롤을 해서 맨 마지막 부분에 도달했다면(맨 첫 번째 요소가 화면에 출력되고 있을 것이다) contentOffset을 동일한 요소가 있는 앞 쪽으로 설정해주고, 스크롤을 해서 맨 첫 번째 부분에 도달했다면(맨 마지막 요소가 화면에는 출력되고 있을 것이다) contentOffset을 동일한 요소가 있는 뒷 부분으로 설정해주는 것이다.

image

Step 4: Selected Option을 전달하기 위한 delegate 생성

우리는 여기서 뷰 컨트롤러로 우리가 선택한 옵션을 다시 전달할 수 있게 해야 한다. 이를 위한 좋은 방법은 delegate pattern을 사용하는 것이다.

optionChanged(to option:)을 가진 InfiniteScrollViewDelegate 프로토콜을 선언한다. 그리고 delegate 프로퍼티를 옵셔널 protocol 타입으로 생성하고, didSet 프로퍼티 감시자로 이 delegate 메서드를 부르는 selectedOption 문자열 프로퍼티를 생성한다. 이제 selectedOption을 설정할 때마다 delegate는 selection을 수용한다. 이 프로퍼티를 설정하는 장소가 두 곳이 있다. Content view가 배치되었을 때, 우리는 첫 번째 옵션을 보여주기 위해 우리의 content offset을 첫 번재 요소로 변경하는데, 이 때 selectedOption을 변경하고, 페이징 애니메이션이 시작하기 전에 사용자가 탭을 하면 이 selectedOption을 설정한다.

protocol InfiniteScrollViewDelegate {
   func optionChanged(to option: String)
}

class InfiniteScrollView: UIView {

   var selectedOption: String! {
       didSet {
           self.delegate?.optionChanged(to: selectedOption)
       }
   }

   var delegate: InfiniteScrollViewDelegate?

   private func setupContentView() {

        let subviews = scrollView.subviews
        for subview in subviews {
            subview.removeFromSuperview()
        }

        guard let data = _datasource else { return }

        self.scrollView.contentSize = CGSize(width: scrollView.frame.size.width * CGFloat(data.count),
                                             height: scrollView.frame.size.height)

        for i in 0..<data.count {
            var frame = CGRect()
            frame.origin.x = scrollView.frame.size.width * CGFloat(i)
            frame.origin.y = 0
            frame.size = scrollView.frame.size

            let label = UILabel(frame: frame)
            label.text = data[i]
            self.scrollView.addSubview(label)
        }
        let index = 1
        scrollView.contentOffset = CGPoint(x: (scrollView.frame.width * CGFloat(index)), y: 0)
        self.selectedOption = data[index]
    }

    @objc
    func didReceiveTap(sender: UITapGestureRecognizer) {
       guard let data = datasource else { return }

       var index = Int(scrollView.contentOffset.x / scrollView.frame.width)
       index = index < data.count ? index : 0
       self.selectedOption = data[index]

        let x = scrollView.contentOffset.x
        let nextRect = CGRect(x: x + scrollView.frame.width,
                              y: 0,
                              width: scrollView.frame.width,
                              height: scrollView.frame.height)

        scrollView.scrollRectToVisible(nextRect, animated: true)
    }
}

참고로 여기서 새로운 rect를 만들어서 scrollView.scrollRectToVisible() 메서드를 사용하는데, 이 메서드는 content의 특정 영역으로 스크롤링 해서 화면에 보여질 수 있게 하는 메서드이다. 인자로 받는 rect는 content view의 특정 영역을 나타내는 사각형으로, 스크롤 뷰 내에 위치한 영역이어야 한다. 이미 보여지고 있는 부분일 경우 아무런 동작도 하지 않는다.

뷰 컨트롤러에서는 아래와 같이 동작한다.

//In the VC
self.infiniteScrollView.delegate = self

extension ViewController: InfiniteScrollViewDelegate {

    func optionChanged(to option: String) {
        print("delegate called with option: \(option)")
    }

}