// // Pagination.swift // VDS // // Created by Bandaru, Krishna Kishore on 01/03/24. // import Foundation import UIKit import VDSCoreTokens import Combine ///Pagination is a control that enables customers to navigate multiple pages of content by selecting either a specific page or the next or previous set of four pages. @objc(VDSPagination) open class Pagination: View { //-------------------------------------------------- // MARK: - Private Properties //-------------------------------------------------- private let pageChangedSubject = PassthroughSubject() ///Maximum component width private let maxWidth: CGFloat = 288.0 ///Collectionview width anchor private var collectionViewWidthAnchor: NSLayoutConstraint? ///Collectionview container Center X constraint private var collectionContainerViewCenterXConstraint: NSLayoutConstraint? ///Selected page index private var _selectedPageIndex: Int = 0 ///Custom flow layout defined for the Pagination private let flowLayout = PaginationFlowLayout() ///A root view for the pagination public let containerView: View = View().with { $0.translatesAutoresizingMaskIntoConstraints = false } ///Collectionview to render pagination indexes private lazy var collectionView: UICollectionView = { let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout) collectionView.isScrollEnabled = false collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.showsHorizontalScrollIndicator = false collectionView.showsVerticalScrollIndicator = false collectionView.isAccessibilityElement = true collectionView.register(PaginationCellItem.self, forCellWithReuseIdentifier: PaginationCellItem.identifier) collectionView.backgroundColor = .clear collectionView.delegate = self collectionView.dataSource = self return collectionView }() ///Container view to hold collectionview to render pagination indexes and to handler accessibility. private let collectionContainerView = PaginationContainer() //-------------------------------------------------- // MARK: - Public Properties //-------------------------------------------------- public var pageChangedPublisher: AnyPublisher { pageChangedSubject.eraseToAnyPublisher() } ///Previous button to select previous page public let previousButton: PaginationButton = .init(type: .previous) ///Next button to select next page public let nextButton: PaginationButton = .init(type: .next) /// A callback when the page changes. Passes parameters (selectedPage). open var onPageDidSelect: ((Int) -> Void)? /// Total number of pages, allows limit ranging from 0 to 9999. @Clamping(range: 0...9999) public var total: Int { didSet { previousButton.isHidden = true nextButton.isHidden = total <= 1 _selectedPageIndex = 0 setNeedsUpdate() updateSelection() } } ///Selected active page number and clips to total pages if selected index is greater than the total pages. open var selectedPage: Int { set { if newValue >= total { _selectedPageIndex = total - 1 } else if newValue < 0 { _selectedPageIndex = 0 } else { _selectedPageIndex = max(newValue - 1, 0) } setNeedsUpdate() updateSelection() } get { _selectedPageIndex + 1 //Returns selected page value not index } } private var paginationDescription: String { "Page \(selectedPage) of \(total) selected" } //-------------------------------------------------- // MARK: - Overrides //-------------------------------------------------- /// Executed on initialization for this View. open override func setup() { super.setup() collectionContainerView.addSubview(collectionView) containerView.addSubview(previousButton) containerView.addSubview(collectionContainerView) containerView.addSubview(nextButton) addSubview(containerView) containerView .pinTop() .pinBottom() .pinLeadingGreaterThanOrEqualTo() .pinTrailingLessThanOrEqualTo() .pinCenterX() .width(maxWidth) .height(44) previousButton .pinTop() .pinBottom() .pinLeading() .pinTrailingGreaterThanOrEqualTo(collectionContainerView.leadingAnchor) collectionContainerView .pinTrailingGreaterThanOrEqualTo(nextButton.leadingAnchor) .pinTop() .pinBottom() collectionView .height(VDSLayout.space4X) .pinCenterY() .pinCenterX() collectionViewWidthAnchor = collectionView.width(constant: 92) nextButton .pinTop() .pinBottom() .pinTrailing() nextButton.onClick = onbuttonTapped previousButton.onClick = onbuttonTapped previousButton.isHidden = true flowLayout.$collectionViewWidth .receive(on: RunLoop.main) .sink { [weak self] value in self?.collectionViewWidthAnchor?.constant = value //As cell width is dynamic i.e cell may contain 2 or 3 or 4 charcters. Make sure that all the visible cells are displayed. }.store(in: &subscribers) pageChangedPublisher.sink { [weak self] control in guard let self else { return } onPageDidSelect?(control.selectedPage) }.store(in: &subscribers) } open override func setDefaults() { super.setDefaults() collectionContainerView.onAccessibilityIncrement = { [weak self] in guard let self else { return } self.selectedPage = max(0, self.selectedPage + 1) } collectionContainerView.onAccessibilityDecrement = { [weak self] in guard let self else { return } self.selectedPage = max(0, self.selectedPage - 1) } collectionContainerView.bridge_accessibilityLabelBlock = { [weak self] in guard let self else { return "" } return "Pagination containing \(total) pages" } collectionContainerView.bridge_accessibilityValueBlock = { [weak self] in guard let self else { return "" } return paginationDescription } } open override var accessibilityElements: [Any]? { get { let views: [UIView] = [previousButton, collectionContainerView, nextButton] return views.filter({ $0.isHidden == false }) } set { } } /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { super.updateView() nextButton.surface = surface previousButton.surface = surface collectionView.reloadData() } //-------------------------------------------------- // MARK: - Private Methods //-------------------------------------------------- ///When previous/next button is tapped private func onbuttonTapped(_ sender: UIButton) { let isNextAction = sender == nextButton _selectedPageIndex = if isNextAction { _selectedPageIndex + 1 } else { _selectedPageIndex - 1 } updateSelection() pageChangedSubject.send(self) DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in guard let self else { return } UIAccessibility.post(notification: .announcement, argument: paginationDescription) } } ///Refreshing the UI based on the selected page private func updateSelection() { guard _selectedPageIndex < total else { return } //Need to make selected page as second element so scrolling previous index of the selected page to left collectionView.scrollToItem(at: IndexPath(row: max(_selectedPageIndex - 1, 0), section: 0), at: .left, animated: false) previousButton.isHidden = _selectedPageIndex == 0 nextButton.isHidden = _selectedPageIndex == total - 1 collectionView.reloadData() verifyIfMaxDigitChanged() setNeedsUpdate() } ///Identifying if there is any change in the digits of upcoming page private func verifyIfMaxDigitChanged() { let upperLimitPage = _selectedPageIndex + flowLayout.maxNumberOfColumns let upperLimitDigits = upperLimitPage.digitCount //future value digits switch (flowLayout.numberOfColumns, upperLimitDigits) { case (_, 1), (_, 2): flowLayout.numberOfColumns = 4 default: flowLayout.numberOfColumns = 3 } if upperLimitDigits != flowLayout.upperLimitDigits { flowLayout.upperLimitDigits = upperLimitDigits flowLayout.invalidateLayout() collectionView.reloadData() //Need to make selected page as second element so scrolling previous index of the selected page to left collectionView.scrollToItem(at: IndexPath(row: max(_selectedPageIndex - 1, 0), section: 0), at: .left, animated: false) } } } extension Pagination: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { //-------------------------------------------------- // MARK: - UICollectionView Delegate & Datasource //-------------------------------------------------- public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { total } public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PaginationCellItem.identifier, for: indexPath) as? PaginationCellItem else { return UICollectionViewCell() } cell.update(_selectedPageIndex, currentIndex: indexPath.row, surface: surface) return cell } public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { guard _selectedPageIndex != indexPath.row else { return } _selectedPageIndex = indexPath.row updateSelection() pageChangedSubject.send(self) } } fileprivate extension Int { //-------------------------------------------------- // MARK: - Extension on Int to identify number of digits in given number. //-------------------------------------------------- var digitCount: Int { numberOfDigits(in: self) } private func numberOfDigits(in number: Int) -> Int { number < 10 && number >= 0 ? 1 : 1 + numberOfDigits(in: number/10) } }