Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ItemsScrollManager. CellAlignment #104

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SurfUtils.podspec
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions Utils.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -110,6 +111,7 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
0A64D3712A8BD9E000255566 /* ItemsScrollManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsScrollManagerTests.swift; sourceTree = "<group>"; };
18F2361321D2150200169AC9 /* Dictionary+QueryStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+QueryStringBuilder.swift"; sourceTree = "<group>"; };
39262D442551713B00591787 /* PinCryptoBoxTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinCryptoBoxTests.swift; sourceTree = "<group>"; };
39262D4C2551714F00591787 /* PinHackCryptoBoxTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinHackCryptoBoxTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -414,6 +416,7 @@
isa = PBXGroup;
children = (
39262D432551712300591787 /* SecurityService */,
0A64D3712A8BD9E000255566 /* ItemsScrollManagerTests.swift */,
90C101C921E0B62D002E8E65 /* RouteMeasurerTests.swift */,
90C101C721E0B1E0002E8E65 /* WordDeclinationSelectorTests.swift */,
890C428323795BF100FF63A7 /* LocalStorageTests.swift */,
Expand Down Expand Up @@ -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 */,
);
Expand Down
146 changes: 108 additions & 38 deletions Utils/ItemsScrollManager/ItemsScrollManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
/// ```
Expand All @@ -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:
/// ```
Expand All @@ -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<CGPoint>,
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<CGPoint>,
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(scrollView.contentOffset.x < 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, firstPageWidth > 0 {
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, 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
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
}

}
Loading