Swift: Parallax Scrolling with Header sticking at top (nested UIScrollView)

Swift 2.3 solution for parallax scrolling of UIScrollView inside other UIScrollView

Final project in github: github link

I recently was working with two scrollviews in a screen. One scrollview was the subview of other scrollview. And I needed the parallax scrolling effect i.e. when one scrollview scrolls, next scrollView must scroll and some part of outer scrollview should stick at the top of visible area of View.

ezgif.com-video-to-gif
Case

Below is the view controller scene from storyboard:

design
Design (View Hierarchy)

We need to design our storyboard first, design is also an important part of this solution. Design and constraints are given as you think is necessary to make view appear as above.

While giving height constraint for tableView (from now it will be called child scrollView), give height equal to main view of view controller.

constraint height
Give equal Height Constraint to child scrollView with respect to main View of View Controller
  1. Click [ctrl + click] on child scrollView and drag to main View and release and select Equal Heights.
  2. Select child scrollView from view hierarchy
  3. Go to Size inspector on left side (utilities panel)Screen Shot 2016-08-25 at 3.25.24 PM
  4. Now, scroll down to section constraints in Size inspector and select Height constraint that you just gave in no 1.

    Screen Shot 2016-08-25 at 3.29.09 PM.png
    Equal Height to: View
  5. Now, click edit on that constraint and give negative constant which is equal to height of the menu (view portion that is to stick on top). In my case its 75 pt. so I give constant equal to -75. #Sometimes, -75 won’t be sufficient to get expected because of status bar or navigation bar on View. You need to manually decrease the value so that you get expected output. In my case, status bar gave little problem so, menu went more up after scrolled on completion. So I, changed constraint constant to -95.

Now, the coding part.

  1. Create IBOutlet for both child scrollView and parent scrollView in ViewController
    @IBOutlet weak var parentScrollView: UIScrollView! 
    @IBOutlet weak var tableView: UITableView!
    

    I have used tableView as childScrollView.

  2. Assign delegate of both scrollView to self in viewDidLoad
    parentScrollView.delegate = self
    tableView.delegate = self

    since, UITableViewDelegate also confirms UIScrollViewDelegate assigning delegate of tableView makes ViewController be delegate of tableView’s scroll events.

  3. Declare two instance variables for the class
    var goingUp: Bool? //to track is scrollView is going up or down
    var childScrollingDownDueToParent = false // track if child scrollView is scrolling due to scroll in parent or itself
  4. Implement UIScrollViewDelegate
    func scrollViewDidScroll(scrollView: UIScrollView) {
        // 1: determining whether scrollview is scrolling up or down
        goingUp = scrollView.panGestureRecognizer.translationInView(scrollView).y < 0
        
        // 2: maximum contentOffset y that parent scrollView can have
        let parentViewMaxContentYOffset = parentScrollView.contentSize.height - parentScrollView.frame.height
        
        // 3: if scrollView is going upwards
        if goingUp! {
            // 4:  if scrollView is a child scrollView
            
            if scrollView == childScrollView {
                // 5:  if parent scroll view is't scrolled maximum (i.e. menu isn't sticked on top yet)
                if parentScrollView.contentOffset.y < parentViewMaxContentYOffset && !childScrollingDownDueToParent {
                    
                    // 6: change parent scrollView contentOffset y which is equal to minimum between maximum y offset that parent scrollView can have and sum of parentScrollView's content's y offset and child's y content offset. Because, we don't want parent scrollView go above sticked menu.
                    // Scroll parent scrollview upwards as much as child scrollView is scrolled
                    parentScrollView.contentOffset.y = min(parentScrollView.contentOffset.y + childScrollView.contentOffset.y, parentViewMaxContentYOffset)
                    
                    // 7: change child scrollView's content's y offset to 0 because we are scrolling parent scrollView instead with same content offset change.
                    childScrollView.contentOffset.y = 0
                }
            }
        }
            // 8: Scrollview is going downwards
        else {
            
            if scrollView == childScrollView {
                // 9: when child view scrolls down. if childScrollView is scrolled to y offset 0 (child scrollView is completely scrolled down) then scroll parent scrollview instead
                // if childScrollView's content's y offset is less than 0 and parent's content's y offset is greater than 0
                if childScrollView.contentOffset.y < 0 && parentScrollView.contentOffset.y > 0 {
                    
                    // 10: set parent scrollView's content's y offset to be the maximum between 0 and difference of parentScrollView's content's y offset and absolute value of childScrollView's content's y offset
                    // we don't want parent to scroll more that 0 i.e. more downwards so we use max of 0.
                    parentScrollView.contentOffset.y = max(parentScrollView.contentOffset.y - abs(childScrollView.contentOffset.y), 0)
                }
            }
            
            // 11: if downward scrolling view is parent scrollView
            if scrollView == parentScrollView {
                // 12: if child scrollView's content's y offset is greater than 0. i.e. child is scrolled up and content is hiding up
                // and parent scrollView's content's y offset is less than parentView's maximum y offset
                // i.e. if child view's content is hiding up and parent scrollView is scrolled down than we need to scroll content of childScrollView first
                if childScrollView.contentOffset.y > 0 && parentScrollView.contentOffset.y < parentViewMaxContentYOffset {
                    // 13:  set if scrolling is due to parent scrolled
                    childScrollingDownDueToParent = true
                    // 14:  assign the scrolled offset of parent to child not exceding the offset 0 for child scroll view
                    childScrollView.contentOffset.y = max(childScrollView.contentOffset.y - (parentViewMaxContentYOffset - parentScrollView.contentOffset.y), 0)
                    // 15:  stick parent view to top coz it's scrolled offset is assigned to child
                    parentScrollView.contentOffset.y = parentViewMaxContentYOffset
                    childScrollingDownDueToParent = false
                }
            }
        }
    }

Why that negative constant while designing ? Never mind. 😛 

Its because, parent scrollView needs content size, so we kept on giving height constraint to all views added in scrollView. Specifically, we gave child scrollView’s height equal to height of main view so that scrollView gets content size. Now, parent scroll view will scroll maximum till our child scrollView’s frame will appear on screen. If you don’t give constant to the constraint, menu won’t stick to top. Giving constant -95 to height constraint means we are fitting a menu and child scrollview frame in screen i.e. when parent scrollView scroll’s maximum then also menu and child scrollView will appear in screen.

Why childScrollingDownDueToParent ?

If you remove it from the places we have used, we will get a glitch like when we scroll down parent scrollView. Tap on parent scrollView and pull down. Child scrollView appears to scroll to top as once. So, to remove it we use this variable to denote if child is scrolling due to parent or due to itself. If it is scrolled due to parent then we don’t set content’s y offset to be ‘0’ at number 5 comment line.

Final project in github: github link