What I want to get is the same behaviour that this scroll view has:
You can either turn on pagingEnabled
, or use decelerationRate = UIScrollViewDecelerationRateFast
combined with scrollViewDidEndDragging
and scrollViewDidEndDecelerating
(which will minimize the effect of slowing scrolling to one location and then animating again to another location). It's probably not precisely what you want, but it's pretty close. And by using animateWithDuration
it avoids the instantaneous jumping of that final snap.
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
{
if (!decelerate)
[self snapScrollView:scrollView];
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{
[self snapScrollView:scrollView];
}
You can then write a snapScrollView
, such as:
- (void)snapScrollView:(UIScrollView *)scrollView
{
CGPoint offset = scrollView.contentOffset;
if ((offset.x + scrollView.frame.size.width) >= scrollView.contentSize.width)
{
// no snap needed ... we're at the end of the scrollview
return;
}
// calculate where you want it to snap to
offset.x = floorf(offset.x / kIconOffset + 0.5) * kIconOffset;
// now snap it to there
[UIView animateWithDuration:0.1
animations:^{
scrollView.contentOffset = offset;
}];
}
Simple solution, works like App Store, with velocity or without it. kCellBaseWidth
— width of cell.
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
withVelocity:(CGPoint)velocity
targetContentOffset:(inout CGPoint *)targetContentOffset
{
NSInteger index = lrint(targetContentOffset->x/kCellBaseWidth);
targetContentOffset->x = index * kCellBaseWidth;
}
Setting scrollView.decelerationRate = UIScrollViewDecelerationRateFast
, combined with implementing scrollViewWillEndDragging:withVelocity:targetContentOffset:
, seems to work for me using a collection view.
First, I give myself some instance variables:
@implementation ViewController {
NSString *cellClassName;
CGFloat baseOffset;
CGFloat offsetStep;
}
In viewDidLoad
, I set the view's decelerationRate
:
- (void)viewDidLoad {
[super viewDidLoad];
cellClassName = NSStringFromClass([MyCell class]);
[self.collectionView registerNib:[UINib nibWithNibName:cellClassName bundle:nil] forCellWithReuseIdentifier:cellClassName];
self.collectionView.decelerationRate = UIScrollViewDecelerationRateFast;
}
I need offsetStep
to be the size of an integral number of items that fit in the view's on-screen bounds. I compute it in viewDidLayoutSubviews
:
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
UICollectionViewFlowLayout *layout = (UICollectionViewFlowLayout *)self.collectionView.collectionViewLayout;
CGFloat stepUnit = layout.itemSize.width + layout.minimumLineSpacing;
offsetStep = stepUnit * floorf(self.collectionView.bounds.size.width / stepUnit);
}
I need baseOffset
to the be the X offset of the view before scrolling starts. I initialize it in viewDidAppear:
:
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
baseOffset = self.collectionView.contentOffset.x;
}
Then I need to force the view to scroll in steps of offsetStep
. I do that in scrollViewWillEndDragging:withVelocity:targetContentOffset:
. Depending on the velocity
, I increase or decrease baseOffset
by offsetStep
. But I clamp baseOffset
to a minimum of 0 and a maximum of the contentSize.width - bounds.size.width
.
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
if (velocity.x < 0) {
baseOffset = MAX(0, baseOffset - offsetStep);
} else if (velocity.x > 0) {
baseOffset = MIN(scrollView.contentSize.width - scrollView.bounds.size.width, baseOffset + offsetStep);
}
targetContentOffset->x = baseOffset;
}
Note that I don't care what targetContentOffset->x
comes in as.
This has the effect of aligning to the left edge of the leftmost visible item, until the user scrolls all the way to the last item. At that point it aligns to the right edge of the rightmost visible item, until the user scroll all the way to the left. This seems to match the behavior of the App Store app.
If that doesn't work for you, you can try replacing the last line (targetContentOffset->x = baseOffset
) with this:
dispatch_async(dispatch_get_main_queue(), ^{
[scrollView setContentOffset:CGPointMake(baseOffset, 0) animated:YES];
});
That also works for me.
You can find my test app in this git repository.
Chiming in for Swift. @avdyushin's answer is by far the simplest and, as Ben mentioned, works very well. Although I did add a piece from @Rob's answer regarding the end of the scrollview. Together, this solution seems to work perfectly.
func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
if ((scrollView.contentOffset.x + scrollView.frame.size.width) >= scrollView.contentSize.width) {
// no snap needed ... we're at the end of the scrollview
return
}
let index: CGFloat = CGFloat(lrintf(Float(targetContentOffset.memory.x) / kCellBaseWidth))
targetContentOffset.memory.x = index * kCellBaseWidth
}
Just add your minimumLineSpacing
to kCellBaseWidth
, and voila.