From efc0a23b8f05bec4c114bb2eb14540559d0469ba Mon Sep 17 00:00:00 2001 From: Dmitry Demyanov Date: Tue, 15 Aug 2023 17:08:35 +0300 Subject: [PATCH 1/5] added cell alignment logic --- .../ItemsScrollManager.swift | 146 +++++++++++++----- 1 file changed, 108 insertions(+), 38 deletions(-) diff --git a/Utils/ItemsScrollManager/ItemsScrollManager.swift b/Utils/ItemsScrollManager/ItemsScrollManager.swift index fe986a3..8f7cc78 100644 --- a/Utils/ItemsScrollManager/ItemsScrollManager.swift +++ b/Utils/ItemsScrollManager/ItemsScrollManager.swift @@ -5,12 +5,13 @@ // Created by Александр Чаусов on 08/01/2019. // Copyright © 2019 Surf. All rights reserved. // +// swiftlint:disable line_length import UIKit -/// Manager allows you to organize the scroll inside the carousel in such a way that -/// the beginning of a new element always appears on the left of the screen. -/// To organize a scroll, it is enough to create an instance of the manager +/// Manager allows you to organize scrolling inside the carousel in such a way that +/// the new element always appears according to specified alignment. +/// To organize scrolling, it is enough to create an instance of the manager /// and call two of its methods at the necessary points described in the example below. /// /// Example of usage: @@ -37,45 +38,74 @@ import UIKit /// ``` public final class ItemsScrollManager { + public enum CellAlignment { + case left + case centered + case right + } + // MARK: - Private Properties private let cellWidth: CGFloat - private let cellOffset: CGFloat + private let cellSpacing: CGFloat private let insets: UIEdgeInsets - private var containerWidth: CGFloat + private let containerWidth: CGFloat + private let alignment: CellAlignment private var beginDraggingOffset: CGFloat = 0 private var lastOffset: CGFloat = 0 - private var currentPage: Int = 0 + + private var pageWidth: CGFloat { + cellWidth + cellSpacing + } + + private var cellAlignmentOffset: CGFloat { + switch alignment { + case .left: + return insets.left + case .centered: + return (containerWidth - cellWidth) / 2 + case .right: + return (containerWidth - cellWidth) - insets.right + } + } // MARK: - Initialization - /// Init method for the manager. + /// Creates the manager. /// /// Example of usage: /// ``` /// scrollManager = ItemsScrollManager(cellWidth: 200, /// cellOffset: 10, - /// insets: UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)) + /// insets: UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16), + /// alignment: .center) /// ``` /// /// - Parameters: /// - cellWidth: Items cell width - /// - cellOffset: Inter item space between two items inside the carousel + /// - cellOffset: Inter item space between two cells inside the carousel /// - insets: Insets for section with carousel items in collection view /// - containerWidth: Carousel width, by default equal to screen width - public init(cellWidth: CGFloat, cellOffset: CGFloat, - insets: UIEdgeInsets, containerWidth: CGFloat = UIScreen.main.bounds.width) { + /// - alignment: Rule for cell placement relative to the container. Defaults to `.left` for compatibility with the old version usages + public init( + cellWidth: CGFloat, + cellOffset: CGFloat, + insets: UIEdgeInsets, + containerWidth: CGFloat = UIScreen.main.bounds.width, + alignment: CellAlignment = .left + ) { self.cellWidth = cellWidth - self.cellOffset = cellOffset + self.cellSpacing = cellOffset self.insets = insets self.containerWidth = containerWidth + self.alignment = alignment } // MARK: - Public Methods - /// This method is used for setup begin dragging offset, when user start dragging scroll view. - /// You have to call this method inside UIScrollViewDelegate method scrollViewWillBeginDragging(...) + /// Used to save the dragging offset when user starts dragging the scroll view. + /// Should be called inside `UIScrollViewDelegate.scrollViewWillBeginDragging(...)` /// /// Example of usage: /// ``` @@ -90,8 +120,8 @@ public final class ItemsScrollManager { beginDraggingOffset = contentOffsetX } - /// This is main method, it used for update scroll view targetContentOffset, when user end dragging scroll view. - /// You have to call this method inside UIScrollViewDelegate method scrollViewWillEndDragging(...) + /// Used for replacing `targetContentOffset` when user ends dragging scroll view. + /// Should be called inside `UIScrollViewDelegate.scrollViewWillEndDragging(...)` /// /// Example of usage: /// ``` @@ -103,33 +133,73 @@ public final class ItemsScrollManager { /// ``` /// /// - Parameters: - /// - targetContentOffset: Scroll view targetContentOffset from delegate method scrollViewWillEndDragging(...) - /// - scrollView: Scroll view with carousel - public func setTargetContentOffset(_ targetContentOffset: UnsafeMutablePointer, - for scrollView: UIScrollView) { - let pageWidth = cellWidth + cellOffset - let firstCellOffset = insets.left - cellOffset - var targetX = targetContentOffset.pointee.x - var pageOffset: CGFloat = 0 - - if beginDraggingOffset == targetX && scrollView.isDecelerating { - // If we just tap somewhere we will not scroll to this point. We will use last offset - targetX = lastOffset + /// - targetContentOffset: Scroll view `targetContentOffset` pointer from delegate method `scrollViewWillEndDragging(...)` + /// - scrollView: Scroll view with the carousel + public func setTargetContentOffset( + _ targetContentOffset: UnsafeMutablePointer, + for scrollView: UIScrollView + ) { + let targetOffset = targetContentOffset.pointee.x + + // If offset hasn't changed, keep current position + if targetOffset == beginDraggingOffset, scrollView.isDecelerating { + targetContentOffset.pointee.x = lastOffset + return } - if lastOffset > targetX { - currentPage = Int(max(floor((targetX - firstCellOffset) / pageWidth), 0)) - } else if lastOffset < targetX { - let targetOffset = max(targetX - firstCellOffset, 1) - currentPage = Int(ceil(targetOffset / pageWidth)) + // Detect on which page the scroll will end + let pageProgress = getPageProgress(for: scrollView, targetOffset: targetOffset) + let currentPage = pageProgress.rounded(targetOffset < lastOffset ? .down : .up) + + // Detect which offset corresponds to the selected page + let cellOffset = insets.left + currentPage * pageWidth - cellAlignmentOffset + let normalizedOffset = min(max(0, cellOffset), scrollView.contentSize.width - containerWidth) + + // Save the result + lastOffset = normalizedOffset + targetContentOffset.pointee.x = normalizedOffset + } + + /// Detects the number of scrolled pages considering the cell alignment + /// Can be used to get page for `BeanPageControl` util + /// - Parameters: + /// - targetOffset: offset value for which you need to determine the page. By default, it is current `contentOffset.x` of the `scrollView` + public func getPageProgress(for scrollView: UIScrollView, targetOffset: CGFloat? = nil) -> CGFloat { + let offset = targetOffset ?? scrollView.contentOffset.x + + // Edge pages are shorter than normal ones, because edge cells are always aligned to the edge of the container + // Left offset for the first cell is `insets.left`, for normal ones - `cellAlignmentOffset` + let firstPageWidth = pageWidth - (cellAlignmentOffset - insets.left) + // Get progress for the first page + if offset < firstPageWidth { + return max(0, offset) / firstPageWidth + } + + // Right offset for normal cells + let reversedAdditionalOffset = containerWidth - (cellAlignmentOffset + cellWidth) + // Right offset for the last cell is `insets.right`, for normal ones - `reversedAdditionalOffset` + let lastPageWidth = pageWidth - (reversedAdditionalOffset - insets.right) + // Get progress for the last page + if offset > maxContentOffset(for: scrollView) - lastPageWidth { + // Progress for the last page is calculated in reverse direction, then subtracted from the last page index + let reversedProgress = (maxContentOffset(for: scrollView) - offset) / lastPageWidth + let pagesCount = (scrollView.contentSize.width + cellSpacing - (insets.left + insets.right)) / pageWidth + let lastPage = pagesCount - 1 + return lastPage - max(0, reversedProgress) } - let delta = firstCellOffset > 0 ? firstCellOffset : 0 + // For all other pages, the progress is calculated in the usual way, but taking into account the cell alignment + return (offset - insets.left + cellAlignmentOffset) / pageWidth + } + +} + +// MARK: - Private Methods + +private extension ItemsScrollManager { - pageOffset = currentPage == 0 ? 0 : CGFloat(currentPage) * pageWidth + delta - pageOffset = min(scrollView.contentSize.width - containerWidth, pageOffset) - lastOffset = pageOffset - targetContentOffset.pointee.x = pageOffset + func maxContentOffset(for scrollView: UIScrollView) -> CGFloat { + return scrollView.contentSize.width - containerWidth } } From 4daeea171ab7319a64f32ac1683eae86ac382cd0 Mon Sep 17 00:00:00 2001 From: Dmitry Demyanov Date: Tue, 15 Aug 2023 18:40:12 +0300 Subject: [PATCH 2/5] created Playbook demo page --- .../UtilsExample.xcodeproj/project.pbxproj | 56 +++++ UtilsExample/UtilsExample/AppDelegate.swift | 1 + .../ItemsScrollManagerCoordinator.swift | 33 +++ .../ItemsScrollManagerConfigurator.swift | 16 ++ .../ItemsScrollManagerViewController.swift | 224 ++++++++++++++++++ .../View/ItemsScrollManagerViewController.xib | 86 +++++++ .../ItemsScrollManagerExampleCell.swift | 57 +++++ .../ItemsScrollManagerParameterView.swift | 127 ++++++++++ 8 files changed, 600 insertions(+) create mode 100644 UtilsExample/UtilsExample/Playbook/Coordinators/ItemsScrollManagerCoordinator.swift create mode 100644 UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/Configurator/ItemsScrollManagerConfigurator.swift create mode 100644 UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/ItemsScrollManagerViewController.swift create mode 100644 UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/ItemsScrollManagerViewController.xib create mode 100644 UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/Subviews/ItemsScrollManagerExampleCell.swift create mode 100644 UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/Subviews/ItemsScrollManagerParameterView.swift diff --git a/UtilsExample/UtilsExample.xcodeproj/project.pbxproj b/UtilsExample/UtilsExample.xcodeproj/project.pbxproj index 58e03fc..aba52a6 100644 --- a/UtilsExample/UtilsExample.xcodeproj/project.pbxproj +++ b/UtilsExample/UtilsExample.xcodeproj/project.pbxproj @@ -7,6 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + 0A64D35A2A8A632E00255566 /* ItemsScrollManagerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A64D3582A8A632E00255566 /* ItemsScrollManagerViewController.swift */; }; + 0A64D35B2A8A632E00255566 /* ItemsScrollManagerViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 0A64D3592A8A632E00255566 /* ItemsScrollManagerViewController.xib */; }; + 0A64D3672A8A63B800255566 /* ItemsScrollManagerConfigurator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A64D3662A8A63B800255566 /* ItemsScrollManagerConfigurator.swift */; }; + 0A64D3692A8A64FC00255566 /* ItemsScrollManagerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A64D3682A8A64FC00255566 /* ItemsScrollManagerCoordinator.swift */; }; + 0A64D36D2A8A69A700255566 /* ItemsScrollManagerParameterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A64D36C2A8A69A600255566 /* ItemsScrollManagerParameterView.swift */; }; + 0A64D3702A8A7C6000255566 /* ItemsScrollManagerExampleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A64D36F2A8A7C6000255566 /* ItemsScrollManagerExampleCell.swift */; }; 90AAE30C2851F6100088A5A4 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90AAE30B2851F6100088A5A4 /* AppDelegate.swift */; }; 90AAE3152851F6110088A5A4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 90AAE3142851F6110088A5A4 /* Assets.xcassets */; }; 90AAE3182851F6110088A5A4 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 90AAE3162851F6110088A5A4 /* LaunchScreen.storyboard */; }; @@ -148,6 +154,12 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0A64D3582A8A632E00255566 /* ItemsScrollManagerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsScrollManagerViewController.swift; sourceTree = ""; }; + 0A64D3592A8A632E00255566 /* ItemsScrollManagerViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ItemsScrollManagerViewController.xib; sourceTree = ""; }; + 0A64D3662A8A63B800255566 /* ItemsScrollManagerConfigurator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsScrollManagerConfigurator.swift; sourceTree = ""; }; + 0A64D3682A8A64FC00255566 /* ItemsScrollManagerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsScrollManagerCoordinator.swift; sourceTree = ""; }; + 0A64D36C2A8A69A600255566 /* ItemsScrollManagerParameterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsScrollManagerParameterView.swift; sourceTree = ""; }; + 0A64D36F2A8A7C6000255566 /* ItemsScrollManagerExampleCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsScrollManagerExampleCell.swift; sourceTree = ""; }; 90719DA52851F8FC00B42F99 /* iOS-Utils */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "iOS-Utils"; path = ..; sourceTree = ""; }; 90AAE3082851F6100088A5A4 /* UtilsExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UtilsExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 90AAE30B2851F6100088A5A4 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -301,6 +313,42 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0A64D3542A8A621B00255566 /* ItemsScrollManager */ = { + isa = PBXGroup; + children = ( + 0A64D3572A8A630200255566 /* Configurator */, + 0A64D3552A8A62F300255566 /* View */, + ); + path = ItemsScrollManager; + sourceTree = ""; + }; + 0A64D3552A8A62F300255566 /* View */ = { + isa = PBXGroup; + children = ( + 0A64D36B2A8A699C00255566 /* Subviews */, + 0A64D3582A8A632E00255566 /* ItemsScrollManagerViewController.swift */, + 0A64D3592A8A632E00255566 /* ItemsScrollManagerViewController.xib */, + ); + path = View; + sourceTree = ""; + }; + 0A64D3572A8A630200255566 /* Configurator */ = { + isa = PBXGroup; + children = ( + 0A64D3662A8A63B800255566 /* ItemsScrollManagerConfigurator.swift */, + ); + path = Configurator; + sourceTree = ""; + }; + 0A64D36B2A8A699C00255566 /* Subviews */ = { + isa = PBXGroup; + children = ( + 0A64D36F2A8A7C6000255566 /* ItemsScrollManagerExampleCell.swift */, + 0A64D36C2A8A69A600255566 /* ItemsScrollManagerParameterView.swift */, + ); + path = Subviews; + sourceTree = ""; + }; 90719DA42851F8FC00B42F99 /* Packages */ = { isa = PBXGroup; children = ( @@ -392,6 +440,7 @@ A459CAEC2865E0C100AF463F /* BrightSide */, A459CA6D2864EA6500AF463F /* CustomSwitch */, A459CAD62865D44D00AF463F /* GeolocationService */, + 0A64D3542A8A621B00255566 /* ItemsScrollManager */, A459CB442865E6BF00AF463F /* KeyboardPresentable */, A459CB86286603FC00AF463F /* MoneyModel */, A459CAA72864F55E00AF463F /* QueryStringBuilder */, @@ -450,6 +499,7 @@ A459CB002865E15400AF463F /* BrightSideCoordinator.swift */, A459CA822864EB8000AF463F /* CustomSwitchCoordinator.swift */, A459CAEA2865DAC800AF463F /* GeolocationServiceCoordinator.swift */, + 0A64D3682A8A64FC00255566 /* ItemsScrollManagerCoordinator.swift */, A459CB582865E84600AF463F /* KeyboardPresentableCoordinator.swift */, A459CB9A2866051A00AF463F /* MoneyModelCoordinator.swift */, A459CABE2864F84600AF463F /* QueryStringBuilderCoordinator.swift */, @@ -1038,6 +1088,7 @@ A459CB6B2865E9B500AF463F /* SkeletonViewViewController.xib in Resources */, A459CB97286603FC00AF463F /* MoneyModelViewController.xib in Resources */, A459CAB62864F55E00AF463F /* QueryStringBuilderViewController.xib in Resources */, + 0A64D35B2A8A632E00255566 /* ItemsScrollManagerViewController.xib in Resources */, 90AAE3182851F6110088A5A4 /* LaunchScreen.storyboard in Resources */, A459CAFC2865E0C100AF463F /* BrightSideViewController.xib in Resources */, 90AAE3152851F6110088A5A4 /* Assets.xcassets in Resources */, @@ -1104,12 +1155,14 @@ A459CB2A2865E31900AF463F /* SettingsRouterViewInput.swift in Sources */, A459CB562865E6BF00AF463F /* KeyboardPresentableViewOutput.swift in Sources */, A459CA7C2864EA6500AF463F /* CustomSwitchViewOutput.swift in Sources */, + 0A64D35A2A8A632E00255566 /* ItemsScrollManagerViewController.swift in Sources */, A459CB672865E9B500AF463F /* SkeletonViewModuleOutput.swift in Sources */, A459CA9F2864F10400AF463F /* StringAttributesModuleInput.swift in Sources */, A459CB94286603FC00AF463F /* MoneyModelModuleInput.swift in Sources */, A459CA7D2864EA6500AF463F /* CustomSwitchViewController.swift in Sources */, A459CAA32864F10400AF463F /* StringAttributesViewInput.swift in Sources */, A459CAB42864F55E00AF463F /* QueryStringBuilderModuleInput.swift in Sources */, + 0A64D3692A8A64FC00255566 /* ItemsScrollManagerCoordinator.swift in Sources */, A459CAFB2865E0C100AF463F /* BrightSideViewInput.swift in Sources */, A459CA7B2864EA6500AF463F /* CustomSwitchModuleOutput.swift in Sources */, A459CB572865E6BF00AF463F /* KeyboardPresentableModuleConfigurator.swift in Sources */, @@ -1124,9 +1177,11 @@ A459CA802864EA6500AF463F /* CustomSwitchModuleConfigurator.swift in Sources */, A459CAD52864FE9E00AF463F /* UIDeviceCoordinator.swift in Sources */, A459CB242865E31900AF463F /* SettingsRouterModuleInput.swift in Sources */, + 0A64D3672A8A63B800255566 /* ItemsScrollManagerConfigurator.swift in Sources */, A459CB122865E1A300AF463F /* RouteMeasurerViewOutput.swift in Sources */, A459CB6D2865E9B500AF463F /* SkeletonViewModuleConfigurator.swift in Sources */, A459CAE72865D44D00AF463F /* GeolocationServiceViewOutput.swift in Sources */, + 0A64D3702A8A7C6000255566 /* ItemsScrollManagerExampleCell.swift in Sources */, A459CABF2864F84600AF463F /* QueryStringBuilderCoordinator.swift in Sources */, A459CAA12864F10400AF463F /* StringAttributesViewController.swift in Sources */, A459CA8A2864ED5300AF463F /* MainRouter.swift in Sources */, @@ -1177,6 +1232,7 @@ A459CAE32865D44D00AF463F /* GeolocationServicePresenter.swift in Sources */, A459CB2B2865E31900AF463F /* SettingsRouterModuleConfigurator.swift in Sources */, A459CB152865E1A300AF463F /* RouteMeasurerModuleConfigurator.swift in Sources */, + 0A64D36D2A8A69A700255566 /* ItemsScrollManagerParameterView.swift in Sources */, A459CA832864EB8000AF463F /* CustomSwitchCoordinator.swift in Sources */, A459CA7E2864EA6500AF463F /* CustomSwitchViewInput.swift in Sources */, A459CAB82864F55E00AF463F /* QueryStringBuilderViewOutput.swift in Sources */, diff --git a/UtilsExample/UtilsExample/AppDelegate.swift b/UtilsExample/UtilsExample/AppDelegate.swift index bfe1ab9..729eb62 100644 --- a/UtilsExample/UtilsExample/AppDelegate.swift +++ b/UtilsExample/UtilsExample/AppDelegate.swift @@ -27,6 +27,7 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { .add(flowCoordinator: BrightSideCoordinator()) .add(flowCoordinator: CustomSwitchCoordinator()) .add(flowCoordinator: GeolocationServiceCoordinator()) + .add(flowCoordinator: ItemsScrollManagerCoordinator()) .add(flowCoordinator: KeyboardPresentableCoordinator()) .add(flowCoordinator: MoneyModelCoordinator()) .add(flowCoordinator: QueryStringBuilderCoordinator()) diff --git a/UtilsExample/UtilsExample/Playbook/Coordinators/ItemsScrollManagerCoordinator.swift b/UtilsExample/UtilsExample/Playbook/Coordinators/ItemsScrollManagerCoordinator.swift new file mode 100644 index 0000000..d274faf --- /dev/null +++ b/UtilsExample/UtilsExample/Playbook/Coordinators/ItemsScrollManagerCoordinator.swift @@ -0,0 +1,33 @@ +// +// ItemsScrollManagerCoordinator.swift +// UtilsExample +// +// Created by Дмитрий Демьянов on 14.08.2023. +// + +import SurfPlaybook + +final class ItemsScrollManagerCoordinator: PlaybookFlowCoordinator { + + // MARK: - Private Properties + + private let router = MainRouter() + + // MARK: - PlaybookFlowCoordinator + + var id: String { + return "ItemsScrollManagerCoordinator" + } + + var name: String { + return "ItemsScrollManager & BeanPageControl" + } + + var type: FlowCoordinatorType { + return .coordinator { [weak self] in + let view = ItemsScrollManagerConfigurator().configure() + self?.router.present(view) + } + } + +} diff --git a/UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/Configurator/ItemsScrollManagerConfigurator.swift b/UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/Configurator/ItemsScrollManagerConfigurator.swift new file mode 100644 index 0000000..9446eba --- /dev/null +++ b/UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/Configurator/ItemsScrollManagerConfigurator.swift @@ -0,0 +1,16 @@ +// +// ItemsScrollManagerConfigurator.swift +// UtilsExample +// +// Created by Дмитрий Демьянов on 14.08.2023. +// + +import UIKit + +final class ItemsScrollManagerConfigurator { + + func configure() -> UIViewController { + return ItemsScrollManagerViewController() + } + +} diff --git a/UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/ItemsScrollManagerViewController.swift b/UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/ItemsScrollManagerViewController.swift new file mode 100644 index 0000000..429f586 --- /dev/null +++ b/UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/ItemsScrollManagerViewController.swift @@ -0,0 +1,224 @@ +// +// ItemsScrollManagerViewController.swift +// UtilsExample +// +// Created by Дмитрий Демьянов on 14.08.2023. +// +// swiftlint:disable line_length + +import UIKit +import Utils + +final class ItemsScrollManagerViewController: UIViewController { + + // MARK: - Nested Types + + typealias Cell = ItemsScrollManagerExampleCell + typealias Parameter = ItemsScrollManagerParameterView.Model + + private enum Parameters { + static let cellCount = Parameter(title: "Cell count", minValue: 1, maxValue: 24, initialValue: 8) + static let cellSpacing = Parameter(title: "Cell spacing", minValue: 0, maxValue: 128, initialValue: 10) + static let cellWidth = Parameter(title: "Cell width", minValue: 32, maxValue: 400, initialValue: 128) + static let edgeInsets = Parameter(title: "Edge insets", minValue: 0, maxValue: 128, initialValue: 10) + } + + // MARK: - IBOutlets + + @IBOutlet private var collectionView: UICollectionView! + @IBOutlet private var collectionLayout: UICollectionViewFlowLayout! + @IBOutlet private var parametersContainer: UIView! + @IBOutlet private var parametersStackView: UIStackView! + + // MARK: - Private Properties + + private var pageControl: BeanPageControl? + + private var cellCount = Parameters.cellCount.initialValue + private var cellAlignment: ItemsScrollManager.CellAlignment = .left + private var scrollManager: ItemsScrollManager? + + // MARK: - UIViewController + + override func viewDidLoad() { + super.viewDidLoad() + setupInitialState() + } + +} + +// MARK: - UICollectionViewDataSource + +extension ItemsScrollManagerViewController: UICollectionViewDataSource { + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return cellCount + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Cell.identifier, for: indexPath) + if let cell = cell as? Cell { + cell.configure(with: indexPath.item) + } + return cell + } + +} + +// MARK: - UICollectionViewDelegate + +extension ItemsScrollManagerViewController: UICollectionViewDelegate { + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrollManager?.setBeginDraggingOffset(scrollView.contentOffset.x) + } + + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + scrollManager?.setTargetContentOffset(targetContentOffset, for: scrollView) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let progress = scrollManager?.getPageProgress(for: scrollView) ?? .zero + pageControl?.set(index: Int(progress), progress: progress.truncatingRemainder(dividingBy: 1)) + } + +} + +// MARK: - Private Methods + +private extension ItemsScrollManagerViewController { + + func setupInitialState() { + setupCollectionView() + setupParameters() + } + + func setupCollectionView() { + collectionView.backgroundColor = .lightGray.withAlphaComponent(0.5) + collectionView.decelerationRate = .init(rawValue: 0.1) + collectionView.showsHorizontalScrollIndicator = false + collectionLayout.sectionInset.left = .init(Parameters.edgeInsets.initialValue) + collectionLayout.sectionInset.right = .init(Parameters.edgeInsets.initialValue) + + collectionLayout.scrollDirection = .horizontal + collectionLayout.minimumLineSpacing = .init(Parameters.cellSpacing.initialValue) + collectionLayout.itemSize = .init(width: Parameters.cellWidth.initialValue, height: Parameters.cellWidth.initialValue) + + collectionView.register(Cell.self, forCellWithReuseIdentifier: Cell.identifier) + collectionView.dataSource = self + collectionView.delegate = self + + resetView() + } + + func setupParameters() { + setupParametersContainer() + + addParameter(with: Parameters.cellCount) { [weak self] value in + self?.cellCount = value + } + + addParameter(with: Parameters.cellSpacing) { [weak self] value in + self?.collectionLayout.minimumLineSpacing = .init(value) + } + + addParameter(with: Parameters.cellWidth) { [weak self] value in + self?.collectionLayout.itemSize.width = .init(value) + } + + addParameter(with: Parameters.edgeInsets) { [weak self] value in + self?.collectionLayout.sectionInset.left = .init(value) + self?.collectionLayout.sectionInset.right = .init(value) + } + + addAlignmentParameter() + } + + func setupParametersContainer() { + parametersContainer.layer.cornerRadius = 16 + + parametersContainer.layer.shadowColor = UIColor.black.cgColor + parametersContainer.layer.shadowOpacity = 0.3 + parametersContainer.layer.shadowOffset = .init(width: 0, height: 1) + } + + func addAlignmentParameter() { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.spacing = 10 + stackView.translatesAutoresizingMaskIntoConstraints = false + parametersStackView.addArrangedSubview(stackView) + + let titleLabel = UILabel() + titleLabel.text = "Alignment" + titleLabel.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(titleLabel) + + let alignmentControl = UISegmentedControl(items: ["Left", "Center", "Right"]) + alignmentControl.selectedSegmentIndex = 0 + alignmentControl.addTarget(self, action: #selector(alignmentValueChanged), for: .valueChanged) + alignmentControl.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(alignmentControl) + alignmentControl.widthAnchor.constraint(equalToConstant: 218).isActive = true + } + + func addParameter(with model: Parameter, onChanged: ((Int) -> Void)?) { + let parameterView = ItemsScrollManagerParameterView() + parameterView.translatesAutoresizingMaskIntoConstraints = false + parametersStackView.addArrangedSubview(parameterView) + + parameterView.configure(with: model) + parameterView.onValueChanged = { [weak self] value in + onChanged?(value) + self?.resetView() + } + } + + func resetView() { + scrollManager = ItemsScrollManager( + cellWidth: collectionLayout.itemSize.width, + cellOffset: collectionLayout.minimumLineSpacing, + insets: collectionLayout.sectionInset, + alignment: cellAlignment + ) + + pageControl?.removeFromSuperview() + pageControl = nil + + collectionView.setContentOffset(.zero, animated: false) + collectionLayout.invalidateLayout() + collectionView.reloadData() + + setupPageControl() + } + + func setupPageControl() { + let pageControl = BeanPageControl() + pageControl.count = cellCount + + view.addSubview(pageControl) + pageControl.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + pageControl.centerXAnchor.constraint(equalTo: view.centerXAnchor), + pageControl.topAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 8), + pageControl.heightAnchor.constraint(equalToConstant: 4), + pageControl.widthAnchor.constraint(greaterThanOrEqualToConstant: 10) + ]) + + self.pageControl = pageControl + } + +} + +// MARK: - Actions + +private extension ItemsScrollManagerViewController { + + @objc + func alignmentValueChanged(_ sender: UISegmentedControl) { + let allValues: [ItemsScrollManager.CellAlignment] = [.left, .centered, .right] + cellAlignment = allValues[sender.selectedSegmentIndex] + resetView() + } + +} diff --git a/UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/ItemsScrollManagerViewController.xib b/UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/ItemsScrollManagerViewController.xib new file mode 100644 index 0000000..4d48126 --- /dev/null +++ b/UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/ItemsScrollManagerViewController.xib @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/Subviews/ItemsScrollManagerExampleCell.swift b/UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/Subviews/ItemsScrollManagerExampleCell.swift new file mode 100644 index 0000000..c5d5a84 --- /dev/null +++ b/UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/Subviews/ItemsScrollManagerExampleCell.swift @@ -0,0 +1,57 @@ +// +// ItemsScrollManagerExampleCell.swift +// UtilsExample +// +// Created by Дмитрий Демьянов on 14.08.2023. +// + +import UIKit + +final class ItemsScrollManagerExampleCell: UICollectionViewCell { + + // MARK: - Constants + + static let identifier = description() + + // MARK: - Private Properties + + private let label = UILabel() + + // MARK: - Initialization + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + setupInitialState() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupInitialState() + } + + // MARK: - Internal Methods + + func configure(with index: Int) { + label.text = String(index) + } + +} + +// MARK: - Private Methods + +private extension ItemsScrollManagerExampleCell { + + func setupInitialState() { + backgroundColor = .white + layer.cornerRadius = 16 + + label.font = .systemFont(ofSize: 30) + + addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: centerXAnchor), + label.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } +} diff --git a/UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/Subviews/ItemsScrollManagerParameterView.swift b/UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/Subviews/ItemsScrollManagerParameterView.swift new file mode 100644 index 0000000..f33b242 --- /dev/null +++ b/UtilsExample/UtilsExample/Playbook/Flows/ItemsScrollManager/View/Subviews/ItemsScrollManagerParameterView.swift @@ -0,0 +1,127 @@ +// +// ItemsScrollManagerParameterView.swift +// UtilsExample +// +// Created by Дмитрий Демьянов on 14.08.2023. +// + +import UIKit + +final class ItemsScrollManagerParameterView: UIView { + + // MARK: - Nested Types + + struct Model { + let title: String + let minValue: Int + let maxValue: Int + let initialValue: Int + } + + // MARK: - Properties + + var onValueChanged: ((Int) -> Void)? + + // MARK: - Private Properties + + private let titleLabel = UILabel() + private let slider = UISlider() + private let valueLabel = UILabel() + + private var previousValue: Int? + + // MARK: - Initialization + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + setupInitialState() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupInitialState() + } + + // MARK: - Internal Methods + + func configure(with model: Model) { + titleLabel.text = model.title + slider.minimumValue = Float(model.minValue) + slider.maximumValue = Float(model.maxValue) + slider.value = Float(model.initialValue) + updateValueLabel(with: model.initialValue) + } + +} + +// MARK: - Private Methods + +private extension ItemsScrollManagerParameterView { + + func setupInitialState() { + setupContainer() + setupTitleLabel() + setupSlider() + setupValueLabel() + } + + func setupContainer() { + let containerView = UIStackView() + containerView.axis = .horizontal + containerView.spacing = 10 + + addSubview(containerView) + containerView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + containerView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: trailingAnchor), + containerView.topAnchor.constraint(equalTo: topAnchor), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + containerView.addArrangedSubview(titleLabel) + containerView.addArrangedSubview(slider) + containerView.addArrangedSubview(valueLabel) + } + + func setupTitleLabel() { + titleLabel.translatesAutoresizingMaskIntoConstraints = false + } + + func setupSlider() { + slider.addTarget(self, action: #selector(sliderValueChanged), for: .valueChanged) + + slider.translatesAutoresizingMaskIntoConstraints = false + slider.widthAnchor.constraint(equalToConstant: 160).isActive = true + } + + func setupValueLabel() { + valueLabel.textAlignment = .right + + valueLabel.translatesAutoresizingMaskIntoConstraints = false + valueLabel.widthAnchor.constraint(equalToConstant: 48).isActive = true + } + + func updateValueLabel(with value: Int) { + valueLabel.text = String(value) + } + +} + +// MARK: - Actions + +private extension ItemsScrollManagerParameterView { + + @objc + func sliderValueChanged() { + let value = Int(slider.value.rounded()) + guard value != previousValue else { + return + } + + previousValue = value + updateValueLabel(with: value) + onValueChanged?(value) + } + +} From d55ba0d0d231ff3316ec7fe0734c0e61bb81cbdb Mon Sep 17 00:00:00 2001 From: Dmitry Demyanov Date: Wed, 16 Aug 2023 15:23:59 +0300 Subject: [PATCH 3/5] created tests for ItemsScrollManager --- Utils.xcodeproj/project.pbxproj | 4 + UtilsTests/ItemsScrollManagerTests.swift | 382 +++++++++++++++++++++++ 2 files changed, 386 insertions(+) create mode 100644 UtilsTests/ItemsScrollManagerTests.swift diff --git a/Utils.xcodeproj/project.pbxproj b/Utils.xcodeproj/project.pbxproj index 7f86e9c..c9df536 100644 --- a/Utils.xcodeproj/project.pbxproj +++ b/Utils.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0A64D3722A8BD9E000255566 /* ItemsScrollManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A64D3712A8BD9E000255566 /* ItemsScrollManagerTests.swift */; }; 18F2361421D2150200169AC9 /* Dictionary+QueryStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F2361321D2150200169AC9 /* Dictionary+QueryStringBuilder.swift */; }; 3946574E24EC1A580069BDB0 /* LoadingViewBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3946574D24EC1A580069BDB0 /* LoadingViewBlock.swift */; }; 3946575024EC1AAC0069BDB0 /* LoadingViewConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3946574F24EC1AAC0069BDB0 /* LoadingViewConfig.swift */; }; @@ -110,6 +111,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 0A64D3712A8BD9E000255566 /* ItemsScrollManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsScrollManagerTests.swift; sourceTree = ""; }; 18F2361321D2150200169AC9 /* Dictionary+QueryStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+QueryStringBuilder.swift"; sourceTree = ""; }; 39262D442551713B00591787 /* PinCryptoBoxTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCryptoBoxTests.swift; sourceTree = ""; }; 39262D4C2551714F00591787 /* PinHackCryptoBoxTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinHackCryptoBoxTests.swift; sourceTree = ""; }; @@ -414,6 +416,7 @@ isa = PBXGroup; children = ( 39262D432551712300591787 /* SecurityService */, + 0A64D3712A8BD9E000255566 /* ItemsScrollManagerTests.swift */, 90C101C921E0B62D002E8E65 /* RouteMeasurerTests.swift */, 90C101C721E0B1E0002E8E65 /* WordDeclinationSelectorTests.swift */, 890C428323795BF100FF63A7 /* LocalStorageTests.swift */, @@ -943,6 +946,7 @@ 90AC856D2385941D00DF7F3B /* GeolocationServiceTests.swift in Sources */, 90C101CA21E0B62D002E8E65 /* RouteMeasurerTests.swift in Sources */, 90C101C821E0B1E0002E8E65 /* WordDeclinationSelectorTests.swift in Sources */, + 0A64D3722A8BD9E000255566 /* ItemsScrollManagerTests.swift in Sources */, 87239D8124D4220600D38EC7 /* MoneyModelTests.swift in Sources */, 890C428423795BF100FF63A7 /* LocalStorageTests.swift in Sources */, ); diff --git a/UtilsTests/ItemsScrollManagerTests.swift b/UtilsTests/ItemsScrollManagerTests.swift new file mode 100644 index 0000000..1e543a5 --- /dev/null +++ b/UtilsTests/ItemsScrollManagerTests.swift @@ -0,0 +1,382 @@ +// +// ItemsScrollManagerTests.swift +// UtilsTests +// +// Created by Дмитрий Демьянов on 15.08.2023. +// Copyright © 2023 Surf. All rights reserved. +// + +import XCTest +@testable import Utils + +class ItemsScrollManagerTests: XCTestCase { + + // MARK: -  Constants + + private let cellWidth: CGFloat = 200 + private let cellSpacing: CGFloat = 20 + private let edgeInset: CGFloat = 10 + private var pageWidth: CGFloat { cellWidth + cellSpacing } + + // MARK: - Private Properties + + private let scrollView = UIScrollView() + + // MARK: - XCTestCase + + override func setUp() { + super.setUp() + scrollView.contentSize = CGSize(width: pageWidth * 10, height: 200) + } + +} + +// MARK: - Left Alignment Tests + +extension ItemsScrollManagerTests { + + func testLeftAlignmentProgressAtFirstCellStart() { + // Arrange + let scrollManager = makeScrollManager(alignment: .left) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 0) + + // Assert + XCTAssertNearlyEqual(pageProgress, 0) + } + + func testLeftAlignmentProgressAtFirstCellMiddle() { + // Arrange + let scrollManager = makeScrollManager(alignment: .left) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 0.5) + + // Assert + XCTAssertNearlyEqual(pageProgress, 0.5) + } + + func testLeftAlignmentProgressAtFirstCellEnd() { + // Arrange + let scrollManager = makeScrollManager(alignment: .left) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 1) + + // Assert + XCTAssertNearlyEqual(pageProgress, 1) + } + + func testLeftAlignmentProgressAtMiddleCellStart() { + // Arrange + let scrollManager = makeScrollManager(alignment: .left) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 5) + + // Assert + XCTAssertNearlyEqual(pageProgress, 5) + } + + func testLeftAlignmentProgressAtMiddleCellMiddle() { + // Arrange + let scrollManager = makeScrollManager(alignment: .left) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 5.5) + + // Assert + XCTAssertNearlyEqual(pageProgress, 5.5) + } + + func testLeftAlignmentProgressAtMiddleCellEnd() { + // Arrange + let scrollManager = makeScrollManager(alignment: .left) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 6) + + // Assert + XCTAssertNearlyEqual(pageProgress, 6) + } + + func testLeftAlignmentProgressAtLastCellStart() { + // Arrange + let scrollManager = makeScrollManager(alignment: .left) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 8) + + // Assert + XCTAssertNearlyEqual(pageProgress, 8) + } + + func testLeftAlignmentProgressAtLastCellMiddle() { + // Arrange + let scrollManager = makeScrollManager(alignment: .left) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 8.5) + + // Assert + XCTAssertNearlyEqual(pageProgress, 9) + } + + func testLeftAlignmentProgressAtLastCellEnd() { + // Arrange + let scrollManager = makeScrollManager(alignment: .left) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 9) + + // Assert + XCTAssertNearlyEqual(pageProgress, 9) + } + +} + +// MARK: - Center Alignment Tests + +extension ItemsScrollManagerTests { + + func testCenterAlignmentProgressAtFirstCellStart() { + // Arrange + let scrollManager = makeScrollManager(alignment: .centered) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 0) + + // Assert + XCTAssertNearlyEqual(pageProgress, 0) + } + + func testCenterAlignmentProgressAtFirstCellMiddle() { + // Arrange + let scrollManager = makeScrollManager(alignment: .centered) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 0.5) + + // Assert + XCTAssertNearlyEqual(pageProgress, 0.77) + } + + func testCenterAlignmentProgressAtFirstCellEnd() { + // Arrange + let scrollManager = makeScrollManager(alignment: .centered) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 1) + + // Assert + XCTAssertNearlyEqual(pageProgress, 1.35) + } + + func testCenterAlignmentProgressAtMiddleCellStart() { + // Arrange + let scrollManager = makeScrollManager(alignment: .centered) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 5) + + // Assert + XCTAssertNearlyEqual(pageProgress, 5.35) + } + + func testCenterAlignmentProgressAtMiddleCellMiddle() { + // Arrange + let scrollManager = makeScrollManager(alignment: .centered) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 5.5) + + // Assert + XCTAssertNearlyEqual(pageProgress, 5.85) + } + + func testCenterAlignmentProgressAtMiddleCellEnd() { + // Arrange + let scrollManager = makeScrollManager(alignment: .centered) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 6) + + // Assert + XCTAssertNearlyEqual(pageProgress, 6.35) + } + + func testCenterAlignmentProgressAtLastCellStart() { + // Arrange + let scrollManager = makeScrollManager(alignment: .centered) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 8) + + // Assert + XCTAssertNearlyEqual(pageProgress, 8.54) + } + + func testCenterAlignmentProgressAtLastCellMiddle() { + // Arrange + let scrollManager = makeScrollManager(alignment: .centered) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 8.5) + + // Assert + XCTAssertNearlyEqual(pageProgress, 9) + } + + func testCenterAlignmentProgressAtLastCellEnd() { + // Arrange + let scrollManager = makeScrollManager(alignment: .centered) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 9) + + // Assert + XCTAssertNearlyEqual(pageProgress, 9) + } + +} + +// MARK: - Right Alignment Tests + +extension ItemsScrollManagerTests { + + func testRightAlignmentProgressAtFirstCellStart() { + // Arrange + let scrollManager = makeScrollManager(alignment: .right) + + // Act + + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 0) + + // Assert + XCTAssertNearlyEqual(pageProgress, 0) + } + + func testRightAlignmentProgressAtFirstCellMiddle() { + // Arrange + let scrollManager = makeScrollManager(alignment: .right) + + // Act + + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 0.5) + + // Assert + XCTAssertNearlyEqual(pageProgress, 1.2) + } + + func testRightAlignmentProgressAtFirstCellEnd() { + // Arrange + let scrollManager = makeScrollManager(alignment: .right) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 1) + + // Assert + XCTAssertNearlyEqual(pageProgress, 1.7) + } + + func testRightAlignmentProgressAtMiddleCellStart() { + // Arrange + let scrollManager = makeScrollManager(alignment: .right) + + // Act + + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 5) + + // Assert + XCTAssertNearlyEqual(pageProgress, 5.7) + } + + func testRightAlignmentProgressAtMiddleCellMiddle() { + // Arrange + let scrollManager = makeScrollManager(alignment: .right) + + // Act + + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 5.5) + + // Assert + XCTAssertNearlyEqual(pageProgress, 6.2) + } + + func testRightAlignmentProgressAtMiddleCellEnd() { + // Arrange + let scrollManager = makeScrollManager(alignment: .right) + + // Act + + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 6) + + // Assert + XCTAssertNearlyEqual(pageProgress, 6.7) + } + + func testRightAlignmentProgressAtLastCellStart() { + // Arrange + let scrollManager = makeScrollManager(alignment: .right) + + // Act + + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 8) + + // Assert + XCTAssertNearlyEqual(pageProgress, 8.7) + } + + func testRightAlignmentProgressAtLastCellMiddle() { + // Arrange + let scrollManager = makeScrollManager(alignment: .right) + + // Act + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 8.5) + + // Assert + XCTAssertNearlyEqual(pageProgress, 9) + } + + func testRightAlignmentProgressAtLastCellEnd() { + // Arrange + let scrollManager = makeScrollManager(alignment: .right) + + // Act + + let pageProgress = scrollManager.getPageProgress(for: scrollView, targetOffset: pageWidth * 9) + + // Assert + XCTAssertNearlyEqual(pageProgress, 9) + } + +} + +// MARK: - Private Methods + +private extension ItemsScrollManagerTests { + + func makeScrollManager(alignment: ItemsScrollManager.CellAlignment) -> ItemsScrollManager { + return ItemsScrollManager( + cellWidth: cellWidth, + cellOffset: cellSpacing, + insets: UIEdgeInsets(top: 0, left: edgeInset, bottom: 0, right: edgeInset), + containerWidth: 375, // iPhone 8 width + alignment: alignment + ) + } + +} + +// MARK: - XCTAssert + +private func XCTAssertNearlyEqual( + _ expression1: CGFloat, + _ expression2: CGFloat, + _ message: String = "", + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssertLessThanOrEqual(abs(expression1 - expression2), 0.01, message, file: file, line: line) +} From 98aaecb0db2a18e99466aa25455837306aa91d0f Mon Sep 17 00:00:00 2001 From: Dmitry Demyanov Date: Wed, 16 Aug 2023 16:40:37 +0300 Subject: [PATCH 4/5] raised podspec version --- SurfUtils.podspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SurfUtils.podspec b/SurfUtils.podspec index 83cc47a..9930d2a 100644 --- a/SurfUtils.podspec +++ b/SurfUtils.podspec @@ -1,7 +1,7 @@ Pod::Spec.new do |s| s.name = "SurfUtils" - s.version = "13.2.0" + s.version = "13.3.0" s.summary = "Utils collection for iOS-development." s.description = <<-DESC Each utility is a small and frequently used piece of logic or UI component. From 9531d2186a604ed0f7053027b76e58005e30e34e Mon Sep 17 00:00:00 2001 From: Dmitry Demyanov Date: Wed, 13 Sep 2023 17:01:41 +0300 Subject: [PATCH 5/5] fixed negative page width case --- Utils/ItemsScrollManager/ItemsScrollManager.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Utils/ItemsScrollManager/ItemsScrollManager.swift b/Utils/ItemsScrollManager/ItemsScrollManager.swift index 8f7cc78..2786de0 100644 --- a/Utils/ItemsScrollManager/ItemsScrollManager.swift +++ b/Utils/ItemsScrollManager/ItemsScrollManager.swift @@ -149,7 +149,7 @@ public final class ItemsScrollManager { // Detect on which page the scroll will end let pageProgress = getPageProgress(for: scrollView, targetOffset: targetOffset) - let currentPage = pageProgress.rounded(targetOffset < lastOffset ? .down : .up) + let currentPage = pageProgress.rounded(scrollView.contentOffset.x < lastOffset ? .down : .up) // Detect which offset corresponds to the selected page let cellOffset = insets.left + currentPage * pageWidth - cellAlignmentOffset @@ -171,7 +171,7 @@ public final class ItemsScrollManager { // Left offset for the first cell is `insets.left`, for normal ones - `cellAlignmentOffset` let firstPageWidth = pageWidth - (cellAlignmentOffset - insets.left) // Get progress for the first page - if offset < firstPageWidth { + if offset < firstPageWidth, firstPageWidth > 0 { return max(0, offset) / firstPageWidth } @@ -180,7 +180,7 @@ public final class ItemsScrollManager { // Right offset for the last cell is `insets.right`, for normal ones - `reversedAdditionalOffset` let lastPageWidth = pageWidth - (reversedAdditionalOffset - insets.right) // Get progress for the last page - if offset > maxContentOffset(for: scrollView) - lastPageWidth { + if offset > maxContentOffset(for: scrollView) - lastPageWidth, lastPageWidth > 0 { // Progress for the last page is calculated in reverse direction, then subtracted from the last page index let reversedProgress = (maxContentOffset(for: scrollView) - offset) / lastPageWidth let pagesCount = (scrollView.contentSize.width + cellSpacing - (insets.left + insets.right)) / pageWidth