UICollectionView snap onto cell when scrolling hor

2019-01-30 11:30发布

I know some people have asked this question before but they were all about UITableViews or UIScrollViews and I couldn't get the accepted solution to work for me. What I would like is the snapping effect when scrolling through my UICollectionView horizontally - much like what happens in the iOS AppStore. iOS 9+ is my target build so please look at the UIKit changes before answering this.

Thanks.

13条回答
ら.Afraid
2楼-- · 2019-01-30 11:53

Got an answer from SO post here and docs here

First What you can do is set your collection view's scrollview's delegate your class by making your class a scrollview delegate

MyViewController : SuperViewController<... ,UIScrollViewDelegate>

Then make set your view controller as the delegate

UIScrollView *scrollView = (UIScrollView *)super.self.collectionView;
scrollView.delegate = self;

Or do it in the interface builder by control + shift clicking on your collection view and then control + drag or right click drag to your view controller and select delegate. (You should know how to do this). That doesn't work. UICollectionView is a subclass of UIScrollView so you will now be able to see it in the interface builder by control + shift clicking

Next implement the delegate method - (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView

MyViewController.m

... 

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{

}

The docs state that:

Parameters

scrollView | The scroll-view object that is decelerating the scrolling of the content view.

Discussion The scroll view calls this method when the scrolling movement comes to a halt. The decelerating property of UIScrollView controls deceleration.

Availability Available in iOS 2.0 and later.

Then inside of that method check which cell was closest to the center of the scrollview when it stopped scrolling

- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{
  //NSLog(@"%f", truncf(scrollView.contentOffset.x + (self.pictureCollectionView.bounds.size.width / 2)));

float visibleCenterPositionOfScrollView = scrollView.contentOffset.x + (self.pictureCollectionView.bounds.size.width / 2);

//NSLog(@"%f", truncf(visibleCenterPositionOfScrollView / imageArray.count));


NSInteger closestCellIndex;

for (id item in imageArray) {
    // equation to use to figure out closest cell
    // abs(visibleCenter - cellCenterX) <= (cellWidth + cellSpacing/2)

    // Get cell width (and cell too)
    UICollectionViewCell *cell = (UICollectionViewCell *)[self collectionView:self.pictureCollectionView cellForItemAtIndexPath:[NSIndexPath indexPathWithIndex:[imageArray indexOfObject:item]]];
    float cellWidth = cell.bounds.size.width;

    float cellCenter = cell.frame.origin.x + cellWidth / 2;

    float cellSpacing = [self collectionView:self.pictureCollectionView layout:self.pictureCollectionView.collectionViewLayout minimumInteritemSpacingForSectionAtIndex:[imageArray indexOfObject:item]];

    // Now calculate closest cell

    if (fabsf(visibleCenterPositionOfScrollView - cellCenter) <= (cellWidth + (cellSpacing / 2))) {
        closestCellIndex = [imageArray indexOfObject:item];
        break;
    }
}

if (closestCellIndex != nil) {

[self.pictureCollectionView scrollToItemAtIndexPath:[NSIndexPath indexPathWithIndex:closestCellIndex] atScrollPosition:UICollectionViewScrollPositionCenteredVertically animated:YES];

// This code is untested. Might not work.

}
查看更多
Juvenile、少年°
3楼-- · 2019-01-30 11:54

This from a 2012 WWDC video for an Objective-C solution. I subclassed UICollectionViewFlowLayout and added the following.

-(CGPoint)targetContentOffsetForProposedContentOffset:(CGPoint)proposedContentOffset withScrollingVelocity:(CGPoint)velocity
    {
        CGFloat offsetAdjustment = MAXFLOAT;
        CGFloat horizontalCenter = proposedContentOffset.x + (CGRectGetWidth(self.collectionView.bounds) / 2);

        CGRect targetRect = CGRectMake(proposedContentOffset.x, 0.0, self.collectionView.bounds.size.width, self.collectionView.bounds.size.height);
        NSArray *array = [super layoutAttributesForElementsInRect:targetRect];

        for (UICollectionViewLayoutAttributes *layoutAttributes in array)
        {
            CGFloat itemHorizontalCenter = layoutAttributes.center.x;
            if (ABS(itemHorizontalCenter - horizontalCenter) < ABS(offsetAdjustment))
            {
                offsetAdjustment = itemHorizontalCenter - horizontalCenter;
            }
        }

        return CGPointMake(proposedContentOffset.x + offsetAdjustment, proposedContentOffset.y);
    }

And the reason I got to this question was for the snapping with a native feel, which I got from Mark's accepted answer... this I put in the collectionView's view controller.

collectionView.decelerationRate = UIScrollViewDecelerationRateFast;
查看更多
淡お忘
4楼-- · 2019-01-30 11:55

Based on answer from Mete and comment from Chris Chute,

Here's a Swift 4 extension that will do just what OP wants. It's tested on single row and double row nested collection views and it works just fine.

extension UICollectionView {
    func scrollToNearestVisibleCollectionViewCell() {
        self.decelerationRate = UIScrollViewDecelerationRateFast
        let visibleCenterPositionOfScrollView = Float(self.contentOffset.x + (self.bounds.size.width / 2))
        var closestCellIndex = -1
        var closestDistance: Float = .greatestFiniteMagnitude
        for i in 0..<self.visibleCells.count {
            let cell = self.visibleCells[i]
            let cellWidth = cell.bounds.size.width
            let cellCenter = Float(cell.frame.origin.x + cellWidth / 2)

            // Now calculate closest cell
            let distance: Float = fabsf(visibleCenterPositionOfScrollView - cellCenter)
            if distance < closestDistance {
                closestDistance = distance
                closestCellIndex = self.indexPath(for: cell)!.row
            }
        }
        if closestCellIndex != -1 {
            self.scrollToItem(at: IndexPath(row: closestCellIndex, section: 0), at: .centeredHorizontally, animated: true)
        }
    }
}

You need to implement UIScrollViewDelegate protocol for your collection view and then add these two methods:

func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
    self.collectionView.scrollToNearestVisibleCollectionViewCell()
}

func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
    if !decelerate {
        self.collectionView.scrollToNearestVisibleCollectionViewCell()
    }
}
查看更多
Anthone
5楼-- · 2019-01-30 11:58

If you want simple native behavior, without customization:

collectionView.pagingEnabled = YES;

This only works properly when the size of the collection view layout items are all one size only and the UICollectionViewCell's clipToBounds property is set to YES.

查看更多
▲ chillily
6楼-- · 2019-01-30 11:59

Snap to the nearest cell, respecting scroll velocity.

Works without any glitches.

import UIKit

class SnapCenterLayout: UICollectionViewFlowLayout {
  override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
    guard let collectionView = collectionView else { return super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity) }
    let parent = super.targetContentOffset(forProposedContentOffset: proposedContentOffset, withScrollingVelocity: velocity)

    let itemSpace = itemSize.width + minimumInteritemSpacing
    var currentItemIdx = round(collectionView.contentOffset.x / itemSpace)

    // Skip to the next cell, if there is residual scrolling velocity left.
    // This helps to prevent glitches
    let vX = velocity.x
    if vX > 0 {
      currentItemIdx += 1
    } else if vX < 0 {
      currentItemIdx -= 1
    }

    let nearestPageOffset = currentItemIdx * itemSpace
    return CGPoint(x: nearestPageOffset,
                   y: parent.y)
  }
}
查看更多
迷人小祖宗
7楼-- · 2019-01-30 12:05

I just found what I think is the best possible solution to this problem:

First add a target to the collectionView's already existing gestureRecognizer:

[self.collectionView.panGestureRecognizer addTarget:self action:@selector(onPan:)];

Have the selector point to a method which takes a UIPanGestureRecognizer as a parameter:

- (void)onPan:(UIPanGestureRecognizer *)recognizer {};

Then in this method, force the collectionView to scroll to the appropriate cell when the pan gesture has ended. I did this by getting the visible items from the collection view and determining which item I want to scroll to depending on the direction of the pan.

if (recognizer.state == UIGestureRecognizerStateEnded) {

        // Get the visible items
        NSArray<NSIndexPath *> *indexes = [self.collectionView indexPathsForVisibleItems];
        int index = 0;

        if ([(UIPanGestureRecognizer *)recognizer velocityInView:self.view].x > 0) {
            // Return the smallest index if the user is swiping right
            for (int i = index;i < indexes.count;i++) {
                if (indexes[i].row < indexes[index].row) {
                    index = i;
                }
            }
        } else {
            // Return the biggest index if the user is swiping left
            for (int i = index;i < indexes.count;i++) {
                if (indexes[i].row > indexes[index].row) {
                    index = i;
                }
            }
        }
        // Scroll to the selected item
        [self.collectionView scrollToItemAtIndexPath:indexes[index] atScrollPosition:UICollectionViewScrollPositionLeft animated:YES];
    }

Keep in mind that in my case only two items can be visible at a time. I'm sure this method can be adapted for more items however.

查看更多
登录 后发表回答