OK, so I found the solution here: targetContentOffsetForProposedContentOffset:withScrollingVelocity without subclassing UICollectionViewFlowLayout
I should have searched for targetContentOffsetForProposedContentOffset
in the begining.
Pergunta
I have UICollectionView
with horizontal scrolling and there are always 2 cells side-by-side per the entire screen. I need the scrolling to stop at the begining of a cell. With paging enabled, the collection view scrolls the whole page, which is 2 cells at once, and then it stops.
I need to enable scrolling by a single cell, or scrolling by multiple cells with stopping at the edge of the cell.
I tried to subclass UICollectionViewFlowLayout
and to implement the method targetContentOffsetForProposedContentOffset
, but so far I was only able to break my collection view and it stopped scrolling. Is there any easier way to achieve this and how, or do I really need to implement all methods of UICollectionViewFlowLayout
subclass? Thanks.
Solução
OK, so I found the solution here: targetContentOffsetForProposedContentOffset:withScrollingVelocity without subclassing UICollectionViewFlowLayout
I should have searched for targetContentOffsetForProposedContentOffset
in the begining.
Outras dicas
Here's my implementation in Swift 5 for vertical cell-based paging:
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = self.collectionView else {
let latestOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)
return latestOffset
}
// Page height used for estimating and calculating paging.
let pageHeight = self.itemSize.height + self.minimumLineSpacing
// Make an estimation of the current page position.
let approximatePage = collectionView.contentOffset.y/pageHeight
// Determine the current page based on velocity.
let currentPage = velocity.y == 0 ? round(approximatePage) : (velocity.y < 0.0 ? floor(approximatePage) : ceil(approximatePage))
// Create custom flickVelocity.
let flickVelocity = velocity.y * 0.3
// Check how many pages the user flicked, if <= 1 then flickedPages should return 0.
let flickedPages = (abs(round(flickVelocity)) <= 1) ? 0 : round(flickVelocity)
let newVerticalOffset = ((currentPage + flickedPages) * pageHeight) - collectionView.contentInset.top
return CGPoint(x: proposedContentOffset.x, y: newVerticalOffset)
}
Some notes:
itemSize
actually matches the size of the item as that's often a problem, especially when using collectionView(_:layout:sizeForItemAt:)
, use a custom variable with the itemSize instead.self.collectionView.decelerationRate = UIScrollView.DecelerationRate.fast
.Here's a horizontal version (haven't tested it thoroughly so please forgive any mistakes):
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = self.collectionView else {
let latestOffset = super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)
return latestOffset
}
// Page width used for estimating and calculating paging.
let pageWidth = self.itemSize.width + self.minimumInteritemSpacing
// Make an estimation of the current page position.
let approximatePage = collectionView.contentOffset.x/pageWidth
// Determine the current page based on velocity.
let currentPage = velocity.x == 0 ? round(approximatePage) : (velocity.x < 0.0 ? floor(approximatePage) : ceil(approximatePage))
// Create custom flickVelocity.
let flickVelocity = velocity.x * 0.3
// Check how many pages the user flicked, if <= 1 then flickedPages should return 0.
let flickedPages = (abs(round(flickVelocity)) <= 1) ? 0 : round(flickVelocity)
// Calculate newHorizontalOffset.
let newHorizontalOffset = ((currentPage + flickedPages) * pageWidth) - collectionView.contentInset.left
return CGPoint(x: newHorizontalOffset, y: proposedContentOffset.y)
}
This code is based on the code I use in my personal project, you can check it out here by downloading it and running the Example target.
just override the method:
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
*targetContentOffset = scrollView.contentOffset; // set acceleration to 0.0
float pageWidth = (float)self.articlesCollectionView.bounds.size.width;
int minSpace = 10;
int cellToSwipe = (scrollView.contentOffset.x)/(pageWidth + minSpace) + 0.5; // cell width + min spacing for lines
if (cellToSwipe < 0) {
cellToSwipe = 0;
} else if (cellToSwipe >= self.articles.count) {
cellToSwipe = self.articles.count - 1;
}
[self.articlesCollectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForRow:cellToSwipe inSection:0] atScrollPosition:UICollectionViewScrollPositionLeft animated:YES];
}
Many solutions presented here result in some weird behaviour that doesn't feel like properly implemented paging.
The solution presented in this tutorial, however, doesn't seem to have any issues. It just feels like a perfectly working paging algorithm. You can implement it in 5 simple steps:
private var indexOfCellBeforeDragging = 0
collectionView
delegate
like this: collectionView.delegate = self
UICollectionViewDelegate
via an extension: extension YourType: UICollectionViewDelegate { }
Add the following method to the extension implementing the UICollectionViewDelegate
conformance and set a value for pageWidth
:
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
let pageWidth = // The width your page should have (plus a possible margin)
let proportionalOffset = collectionView.contentOffset.x / pageWidth
indexOfCellBeforeDragging = Int(round(proportionalOffset))
}
Add the following method to the extension implementing the UICollectionViewDelegate
conformance, set the same value for pageWidth
(you may also store this value at a central place) and set a value for collectionViewItemCount
:
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
// Stop scrolling
targetContentOffset.pointee = scrollView.contentOffset
// Calculate conditions
let pageWidth = // The width your page should have (plus a possible margin)
let collectionViewItemCount = // The number of items in this section
let proportionalOffset = collectionView.contentOffset.x / pageWidth
let indexOfMajorCell = Int(round(proportionalOffset))
let swipeVelocityThreshold: CGFloat = 0.5
let hasEnoughVelocityToSlideToTheNextCell = indexOfCellBeforeDragging + 1 < collectionViewItemCount && velocity.x > swipeVelocityThreshold
let hasEnoughVelocityToSlideToThePreviousCell = indexOfCellBeforeDragging - 1 >= 0 && velocity.x < -swipeVelocityThreshold
let majorCellIsTheCellBeforeDragging = indexOfMajorCell == indexOfCellBeforeDragging
let didUseSwipeToSkipCell = majorCellIsTheCellBeforeDragging && (hasEnoughVelocityToSlideToTheNextCell || hasEnoughVelocityToSlideToThePreviousCell)
if didUseSwipeToSkipCell {
// Animate so that swipe is just continued
let snapToIndex = indexOfCellBeforeDragging + (hasEnoughVelocityToSlideToTheNextCell ? 1 : -1)
let toValue = pageWidth * CGFloat(snapToIndex)
UIView.animate(
withDuration: 0.3,
delay: 0,
usingSpringWithDamping: 1,
initialSpringVelocity: velocity.x,
options: .allowUserInteraction,
animations: {
scrollView.contentOffset = CGPoint(x: toValue, y: 0)
scrollView.layoutIfNeeded()
},
completion: nil
)
} else {
// Pop back (against velocity)
let indexPath = IndexPath(row: indexOfMajorCell, section: 0)
collectionView.scrollToItem(at: indexPath, at: .left, animated: true)
}
}
Here's the easiest way that i found to do that in Swift 4.2 for horinzontal scroll:
I'm using the first cell on visibleCells
and scrolling to then, if the first visible cell are showing less of the half of it's width i'm scrolling to the next one.
If your collection scroll vertically, simply change x
by y
and width
by height
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
targetContentOffset.pointee = scrollView.contentOffset
var indexes = self.collectionView.indexPathsForVisibleItems
indexes.sort()
var index = indexes.first!
let cell = self.collectionView.cellForItem(at: index)!
let position = self.collectionView.contentOffset.x - cell.frame.origin.x
if position > cell.frame.size.width/2{
index.row = index.row+1
}
self.collectionView.scrollToItem(at: index, at: .left, animated: true )
}
Swift 3 version of Evya's answer:
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
targetContentOffset.pointee = scrollView.contentOffset
let pageWidth:Float = Float(self.view.bounds.width)
let minSpace:Float = 10.0
var cellToSwipe:Double = Double(Float((scrollView.contentOffset.x))/Float((pageWidth+minSpace))) + Double(0.5)
if cellToSwipe < 0 {
cellToSwipe = 0
} else if cellToSwipe >= Double(self.articles.count) {
cellToSwipe = Double(self.articles.count) - Double(1)
}
let indexPath:IndexPath = IndexPath(row: Int(cellToSwipe), section:0)
self.collectionView.scrollToItem(at:indexPath, at: UICollectionViewScrollPosition.left, animated: true)
}
Partly based on StevenOjo's answer. I've tested this using a horizontal scrolling and no Bounce UICollectionView. cellSize is CollectionViewCell size. You can tweak factor to modify scrolling sensitivity.
override func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
targetContentOffset.pointee = scrollView.contentOffset
var factor: CGFloat = 0.5
if velocity.x < 0 {
factor = -factor
}
let indexPath = IndexPath(row: (scrollView.contentOffset.x/cellSize.width + factor).int, section: 0)
collectionView?.scrollToItem(at: indexPath, at: .left, animated: true)
}
flowLayout
is UICollectionViewFlowLayout
property
override func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
if let collectionView = collectionView {
targetContentOffset.memory = scrollView.contentOffset
let pageWidth = CGRectGetWidth(scrollView.frame) + flowLayout.minimumInteritemSpacing
var assistanceOffset : CGFloat = pageWidth / 3.0
if velocity.x < 0 {
assistanceOffset = -assistanceOffset
}
let assistedScrollPosition = (scrollView.contentOffset.x + assistanceOffset) / pageWidth
var targetIndex = Int(round(assistedScrollPosition))
if targetIndex < 0 {
targetIndex = 0
}
else if targetIndex >= collectionView.numberOfItemsInSection(0) {
targetIndex = collectionView.numberOfItemsInSection(0) - 1
}
print("targetIndex = \(targetIndex)")
let indexPath = NSIndexPath(forItem: targetIndex, inSection: 0)
collectionView.scrollToItemAtIndexPath(indexPath, atScrollPosition: .Left, animated: true)
}
}
You could use UIPageViewController
if it meets your requirements, each page would have a separate view controller.
Here is the optimised solution in Swift5, including handling the wrong indexPath. - Michael Lin Liu
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
targetContentOffset.pointee = scrollView.contentOffset
//M: Get the first visiable item's indexPath from visibaleItems.
var indexPaths = *YOURCOLLECTIONVIEW*.indexPathsForVisibleItems
indexPaths.sort()
var indexPath = indexPaths.first!
//M: Use the velocity to detect the paging control movement.
//M: If the movement is forward, then increase the indexPath.
if velocity.x > 0{
indexPath.row += 1
//M: If the movement is in the next section, which means the indexPath's row is out range. We set the indexPath to the first row of the next section.
if indexPath.row == *YOURCOLLECTIONVIEW*.numberOfItems(inSection: indexPath.section){
indexPath.row = 0
indexPath.section += 1
}
}
else{
//M: If the movement is backward, the indexPath will be automatically changed to the first visiable item which is indexPath.row - 1. So there is no need to write the logic.
}
//M: Tell the collection view to scroll to the next item.
*YOURCOLLECTIONVIEW*.scrollToItem(at: indexPath, at: .left, animated: true )
}
modify Romulo BM answer for velocity listening
func scrollViewWillEndDragging(
_ scrollView: UIScrollView,
withVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>
) {
targetContentOffset.pointee = scrollView.contentOffset
var indexes = collection.indexPathsForVisibleItems
indexes.sort()
var index = indexes.first!
if velocity.x > 0 {
index.row += 1
} else if velocity.x == 0 {
let cell = self.collection.cellForItem(at: index)!
let position = self.collection.contentOffset.x - cell.frame.origin.x
if position > cell.frame.size.width / 2 {
index.row += 1
}
}
self.collection.scrollToItem(at: index, at: .centeredHorizontally, animated: true )
}
This is a straight way to do this.
The case is simple, but finally quite common ( typical thumbnails scroller with fixed cell size and fixed gap between cells )
var itemCellSize: CGSize = <your cell size>
var itemCellsGap: CGFloat = <gap in between>
override func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
let pageWidth = (itemCellSize.width + itemCellsGap)
let itemIndex = (targetContentOffset.pointee.x) / pageWidth
targetContentOffset.pointee.x = round(itemIndex) * pageWidth - (itemCellsGap / 2)
}
// CollectionViewFlowLayoutDelegate
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
return itemCellSize
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
return itemCellsGap
}
Note that there is no reason to call a scrollToOffset or dive into layouts. The native scrolling behaviour already does everything.
Cheers All :)
Kind of like evya's answer, but a little smoother because it doesn't set the targetContentOffset to zero.
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
if ([scrollView isKindOfClass:[UICollectionView class]]) {
UICollectionView* collectionView = (UICollectionView*)scrollView;
if ([collectionView.collectionViewLayout isKindOfClass:[UICollectionViewFlowLayout class]]) {
UICollectionViewFlowLayout* layout = (UICollectionViewFlowLayout*)collectionView.collectionViewLayout;
CGFloat pageWidth = layout.itemSize.width + layout.minimumInteritemSpacing;
CGFloat usualSideOverhang = (scrollView.bounds.size.width - pageWidth)/2.0;
// k*pageWidth - usualSideOverhang = contentOffset for page at index k if k >= 1, 0 if k = 0
// -> (contentOffset + usualSideOverhang)/pageWidth = k at page stops
NSInteger targetPage = 0;
CGFloat currentOffsetInPages = (scrollView.contentOffset.x + usualSideOverhang)/pageWidth;
targetPage = velocity.x < 0 ? floor(currentOffsetInPages) : ceil(currentOffsetInPages);
targetPage = MAX(0,MIN(self.projects.count - 1,targetPage));
*targetContentOffset = CGPointMake(MAX(targetPage*pageWidth - usualSideOverhang,0), 0);
}
}
}
Here is my version of it in Swift 3. Calculate the offset after scrolling ended and adjust the offset with animation.
collectionLayout
is a UICollectionViewFlowLayout()
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
let index = scrollView.contentOffset.x / collectionLayout.itemSize.width
let fracPart = index.truncatingRemainder(dividingBy: 1)
let item= Int(fracPart >= 0.5 ? ceil(index) : floor(index))
let indexPath = IndexPath(item: item, section: 0)
collectionView.scrollToItem(at: indexPath, at: .left, animated: true)
}
Swift 5
I've found a way to do this without subclassing UICollectionView, just calculating the contentOffset in horizontal. Obviously without isPagingEnabled set true. Here is the code:
var offsetScroll1 : CGFloat = 0
var offsetScroll2 : CGFloat = 0
let flowLayout = UICollectionViewFlowLayout()
let screenSize : CGSize = UIScreen.main.bounds.size
var items = ["1", "2", "3", "4", "5"]
override func viewDidLoad() {
super.viewDidLoad()
flowLayout.scrollDirection = .horizontal
flowLayout.minimumLineSpacing = 7
let collectionView = UICollectionView(frame: CGRect(x: 0, y: 590, width: screenSize.width, height: 200), collectionViewLayout: flowLayout)
collectionView.register(collectionViewCell1.self, forCellWithReuseIdentifier: cellReuseIdentifier)
collectionView.delegate = self
collectionView.dataSource = self
collectionView.backgroundColor = UIColor.clear
collectionView.showsHorizontalScrollIndicator = false
self.view.addSubview(collectionView)
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
offsetScroll1 = offsetScroll2
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
offsetScroll1 = offsetScroll2
}
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>){
let indexOfMajorCell = self.desiredIndex()
let indexPath = IndexPath(row: indexOfMajorCell, section: 0)
flowLayout.collectionView!.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: true)
targetContentOffset.pointee = scrollView.contentOffset
}
private func desiredIndex() -> Int {
var integerIndex = 0
print(flowLayout.collectionView!.contentOffset.x)
offsetScroll2 = flowLayout.collectionView!.contentOffset.x
if offsetScroll2 > offsetScroll1 {
integerIndex += 1
let offset = flowLayout.collectionView!.contentOffset.x / screenSize.width
integerIndex = Int(round(offset))
if integerIndex < (items.count - 1) {
integerIndex += 1
}
}
if offsetScroll2 < offsetScroll1 {
let offset = flowLayout.collectionView!.contentOffset.x / screenSize.width
integerIndex = Int(offset.rounded(.towardZero))
}
let targetIndex = integerIndex
return targetIndex
}
Also you can create fake scroll view to handle scrolling.
Horizontal or Vertical
// === Defaults ===
let bannerSize = CGSize(width: 280, height: 170)
let pageWidth: CGFloat = 290 // ^ + paging
let insetLeft: CGFloat = 20
let insetRight: CGFloat = 20
// ================
var pageScrollView: UIScrollView!
override func viewDidLoad() {
super.viewDidLoad()
// Create fake scrollview to properly handle paging
pageScrollView = UIScrollView(frame: CGRect(origin: .zero, size: CGSize(width: pageWidth, height: 100)))
pageScrollView.isPagingEnabled = true
pageScrollView.alwaysBounceHorizontal = true
pageScrollView.showsVerticalScrollIndicator = false
pageScrollView.showsHorizontalScrollIndicator = false
pageScrollView.delegate = self
pageScrollView.isHidden = true
view.insertSubview(pageScrollView, belowSubview: collectionView)
// Set desired gesture recognizers to the collection view
for gr in pageScrollView.gestureRecognizers! {
collectionView.addGestureRecognizer(gr)
}
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView == pageScrollView {
// Return scrolling back to the collection view
collectionView.contentOffset.x = pageScrollView.contentOffset.x
}
}
func refreshData() {
...
refreshScroll()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
refreshScroll()
}
/// Refresh fake scrolling view content size if content changes
func refreshScroll() {
let w = collectionView.width - bannerSize.width - insetLeft - insetRight
pageScrollView.contentSize = CGSize(width: pageWidth * CGFloat(banners.count) - w, height: 100)
}
This is my solution, in Swift 4.2, I wish it could help you.
class SomeViewController: UIViewController {
private lazy var flowLayout: UICollectionViewFlowLayout = {
let layout = UICollectionViewFlowLayout()
layout.itemSize = CGSize(width: /* width */, height: /* height */)
layout.minimumLineSpacing = // margin
layout.minimumInteritemSpacing = 0.0
layout.sectionInset = UIEdgeInsets(top: 0.0, left: /* margin */, bottom: 0.0, right: /* margin */)
layout.scrollDirection = .horizontal
return layout
}()
private lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout)
collectionView.showsHorizontalScrollIndicator = false
collectionView.dataSource = self
collectionView.delegate = self
// collectionView.register(SomeCell.self)
return collectionView
}()
private var currentIndex: Int = 0
}
// MARK: - UIScrollViewDelegate
extension SomeViewController {
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
guard scrollView == collectionView else { return }
let pageWidth = flowLayout.itemSize.width + flowLayout.minimumLineSpacing
currentIndex = Int(scrollView.contentOffset.x / pageWidth)
}
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
guard scrollView == collectionView else { return }
let pageWidth = flowLayout.itemSize.width + flowLayout.minimumLineSpacing
var targetIndex = Int(roundf(Float(targetContentOffset.pointee.x / pageWidth)))
if targetIndex > currentIndex {
targetIndex = currentIndex + 1
} else if targetIndex < currentIndex {
targetIndex = currentIndex - 1
}
let count = collectionView.numberOfItems(inSection: 0)
targetIndex = max(min(targetIndex, count - 1), 0)
print("targetIndex: \(targetIndex)")
targetContentOffset.pointee = scrollView.contentOffset
var offsetX: CGFloat = 0.0
if targetIndex < count - 1 {
offsetX = pageWidth * CGFloat(targetIndex)
} else {
offsetX = scrollView.contentSize.width - scrollView.width
}
collectionView.setContentOffset(CGPoint(x: offsetX, y: 0.0), animated: true)
}
}
The original answer of Олень Безрогий had an issue, so on the last cell collection view was scrolling to the beginning
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
targetContentOffset.pointee = scrollView.contentOffset
var indexes = yourCollectionView.indexPathsForVisibleItems
indexes.sort()
var index = indexes.first!
// if velocity.x > 0 && (Get the number of items from your data) > index.row + 1 {
if velocity.x > 0 && yourCollectionView.numberOfItems(inSection: 0) > index.row + 1 {
index.row += 1
} else if velocity.x == 0 {
let cell = yourCollectionView.cellForItem(at: index)!
let position = yourCollectionView.contentOffset.x - cell.frame.origin.x
if position > cell.frame.size.width / 2 {
index.row += 1
}
}
yourCollectionView.scrollToItem(at: index, at: .centeredHorizontally, animated: true )
}
Ok so the proposed answers did'nt worked for me because I wanted to scroll by sections instead, and thus, have variable width page sizes
I did this (vertical only):
var pagesSizes = [CGSize]()
func scrollViewDidScroll(_ scrollView: UIScrollView) {
defer {
lastOffsetY = scrollView.contentOffset.y
}
if collectionView.isDecelerating {
var currentPage = 0
var currentPageBottom = CGFloat(0)
for pagesSize in pagesSizes {
currentPageBottom += pagesSize.height
if currentPageBottom > collectionView!.contentOffset.y {
break
}
currentPage += 1
}
if collectionView.contentOffset.y > currentPageBottom - pagesSizes[currentPage].height, collectionView.contentOffset.y + collectionView.frame.height < currentPageBottom {
return // 100% of view within bounds
}
if lastOffsetY < collectionView.contentOffset.y {
if currentPage + 1 != pagesSizes.count {
collectionView.setContentOffset(CGPoint(x: 0, y: currentPageBottom), animated: true)
}
} else {
collectionView.setContentOffset(CGPoint(x: 0, y: currentPageBottom - pagesSizes[currentPage].height), animated: true)
}
}
}
In this case, I calculate each page size beforehand using the section height + header + footer, and store it in the array. That's the pagesSizes
member
i created a custom collection view layout here that supports:
it's as easy as:
let layout = PagingCollectionViewLayout()
layout.itemSize =
layout.minimumLineSpacing =
layout.scrollDirection =
you can just add PagingCollectionViewLayout.swift to your project
or
add pod 'PagingCollectionViewLayout'
to your podfile
final class PagingFlowLayout: UICollectionViewFlowLayout {
private var currentIndex = 0
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
let count = collectionView!.numberOfItems(inSection: 0)
let currentAttribute = layoutAttributesForItem(
at: IndexPath(item: currentIndex, section: 0)
) ?? UICollectionViewLayoutAttributes()
let direction = proposedContentOffset.x > currentAttribute.frame.minX
if collectionView!.contentOffset.x + collectionView!.bounds.width < collectionView!.contentSize.width || currentIndex < count - 1 {
currentIndex += direction ? 1 : -1
currentIndex = max(min(currentIndex, count - 1), 0)
}
let indexPath = IndexPath(item: currentIndex, section: 0)
let closestAttribute = layoutAttributesForItem(at: indexPath) ?? UICollectionViewLayoutAttributes()
let centerOffset = collectionView!.bounds.size.width / 2
return CGPoint(x: closestAttribute.center.x - centerOffset, y: 0)
}
}
It is the best solution I ever seen. Just use it with .linear
type.
https://github.com/nicklockwood/iCarousel
God bless the author!:)
The only truly solid solution I have ever found is this:
Have your "visual" collection view. Which has the actual images in each cell. The visual collection view is the full width of the screen.
On top of that, i.e. directly above it, have you "touch" or "ghost" collection view. The cells are the same size, but the width of the touch collection view is actually only the width of your "real" cells in the "visual" collection view underneath.
The user only ever touches the "ghost" cv on top. And the ghost cv simply moves the "visual" cv.
It's actually remarkably easy to do this once you start setting it up.
All you need is one line of code to drive the underneath "visual" cv from the "ghost" cv, like
func scrollViewDidScroll(_ scrollView: UIScrollView) {
if scrollView == ghost {
// double trouble magic:
visual.contentOffset.x = ghost.contentOffset.x
}
}
(Note that, more specifically, that line will be something like visual.contentOffset.x = ghost.contentOffset.x - visual.contentInset.left
depending on your own style for setting up the two CVs.)
Once you try this approach, you'll never do it any other way, because it's absolutely rock solid, it literally works exactly as it should - since you're literally using a CV (the top one) that simply is "exactly the mechanism you want", by definition, no ifs ands or buts. It pages to the width of your cells, using obviously exactly the Apple physics/touch and that's that.
Obviously, the top "ghost" one simply has .. blank cells, ie just clear cells.
Incredibly, you just use the same data source for both !! Simply point both CVs at the same view controller for the numberOfItemsInSection
and cellForItemAt
calls!
Give it a try!
Once again, the whole thing is one line of code, given above.
If you think about it, the top "touch" one, the actual CV is as we say only as wide as one of your cells ... however, you want to be able to touch the top "touch" one anywhere on the full width of the screen. Of course, widening the touch area on any view is a very common issue you are familiar with, just use the usual solution, something like ...
class SuperWideCollectionView: UICollectionView {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { return bounds.insetBy(dx: -5000, dy: 0).contains(point) }
}
It's a great rig, and now the only way we do this.
This is one of the three or four Apple issues where it is "just incredible" they don't have a built in toggle in the relevant views where you can page by "item, not screen width" - but for now they don't!
Here is my way to do it by using a UICollectionViewFlowLayout
to override the targetContentOffset
:
(Although in the end, I end up not using this and use UIPageViewController instead.)
/**
A UICollectionViewFlowLayout with...
- paged horizontal scrolling
- itemSize is the same as the collectionView bounds.size
*/
class PagedFlowLayout: UICollectionViewFlowLayout {
override init() {
super.init()
self.scrollDirection = .horizontal
self.minimumLineSpacing = 8 // line spacing is the horizontal spacing in horizontal scrollDirection
self.minimumInteritemSpacing = 0
if #available(iOS 11.0, *) {
self.sectionInsetReference = .fromSafeArea // for iPhone X
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("not implemented")
}
// Note: Setting `minimumInteritemSpacing` here will be too late. Don't do it here.
override func prepare() {
super.prepare()
guard let collectionView = collectionView else { return }
collectionView.decelerationRate = UIScrollViewDecelerationRateFast // mostly you want it fast!
let insetedBounds = UIEdgeInsetsInsetRect(collectionView.bounds, self.sectionInset)
self.itemSize = insetedBounds.size
}
// Table: Possible cases of targetContentOffset calculation
// -------------------------
// start | |
// near | velocity | end
// page | | page
// -------------------------
// 0 | forward | 1
// 0 | still | 0
// 0 | backward | 0
// 1 | forward | 1
// 1 | still | 1
// 1 | backward | 0
// -------------------------
override func targetContentOffset( //swiftlint:disable:this cyclomatic_complexity
forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
guard let collectionView = collectionView else { return proposedContentOffset }
let pageWidth = itemSize.width + minimumLineSpacing
let currentPage: CGFloat = collectionView.contentOffset.x / pageWidth
let nearestPage: CGFloat = round(currentPage)
let isNearPreviousPage = nearestPage < currentPage
var pageDiff: CGFloat = 0
let velocityThreshold: CGFloat = 0.5 // can customize this threshold
if isNearPreviousPage {
if velocity.x > velocityThreshold {
pageDiff = 1
}
} else {
if velocity.x < -velocityThreshold {
pageDiff = -1
}
}
let x = (nearestPage + pageDiff) * pageWidth
let cappedX = max(0, x) // cap to avoid targeting beyond content
//print("x:", x, "velocity:", velocity)
return CGPoint(x: cappedX, y: proposedContentOffset.y)
}
}
You can use the following library: https://github.com/ink-spot/UPCarouselFlowLayout
It's very simple and ofc you do not need to think about details like other answers contain.
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity
velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
targetContentOffset.pointee = scrollView.contentOffset
var indexes = self.collectionHome.indexPathsForVisibleItems
indexes.sort()
var index = indexes.first!
let cell = self.collectionHome.cellForItem(at: index)!
let position = self.collectionHome.contentOffset.x - cell.frame.origin.x
if position > cell.frame.size.width/2{
index.row = index.row+1
}
self.collectionHome.scrollToItem(at: index, at: .left, animated: true )
}