diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index d37e3f8c..c70712cf 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -22,6 +22,8 @@ 71C02B382B7BD98F00E93E66 /* NotificationChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 71C02B372B7BD98F00E93E66 /* NotificationChangeLog.txt */; }; 71FC86DA2B96F44C00700965 /* PaginationButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71FC86D92B96F44C00700965 /* PaginationButton.swift */; }; 71FC86DC2B96F4C800700965 /* PaginationCellItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71FC86DB2B96F4C800700965 /* PaginationCellItem.swift */; }; + 71FC86E22B97483000700965 /* Clamping.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71FC86E12B97483000700965 /* Clamping.swift */; }; + 71FC86E42B9841AC00700965 /* PaginationFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71FC86E32B9841AC00700965 /* PaginationFlowLayout.swift */; }; EA0B18022A9E236900F2D0CD /* SelectorGroupBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0B18012A9E236900F2D0CD /* SelectorGroupBase.swift */; }; EA0B18052A9E2D2D00F2D0CD /* SelectorBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0B18032A9E2D2D00F2D0CD /* SelectorBase.swift */; }; EA0B18062A9E2D2D00F2D0CD /* SelectorItemBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA0B18042A9E2D2D00F2D0CD /* SelectorItemBase.swift */; }; @@ -194,6 +196,8 @@ 71C02B372B7BD98F00E93E66 /* NotificationChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = NotificationChangeLog.txt; sourceTree = ""; }; 71FC86D92B96F44C00700965 /* PaginationButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationButton.swift; sourceTree = ""; }; 71FC86DB2B96F4C800700965 /* PaginationCellItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationCellItem.swift; sourceTree = ""; }; + 71FC86E12B97483000700965 /* Clamping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Clamping.swift; sourceTree = ""; }; + 71FC86E32B9841AC00700965 /* PaginationFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaginationFlowLayout.swift; sourceTree = ""; }; EA0B18012A9E236900F2D0CD /* SelectorGroupBase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectorGroupBase.swift; sourceTree = ""; }; EA0B18032A9E2D2D00F2D0CD /* SelectorBase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectorBase.swift; sourceTree = ""; }; EA0B18042A9E2D2D00F2D0CD /* SelectorItemBase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectorItemBase.swift; sourceTree = ""; }; @@ -398,6 +402,7 @@ 71B23C2C2B91FA690027F7D9 /* Pagination.swift */, 71FC86D92B96F44C00700965 /* PaginationButton.swift */, 71FC86DB2B96F4C800700965 /* PaginationCellItem.swift */, + 71FC86E32B9841AC00700965 /* PaginationFlowLayout.swift */, 71B5FCBA2B95A0CA00269BCC /* PaginationChangeLog.txt */, ); path = Pagination; @@ -603,6 +608,7 @@ isa = PBXGroup; children = ( EA3361BC288B2C760071C351 /* TypeAlias.swift */, + 71FC86E12B97483000700965 /* Clamping.swift */, ); path = Utilities; sourceTree = ""; @@ -1022,6 +1028,7 @@ EA3361C328902D960071C351 /* Toggle.swift in Sources */, EAF7F0A0289AB7EC00B287F5 /* View.swift in Sources */, EA89201328B568D8006B9984 /* RadioBoxItem.swift in Sources */, + 71FC86E42B9841AC00700965 /* PaginationFlowLayout.swift in Sources */, EAC9258C2911C9DE00091998 /* InputField.swift in Sources */, EA3362402892EF6C0071C351 /* Label.swift in Sources */, EAB2376229E9880400AABE9A /* TrailingTooltipLabel.swift in Sources */, @@ -1030,6 +1037,7 @@ 71BFA70A2B7F70E6000DCE33 /* Dropshadowable.swift in Sources */, EA0D1C452A6AD73000E5C127 /* RawRepresentable.swift in Sources */, EA985C23296E033A00F2FF2E /* TextArea.swift in Sources */, + 71FC86E22B97483000700965 /* Clamping.swift in Sources */, EAF7F0B3289B1ADC00B287F5 /* ActionLabelAttribute.swift in Sources */, EAC925832911B35400091998 /* TextLinkCaret.swift in Sources */, EA33622E2891EA3C0071C351 /* DispatchQueue+Once.swift in Sources */, diff --git a/VDS/Components/Pagination/Pagination.swift b/VDS/Components/Pagination/Pagination.swift index 170e1935..f4bf8a60 100644 --- a/VDS/Components/Pagination/Pagination.swift +++ b/VDS/Components/Pagination/Pagination.swift @@ -9,58 +9,24 @@ import Foundation import VDSColorTokens 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 { - open var onPageDidSelect: ((Int) -> Void)? - - public let previousButton: PaginationButton = .init(type: .previous) - public let nextButton: PaginationButton = .init(type: .next) - - public var total: Int = 0 { - didSet { - previousButton.isHidden = true - nextButton.isHidden = total <= 1 - _selectedPage = 0 - setNeedsUpdate() - } - } - - public var selectedPage: Int { - set { - if newValue >= total { - _selectedPage = total - 1 - } else if newValue < 0 { - _selectedPage = 0 - } else { - _selectedPage = max(newValue - 1, 0) - } - setNeedsUpdate() - updateSelection() - } - get { - _selectedPage - } - } - - private var _selectedPage: Int = 0 - private let pageItemCellSize: CGSize = .init(width: 20, height: 16) - private let spacingBetweenCell: CGFloat = VDSLayout.Spacing.space1X.value - + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + ///Collectionview width anchor + private var collectionViewWidthAnchor: 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 private let containerView: View = View().with { $0.translatesAutoresizingMaskIntoConstraints = false } - - private lazy var flowLayout: UICollectionViewFlowLayout = { - let layout = UICollectionViewFlowLayout() - layout.scrollDirection = .horizontal - layout.minimumInteritemSpacing = spacingBetweenCell - layout.minimumLineSpacing = spacingBetweenCell - layout.sectionInset = .zero - layout.estimatedItemSize = pageItemCellSize - return layout - }() - + ///Collectionview to render pagination indexes private lazy var collectionView: UICollectionView = { let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout) collectionView.isScrollEnabled = false @@ -74,6 +40,48 @@ open class Pagination: View { return collectionView }() + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- + ///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). + public 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. + public 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 + } + } + + //-------------------------------------------------- + // MARK: - Overrides + //-------------------------------------------------- + /// Executed on initialization for this View. open override func initialSetup() { super.initialSetup() @@ -94,56 +102,100 @@ open class Pagination: View { collectionView.centerYAnchor.constraint(equalTo: centerYAnchor).activate() collectionView.centerXAnchor.constraint(equalTo: centerXAnchor).activate() collectionView.trailingAnchor.constraint(greaterThanOrEqualTo: nextButton.leadingAnchor).activate() - collectionView.widthAnchor.constraint(equalToConstant: 92).activate() + collectionViewWidthAnchor = collectionView.widthAnchor.constraint(equalToConstant: 92) + collectionViewWidthAnchor?.activate() + 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 + }.store(in: &subscribers) } + /// 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 - if isNextAction { - _selectedPage += 1 - } else { - _selectedPage -= 1 - } + _selectedPageIndex = if isNextAction { _selectedPageIndex + 1 } else { _selectedPageIndex - 1 } updateSelection() } + ///Refreshing the UI based on the selected page private func updateSelection() { - let indexPath = IndexPath(row: selectedPage, section: 0) - collectionView.scrollToItem(at: indexPath, at: .left, animated: false) - previousButton.isHidden = selectedPage == 0 - nextButton.isHidden = selectedPage == total - 1 + guard _selectedPageIndex < total else { return } + collectionView.scrollToItem(at: IndexPath(row: _selectedPageIndex, section: 0), at: .left, animated: false) + previousButton.isHidden = _selectedPageIndex == 0 + nextButton.isHidden = _selectedPageIndex == total - 1 collectionView.reloadData() + verifyIfMaxDigitChanged() + } + + ///Identifying if there is any change in the digits of upcoming page + func verifyIfMaxDigitChanged() { + let upperLimitPage = _selectedPageIndex + flowLayout.maxNumberOfColumns + let upperLimitDigits = upperLimitPage.digitCount //future value digits + switch (flowLayout.numberOfColumns, upperLimitDigits) { + case (_, 3), (_, 4): + flowLayout.numberOfColumns = 3 + default: + flowLayout.numberOfColumns = 4 + } + if upperLimitDigits != flowLayout.upperLimitDigits { + flowLayout.upperLimitDigits = upperLimitDigits + flowLayout.invalidateLayout() + collectionView.reloadData() + collectionView.scrollToItem(at: IndexPath(row: self._selectedPageIndex, 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(selectedPage, currentIndex: indexPath.row, surface: surface) + cell.update(_selectedPageIndex, currentIndex: indexPath.row, surface: surface) return cell } public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - _selectedPage = indexPath.row + _selectedPageIndex = indexPath.row updateSelection() - onPageDidSelect?(indexPath.row) + onPageDidSelect?(selectedPage) + } +} + +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) } } diff --git a/VDS/Components/Pagination/PaginationButton.swift b/VDS/Components/Pagination/PaginationButton.swift index 6a1cd3fa..3805051d 100644 --- a/VDS/Components/Pagination/PaginationButton.swift +++ b/VDS/Components/Pagination/PaginationButton.swift @@ -8,13 +8,20 @@ import UIKit import VDSColorTokens +///This is customised button for Pagination view open class PaginationButton: ButtonBase { - + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + /// Type of the PaginationButton + private var type: Type = .next + /// Button tint color configuration private let buttonTintColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteBlack, VDSColor.paletteWhite) + /// Button title color configuration private let buttonTextColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteBlack, VDSColor.paletteWhite) - + /// Button configuration for iOS 15+ @available(iOS 15.0, *) - var buttonConfiguration: Button.Configuration { + private var buttonConfiguration: Button.Configuration { var configuration = ButtonBase.Configuration.plain() configuration.imagePadding = VDSLayout.Spacing.space2X.value configuration.imagePlacement = type == .next ? .trailing : .leading @@ -23,12 +30,17 @@ open class PaginationButton: ButtonBase { return configuration } + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- + /// TextStyle used on the titleLabel. open override var textStyle: TextStyle { TextStyle.boldBodySmall } - + /// UIColor used on the titleLabel text. open override var textColor: UIColor { buttonTextColorConfiguration.getColor(surface) } - private var type: Type = .next - + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- init(type: Type) { self.type = type super.init() @@ -42,6 +54,10 @@ open class PaginationButton: ButtonBase { super.init(coder: coder) } + //-------------------------------------------------- + // MARK: - Overrides + //-------------------------------------------------- + /// Executed on initialization for this View. open override func initialSetup() { super.initialSetup() if #available(iOS 15.0, *) { @@ -53,6 +69,7 @@ open class PaginationButton: ButtonBase { contentHorizontalAlignment = type == .next ? .trailing : .leading } + /// Used to make changes to the View based off a change events or from local properties. open override func updateView() { text = type.title setImage(type.image, for: .normal) @@ -62,7 +79,9 @@ open class PaginationButton: ButtonBase { } extension PaginationButton { - + //-------------------------------------------------- + // MARK: - Enum to configure PaginationButton + //-------------------------------------------------- enum `Type` { case previous, next @@ -74,7 +93,7 @@ extension PaginationButton { "Previous" } } - + ///Image for the configuration type var image: UIImage? { switch self { case .previous: diff --git a/VDS/Components/Pagination/PaginationCellItem.swift b/VDS/Components/Pagination/PaginationCellItem.swift index 1a546551..68fa7271 100644 --- a/VDS/Components/Pagination/PaginationCellItem.swift +++ b/VDS/Components/Pagination/PaginationCellItem.swift @@ -8,18 +8,27 @@ import UIKit import VDSColorTokens +///This is customised view for Pagination cell item final class PaginationCellItem: UICollectionViewCell { + ///Identifier for the PaginationCellItem static let identifier: String = String(describing: PaginationCellItem.self) - + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + ///Text color configuration for the element private let textColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark) - + ///Pagination index label private var indexLabel: Label = Label().with { $0.translatesAutoresizingMaskIntoConstraints = false $0.textAlignment = .center $0.numberOfLines = 1 } + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- override init(frame: CGRect) { super.init(frame: frame) setUp() @@ -30,6 +39,7 @@ final class PaginationCellItem: UICollectionViewCell { setUp() } + ///Configuring the cell with default setup private func setUp() { let containerView = View() containerView.translatesAutoresizingMaskIntoConstraints = false @@ -43,6 +53,7 @@ final class PaginationCellItem: UICollectionViewCell { indexLabel.backgroundColor = .clear } + ///Updating UI based on selected index, current index along with surface func update(_ selectedIndex: Int, currentIndex: Int, surface: Surface) { indexLabel.textStyle = selectedIndex == currentIndex ? .boldBodySmall : .bodySmall indexLabel.text = "\(currentIndex + 1)" diff --git a/VDS/Components/Pagination/PaginationFlowLayout.swift b/VDS/Components/Pagination/PaginationFlowLayout.swift new file mode 100644 index 00000000..89debfe2 --- /dev/null +++ b/VDS/Components/Pagination/PaginationFlowLayout.swift @@ -0,0 +1,91 @@ +// +// PaginationFlowLayout.swift +// VDS +// +// Created by Bandaru, Krishna Kishore on 06/03/24. +// + +import Foundation +import UIKit + +///Customised flow layout for Pagination view +final class PaginationFlowLayout : UICollectionViewLayout { + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + ///Spacing between the pagination cells + private let spacingBetweenCell: CGFloat = VDSLayout.Spacing.space1X.value + ///Pre-defined sizes of the pagination cell based on number of digits. + private var upperLimitSize: CGSize { + switch upperLimitDigits { + case 3: .init(width: 28, height: 16) + case 4: .init(width: 34, height: 16) + default: .init(width: 20, height: 16) + } + } + ///Property to store the defined layout attributes. + private var itemCache : [UICollectionViewLayoutAttributes] = [] + + //-------------------------------------------------- + // MARK: - Internal Properties + //-------------------------------------------------- + ///Maximum number of page indexes shown on UI + let maxNumberOfColumns: Int = 4 + ///Number of digits of the maximum page index. + var upperLimitDigits: Int = 0 + ///Number of page indexes shown on UI. + var numberOfColumns: Int = 4 + ///A property that publishes when there is change in collection view width. + @Published var collectionViewWidth: CGFloat = 0 + + //-------------------------------------------------- + // MARK: - Overrides + //-------------------------------------------------- + ///Preparing the layout collection attributes for pagination and updating the collectionview width. + override func prepare() { + + guard let collectionView else { return } + + itemCache.removeAll() + var xPos : CGFloat = 0 + for item in 0.. [UICollectionViewLayoutAttributes]? { + var visibleLayoutAttributes: [UICollectionViewLayoutAttributes] = [] + for attributes in itemCache { + if attributes.frame.intersects(rect) { + visibleLayoutAttributes.append(attributes) + } + } + return visibleLayoutAttributes + } + + ///This will return the layout attributes at particular indexPath + override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? { + return itemCache[indexPath.row] + } + + ///Returns the collectionview content size + override var collectionViewContentSize: CGSize { + guard let lastAttribute = itemCache.last else { return super.collectionViewContentSize } + return .init(width: lastAttribute.frame.width + lastAttribute.frame.origin.x, height: 16) + } +} diff --git a/VDS/Utilities/Clamping.swift b/VDS/Utilities/Clamping.swift new file mode 100644 index 00000000..c8213828 --- /dev/null +++ b/VDS/Utilities/Clamping.swift @@ -0,0 +1,24 @@ +// +// Clamping.swift +// VDS +// +// Created by Bandaru, Krishna Kishore on 05/03/24. +// + +import Foundation + +@propertyWrapper public struct Clamping { + + private var value: Value + private let range: ClosedRange + + public init(range: ClosedRange) { + self.value = range.lowerBound + self.range = range + } + + public var wrappedValue: Value { + get { value } + set { value = min(max(range.lowerBound, newValue), range.upperBound) } + } +}