[Swift] UIScrollView snapping into place like in the iOS App Store

I was asked recently to modify the UIScrollViews that I implemented in an iOS application design. The request was to have our scroll views behave like the ones that appear in the iOS App Store. Here is a screen capture of that ribbon to familiarize you with the scroll view in question.

appStoreScroll

Alright. In short the behavior prevents cell clipping when navigated to someplace in the middle section of the exposed cells. If the user is at either the beginning or the end of the content, you’ll see peak-through which is desired. But anywhere else, don’t clip the display of a cell on the left side.

I searched around doing the StackOverflow dance and found a few posts about this very thing. One of them mentioned the App Store specifically and that’s where I lifted the screen capture. The explanations were up-voted but when I tried them, they only worked part of the way. I’d get some wonky, sticky behavior. I had to convert many of them to Swift as well as they were offered as Objective-C solutions.

I started to get frustrated – just wanting this thing to work quickly, show it to the person who requested the behavior, and then move on to more pressing application authoring issues. Meaning bigger stuff. So after some time trying to modify someone else’s code to work I decided to junk it and use their concept and code it myself.

The place you want to put some code would be in your UIScrollView’s scrollWillEndDragging delegate method. Make sure that you include UIScrollViewDelegate in your class and to set your scroll view delegate appropriately.

class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, UIScrollViewDelegate {
   ...

And then

...
myScrollView.delegate = self

When those things are done, the scroll view will call it’s delegate methods. Here is a solution written in Swift that I used to get this behavior and I’ll explain some if it for you.

func scrollViewWillEndDragging(scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
        if scrollView.tag == 99 {
            let pageWidth:CGFloat = 170 //160 plus the 10x on each side.
            let val:CGFloat = scrollView.contentOffset.x / pageWidth
            var newPage = NSInteger(val)
            
            if (velocity.x == 0)
            {
                newPage = Int(floor((targetContentOffset.memory.x - pageWidth / 2) / pageWidth) + 1)
            } else {
                print(velocity.x)
                if(velocity.x < 0)
                {
                    let diff:CGFloat = val - CGFloat(newPage)
                    if(diff > 0.6){
                        newPage++
                    }
                }
                newPage = velocity.x > 0 ? newPage + 1 : newPage - 1
                
                //Velocity adjustments.
                if velocity.x > 2.7 {
                    newPage += 2
                } else if velocity.x > 2.2 {
                    newPage++
                }
                if velocity.x < -2.7 {
                    newPage -= 2
                } else if velocity.x < -2.2 {
                    newPage--
                }
                
                if (newPage < 0){
                    newPage = 0
                }
                if (newPage > NSInteger(scrollView.contentSize.width / pageWidth)){
                    newPage = NSInteger(ceil(scrollView.contentSize.width / pageWidth) - 1.0)
                }
            }
            targetContentOffset.memory.x = CGFloat(newPage) * pageWidth
        }
    }

Since I had several scroll views in this view, I wanted to make sure I could target them individually. I didn’t set them up as class members – I simply used tags to mark them for evaluation. For instance 1 scroll view has the tag 100, another 99, etc. As long as I know which tag calls the delegate method (they all will), I can address them individually. So in this example I’m looking for tag of 99.

The pageWidth is that scroll view’s cell width including the padding. I’ve hard-coded that, you could use a global variable or whatever. If that number changes when the scroll view is populated with cells, you have to remember to change that value in the delegate method. So it’s a little messy, but not a deal breaker in my opinion.

We then determine which page we’re currently on. We’ll use that later to make adjustments if required. The velocity adjustments are there in case the user wants to “throw” the scroll view contents… since we’re making the scroll view sticky, this makes the behavior a little more like the traditional scroll view in regards to perceived smoothness.

If the user throws with a certain velocity, we adjust how many cell indexes becomes the target. If you have a lot of content in your scroll view, you may want to adjust more for velocity – adding more distance for the offset target. Apple does this in their implementation. They have about 3 or 4 “pages” of content. A high velocity drag has a target to get you to the next batch of off-screen content. It’s slick.

We update the targetContentOffset.x with the target “page” * the pageWidth. When that’s updated, the scroll view uses that and updates it’s target position. You end up getting snapping on your cell content just like you would in the iOS App. It’s not a ton of code either.

A note: I read where people were changing the deceleration of the scroll view to make it feel even snappier.

scrollView.decelerationRate = UIScrollViewDecelerationRateFast

I tried it and I felt it made things feel too snappy. It reduced the perception of fluidity and made it feel like something was subtlely wrong with the application or control. So I didn’t use the technique and feel good about that decision. Your milage may vary.

One thought on “[Swift] UIScrollView snapping into place like in the iOS App Store

  1. Thanks for this post. Exactly what I was looking for except for height but hat was an easy adjustment

Leave a Reply

Your email address will not be published. Required fields are marked *

Time limit is exhausted. Please reload CAPTCHA.

This site uses Akismet to reduce spam. Learn how your comment data is processed.