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
이다. 이제 ViewController
의 ASCollectionNode
의 dataSource
를 DataProvider
와 연결해주자.
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
일테니 옵셔널이 지저분하다면 !
를 통해 강제로 옵셔널을 풀어주면된다.