iosswiftanimationuiviewuianimation

Slide out animation of three elements on top of each other


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 :)


Solution

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

    Here's another approach...

    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