Slide out animation of three elements on top of each other

220 Views Asked by At

I'm looking for the best way to make the following animation. I've tried some solutions, but none of them seem to tackle my problem as every single solution seems to fail at some point. The animation I need to do is the following:

enter image description here

User swipes his finger up and the tableView & 1 & 2 scroll up (it's like tableView & 1 & 2 are one scrollable element). Then, when 2 becomes invisible upon the scroll, 3 & 1 & tableView become scrollable (again, as if it were one scrollable element). Then, when 3 becomes invisible (as it is scrolled) the tableView is then the only scrollable element.

What I tried:

  1. I initially tried with simple animations like changing height constraints for every 1/2/3 element based on the scroll offset and although it seemed fine to me, it didn't to the reviewer as he wanted more precise scrolling in between hiding elements animations

  2. I then tried to combine panGesture with scroll. I embedded 1 & 2 and tableView into one scroll View and set panGesture recognizer for it with delegate function shouldRecognizeSimultaneouslyWith returning true while having tableView scrolling disabled. Then, upon intersecting 3 I was disabling the panGesture and tried to enable tableView scrolling, but failed to recognize which panGesture/scrolling works or not, which one to disable, which one to fail or work alone/simultaneously.

How would you dear developers tackle it so that the animations are smooth and as described? Perhaps, you have an awesome idea :)

2

There are 2 best solutions below

1
DonMag On BEST ANSWER

I think you'll be fighting a losing battle trying to toggle scrolling.

Here's another approach...

  • add the tableView as a subview of a "container" UIView
  • add the three "top" views as subviews
  • give the tableView a contentInset.top of the height of the three views plus vertical spacing
  • constrain the three "top" views so
    • 3 will stick to the top, until it is pushed up by 1
    • 2 till slide under 3 when 1 pushes it up
    • update the top constraint for 1 when the tableView scrolls

You can try it with this example code (no @IBOutlet connections needed):

class ExampleViewController: UIViewController, UIScrollViewDelegate {
    
    let tableView: UITableView = {
        let v = UITableView()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.separatorInset = .zero
        return v
    }()
    let view1: UILabel = {
        let v = UILabel()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.textAlignment = .center
        v.backgroundColor = .systemRed
        v.text = "1"
        return v
    }()
    let view2: UILabel = {
        let v = UILabel()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.textAlignment = .center
        v.backgroundColor = .systemGreen
        v.text = "2"
        return v
    }()
    let view3: UILabel = {
        let v = UILabel()
        v.translatesAutoresizingMaskIntoConstraints = false
        v.textAlignment = .center
        v.backgroundColor = .systemBlue
        v.text = "3"
        return v
    }()
    
    // this will hold the tableView and the
    //  three other views
    let containerView: UIView = {
        let v = UIView()
        v.translatesAutoresizingMaskIntoConstraints = false
        // clip to bounds to prevent the "top" views from showing
        //  as they are "pushed up" out of bounds
        v.clipsToBounds = true
        return v
    }()
    
    // this constraint constant will be changed
    //  in scrollViewDidScroll
    @IBOutlet var view1TopConstraint: NSLayoutConstraint!
    
    override func viewDidLoad() {
        super.viewDidLoad()

        // add our container view
        view.addSubview(containerView)
        
        // add our tableView and three "top" views
        containerView.addSubview(tableView)
        
        containerView.addSubview(view2)
        containerView.addSubview(view3)
        containerView.addSubview(view1)
        
        for v in [view1, view2, view3] {
            // all three "top" views should be
            //  equal width to tableView
            //  horizontally centered to tableView
            //  40-pts tall
            NSLayoutConstraint.activate([
                v.widthAnchor.constraint(equalTo: tableView.widthAnchor),
                v.centerXAnchor.constraint(equalTo: tableView.centerXAnchor),
                v.heightAnchor.constraint(equalToConstant: 40.0),
            ])
        }

        let g = view.safeAreaLayoutGuide
        NSLayoutConstraint.activate([

            // constrain container view with 20-pts "padding"
            containerView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
            containerView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
            containerView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
            containerView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: -20.0),

            // constrain all 4 sides of tableView ot container view
            tableView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 0.0),
            tableView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 0.0),
            tableView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 0.0),
            tableView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 0.0),

        ])
        
        // view3 should stick to the top of the table,
        //  unless it's being pushed up by view1
        let v3Top = view3.topAnchor.constraint(equalTo: tableView.topAnchor)
        v3Top.priority = .defaultHigh + 1

        // view2 should stick to the bottom of view3,
        //  unless it's being pushed up by view1
        let v2Top = view2.topAnchor.constraint(equalTo: view3.bottomAnchor, constant: 4.0)
        v2Top.priority = .defaultHigh

        // view1 should ALWAYS be 4-pts from bottom of view2
        let v1TopA = view1.topAnchor.constraint(equalTo: view2.bottomAnchor, constant: 4.0)
        v1TopA.priority = .required
        
        // view1 should ALWAYS be greater-than-or-equal 4-pts from bottom of view3
        let v1TopB = view1.topAnchor.constraint(greaterThanOrEqualTo: view3.bottomAnchor, constant: 4.0)
        v1TopB.priority = .required

        // view1 top should ALWAYS be greater-than-or-equal top of tableView
        let v1TopC = view1.topAnchor.constraint(greaterThanOrEqualTo: tableView.topAnchor)
        v1TopC.priority = .required
        
        // 88-pts is 40-pts for view3 and view2 plus 4-pts vertical spacing

        // view1 top should NEVER be more-than 88-pts from top of tableView
        let v1TopD = view1.topAnchor.constraint(lessThanOrEqualTo: tableView.topAnchor, constant: 88.0)
        v1TopD.priority = .required
        
        // view1 top will start at 88-pts from top of tableView
        view1TopConstraint = view1.topAnchor.constraint(equalTo: tableView.topAnchor, constant: 88.0)
        view1TopConstraint.priority = .defaultHigh + 2

        // activate those constraints
        NSLayoutConstraint.activate([
            v3Top,
            v2Top,
            v1TopA,
            v1TopB,
            v1TopC,
            v1TopD,
            view1TopConstraint,
        ])

        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        tableView.dataSource = self
        tableView.delegate = self

        // top inset is
        //  three 40-pt tall views
        //  plus 4-pts vertical spacing between each
        //  and 4-pts vertical spacing below view1
        tableView.contentInset.top = 132
        tableView.contentOffset.y = -132
    }
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        // we're getting called when the tableView scrolls
        // invert the contentOffset y
        let y = -scrollView.contentOffset.y
        // subtract 44-pts (40-pt tall view plus 4-pts vertical spacing)
        view1TopConstraint.constant = y - 44.0
    }
    
}

extension ExampleViewController: UITableViewDelegate, UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 30
    }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let c = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        c.textLabel?.text = "Row \(indexPath.row)"
        return c
    }
}

Here's how it looks to start:

enter image description here

then, after scrolling up just a bit (2 is sliding under 3):

enter image description here

scrolling a bit more (1 is pushing 3 up and out of view):

enter image description here

and then table scrolling while 1 remains at the top:

enter image description here

0
Tarun Tyagi On

I'm using a simplified version of your idea with a UIScrollView & 3 UILabel instances.

You can easily adapt this to be UITableView & 3 UIView instances.

Idea

  1. UIScrollView & 3 UILabel instances have a common superview. In this case it's UIViewController.view.
  2. UIScrollView is laid out to be full screen (edge-to-edge) and extends below these 3 UILabel instances.
  3. UIScrollView has contentInset.top = height_of_three_labels so it's content starts below these other instances.
  4. Whenever UIScrollView.contentOffset changes, we shift frame.origin.y for these 3 instances to provide the needed effect.

UI Setup

import UIKit

class ViewController: UIViewController {
    
    let tileHeight: CGFloat = 60
    let view1 = UILabel()
    let view2 = UILabel()
    let view3 = UILabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let bounds = self.view.bounds
        
        let scrollView = UIScrollView(frame: bounds)
        scrollView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        scrollView.delegate = self
        scrollView.alwaysBounceVertical = true
        self.view.addSubview(scrollView)
        
        view1.frame = CGRect(x: 0, y: 2*tileHeight, width: bounds.width, height: tileHeight)
        configureLabel(view1, text: "1", backgroundColor: .red)
        
        view2.frame = CGRect(x: 0, y: tileHeight, width: bounds.width, height: tileHeight)
        configureLabel(view2, text: "2", backgroundColor: .blue)
        
        view3.frame = CGRect(x: 0, y: 0, width: bounds.width, height: tileHeight)
        configureLabel(view3, text: "3", backgroundColor: .green)
        
        scrollView.contentInset = UIEdgeInsets(top: 3*tileHeight, left: 0, bottom: 0, right: 0)
        
        // 3 is between 2 and 1, 1 is at the top, order is important here
        self.view.addSubview(view2)
        self.view.addSubview(view3)
        self.view.addSubview(view1)
    }
    
    private func configureLabel(_ label: UILabel, text: String, backgroundColor: UIColor) {
        label.text = text
        label.textColor = .white
        label.font = .boldSystemFont(ofSize: 34)
        label.textAlignment = .center
        label.backgroundColor = backgroundColor
    }
    
}

Scroll Management

extension ViewController: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let offset = scrollView.adjustedContentInset.top + scrollView.contentOffset.y
        print(offset)
        if offset > 0 {
            view3.frame.origin.y = (offset > tileHeight) ? (tileHeight - offset) : 0
            view2.frame.origin.y = tileHeight - offset
            view1.frame.origin.y = 2*tileHeight - offset
        }
        else {
            view3.frame.origin.y = 0
            view2.frame.origin.y = tileHeight
            view1.frame.origin.y = 2*tileHeight
        }
    }
}

Output

enter image description here