[Swift] ASCollectionNode, UIPageControl를 Rx로 깔끔하게 페이지 처리하기

Texture에서는 페이징 처리를 위해 ASPagerNode가 구현되어있다. 근데 나는 개인적으로 ASCollectionNode 로 대부분의 UI를 구현하는 편이다. 이번에도 구현할려고 했는데 UIPageControl 와 함께 Rx로 작성한 코드를 공유하고자 한다.

일단 정말 간단한 형태의 UI 코드를 작성하였다.

import AsyncDisplayKit

class ViewController: ASDKViewController<ASDisplayNode> {

    fileprivate enum Const {
        static let itemSize: CGSize = .init(width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.width * 1.15)
    }
    
    // MARK: - UI
    fileprivate let collectionNode: ASCollectionNode = {
        let flowLayout = ASPagerFlowLayout()
        flowLayout.minimumLineSpacing = 0
        flowLayout.minimumInteritemSpacing = 0
        flowLayout.headerReferenceSize = .zero
        flowLayout.footerReferenceSize = .zero
        flowLayout.scrollDirection = .horizontal
        flowLayout.sectionInset = .zero
        flowLayout.itemSize = Const.itemSize
        
        let node = ASCollectionNode(collectionViewLayout: flowLayout)
        node.view.isPagingEnabled = true
        node.view.bounces = false
        node.showsVerticalScrollIndicator = false
        node.showsHorizontalScrollIndicator = false
        node.backgroundColor = .clear
        return node
    }()
    
    fileprivate let pageControl: ASDisplayNode = {
        let node = ASDisplayNode(viewBlock: {
            let pageControl = UIPageControl()
            pageControl.currentPage = 0
            pageControl.numberOfPages = 5
            pageControl.isUserInteractionEnabled = false
            pageControl.currentPageIndicatorTintColor = .init(red: 38 / 255, green: 38 / 255, blue: 38 / 255, alpha: 1.0)
            pageControl.pageIndicatorTintColor = .init(red: 38 / 255, green: 38 / 255, blue: 38 / 255, alpha: 0.2)
            return pageControl
        })
        return node
    }()
    
    // MARK: - Life Cycles
    override init(node: ASDisplayNode) {
        super.init(node: .init())
        self.node.automaticallyManagesSubnodes = true
        self.node.layoutSpecBlock = { [weak self] _, constrainedSize -> ASLayoutSpec in
            self?.layoutSpecThatFits(constrainedSize) ?? ASLayoutSpec()
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: - LayoutSpec
    private func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {
        self.collectionNode.style.preferredSize = Const.itemSize
        
        self.pageControl.style.width = .init(unit: .fraction, value: 1)
        self.pageControl.style.height = .init(unit: .points, value: 30)
        
        let stackLayout = ASStackLayoutSpec(
            direction: .vertical,
            spacing: 20,
            justifyContent: .center,
            alignItems: .center,
            children: [
                self.collectionNode, self.pageControl
            ]
        )
        return stackLayout
    }

}

그리고 이제 ASCollectionNode 에 데이터를 제공해줄 DataProvider 파일을 작성하였다.

import Foundation

import UIKit
import AsyncDisplayKit

typealias UIImages = [UIImage]
class NodeDataProvider: NSObject {
    var images: UIImages?
    weak var collectionNode: ASCollectionNode?

    convenience init(with images: UIImages?) {
        self.init()
        self.images = images
    }
}


extension NodeDataProvider: ASCollectionDataSource {
    
    func numberOfSections(in collectionNode: ASCollectionNode) -> Int {
        1
    }
 
    func collectionNode(_ collectionNode: ASCollectionNode, numberOfItemsInSection section: Int) -> Int {
        self.images?.count ?? 0
    }
    
    func collectionNode(_ collectionNode: ASCollectionNode, nodeBlockForItemAt indexPath: IndexPath) -> ASCellNodeBlock {
        guard let images = self.images else { return { ASCellNode() } }
        let image = images[indexPath.row]
        let cellNodeBlock = { () -> ASCellNode in
            let cellNode = FullSizeImageNode(with: image)
            return cellNode
        }
        return cellNodeBlock
    }
    
}

FullSizeImageNode 는 단순하게 UIImageView를 풀 사이즈로 구성하고 있는 ASCellNode 이다. 이제 ViewControllerASCollectionNodedataSourceDataProvider와 연결해주자.

class ViewController: ASDKViewController<ASDisplayNode> {

    // MARK: - Variables
    fileprivate private(set) var dataProvider: NodeDataProvider!

    // MARK: - Life Cycles
    override init(node: ASDisplayNode) {
        super.init(node: .init())
        
        self.dataProvider = .init(with: contentItems)
        self.dataProvider.collectionNode = self.collectionNode

        self.collectionNode.dataSource = self.dataProvider
    }
}

여기까지는 평범하게 잘된다. 그런데 이제 스크롤을 하면 UIPageControl의 값이 변경되지 않는다.. Rx를 확장해보자.

extension Reactive where Base: ASCollectionNode {

    var delegate: DelegateProxy<ASCollectionNode, ASCollectionDelegate> {
        RxASCollectionNodeDelegateProxy.proxy(for: self.base)
    }

    /// Reactive wrapper for `delegate` message `collectionNode(_:scrollViewDidScroll:)`.
    var currentPage: Observable<Int> {
        delegate.methodInvoked(#selector(ASCollectionDelegate.scrollViewDidScroll(_:)))
            .map { _ in
                let scrollWidth = self.base.frame.width
                return Int(floor((self.base.contentOffset.x - scrollWidth / 2) / scrollWidth ) + 1)
            }
    }
    
}

class RxASCollectionNodeDelegateProxy: DelegateProxy<ASCollectionNode, ASCollectionDelegate> ,
      DelegateProxyType ,
      ASCollectionDelegate {
    static func registerKnownImplementations() {
        self.register { RxASCollectionNodeDelegateProxy(parentObject: $0, delegateProxy: self) }
    }

    static func currentDelegate(for object: ASCollectionNode) -> ASCollectionDelegate? {
        object.delegate
    }

    static func setCurrentDelegate(_ delegate: ASCollectionDelegate?, to object: ASCollectionNode) {
        object.delegate = delegate
    }
}
extension Reactive where Base: UIPageControl {
    
    var currentPage: ASBinder<Int> {
        return ASBinder(self.base) { object, page in
            object.currentPage = page
        }
    }
    
}

이제 사용할 준비가 끝났다. 아래와 같이 Bind 해주면 된다 😀

if let pageControl = self.pageControl.view as? UIPageControl {
    self.collectionNode
        .rx.currentPage
        .bind(to: pageControl.rx.currentPage)
        .disposed(by: self.disposeBag)
}

참고로 ASDisplayNode 로 Wrapping 되어있기 때문에 변환을 한번 해주어야한다. 99.9%의 확률로 UIPageControl 일테니 옵셔널이 지저분하다면 ! 를 통해 강제로 옵셔널을 풀어주면된다.