Search code examples
iosswiftuiviewanimation

Animating UICollectionView With Auto Constraints Swift


I have a UICollectionView inside an inputAccessoryView for selecting images while creating a post (similar to Twitter).

When the user starts typing I want to animate the UICollectionView down with a UIView animation function.

Preferred Outcome

twitter collection view

Code

func animateCollectionView() {
    UIView.animate(withDuration: 1, delay: 0, options: .showHideTransitionViews) {
        self.collectionView.transform = .init(scaleX: 0, y: 100)
    } completion: { finished in
        if finished {
            print("ANIMATION COMPLETED")
        }
    }
}

With this, the UICollectionView gets removed immediately and the console is printing after 1 second (as expected). However, the animation is not happening.

Constraints

NSLayoutConstraint.activate([
            uploadVoiceNoteButton.heightAnchor.constraint(equalToConstant: 48),
            uploadMediaButton.heightAnchor.constraint(equalToConstant: 48),
            uploadPollButton.heightAnchor.constraint(equalToConstant: 48),
            characterCountView.heightAnchor.constraint(equalToConstant: 48),
            characterCountView.widthAnchor.constraint(equalToConstant: 18),
        
        hStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
        hStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
        hStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
        
        separator.heightAnchor.constraint(equalToConstant: Height.separator),
        separator.leadingAnchor.constraint(equalTo: leadingAnchor),
        separator.trailingAnchor.constraint(equalTo: trailingAnchor),
        separator.topAnchor.constraint(equalTo: hStackView.topAnchor),
        
        replyAllowanceButton.heightAnchor.constraint(equalToConstant: 51),
        replyAllowanceButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
        replyAllowanceButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
        replyAllowanceButton.bottomAnchor.constraint(equalTo: separator.topAnchor),
        
        collectionViewSeparator.bottomAnchor.constraint(equalTo: replyAllowanceButton.topAnchor),
        collectionViewSeparator.leadingAnchor.constraint(equalTo: leadingAnchor),
        collectionViewSeparator.trailingAnchor.constraint(equalTo: trailingAnchor),
        collectionViewSeparator.heightAnchor.constraint(equalToConstant: Height.separator),
        
        collectionView.topAnchor.constraint(equalTo: topAnchor),
        collectionView.leadingAnchor.constraint(equalTo: leadingAnchor),
        collectionView.trailingAnchor.constraint(equalTo: trailingAnchor),
        collectionView.bottomAnchor.constraint(equalTo: collectionViewSeparator.topAnchor, constant: -8),
    ])

Solution

  • I would suggest embedding the collection view and the separator view in a "container" UIView.

    Give the collection view Leading/Trailing (to the container view) and Height constraints, but no Bottom constraint (and no Top constraint yet).

    Give the separator view Leading/Trailing (to the container view), Top to the collection view Bottom, and Height constraints, but no Bottom constraint.

    Give the container view a height constraint so collection view and separator view (and maybe a little spacing) will fit.

    Add "visible" and "hidden" constraints as var properties:

    var cvVisibleConstraint: NSLayoutConstraint!
    var cvHiddenConstraint: NSLayoutConstraint!
    

    then, when we're setting all the other constraints, create those two like this:

        // collectionView TOP constrained to TOP of container when visible
        cvVisibleConstraint = collectionView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 8.0)
    
        // collectionView TOP constrained to BOTTOM of container when hidden
        cvHiddenConstraint = collectionView.topAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 8.0)
    

    To show/hide the collection view (and the separator, because it's constrained to the collection view):

    containerView.clipsToBounds = true
    

    and:

    cvVisibleConstraint.isActive.toggle()
    cvHiddenConstraint.isActive = !cvVisibleConstraint.isActive
    UIView.animate(withDuration: 0.5, animations: {
        self.view.layoutIfNeeded()
    })
    

    Here's an example... I'm adding a view to the main view to simulate the input accessory view, but the approach is the same:

    class ShowHideVC: UIViewController {
        
        var collectionView: UICollectionView!
        let collectionViewSeparator = UIView()
        let containerView = UIView()
        let myInputAccessoryView = UIView()
        
        var cvVisibleConstraint: NSLayoutConstraint!
        var cvHiddenConstraint: NSLayoutConstraint!
    
        override func viewDidLoad() {
            super.viewDidLoad()
            
            let fl = UICollectionViewFlowLayout()
            fl.scrollDirection = .horizontal
            fl.itemSize = CGSize(width: 72.0, height: 72.0)
            collectionView = UICollectionView(frame: .zero, collectionViewLayout: fl)
            
            [myInputAccessoryView, containerView, collectionViewSeparator, collectionView].forEach { v in
                v?.translatesAutoresizingMaskIntoConstraints = false
            }
            
            containerView.addSubview(collectionView)
            containerView.addSubview(collectionViewSeparator)
            myInputAccessoryView.addSubview(containerView)
            
            view.addSubview(myInputAccessoryView)
            
            let g = view.safeAreaLayoutGuide
            
            // collectionView TOP constrained to TOP of container when visible
            cvVisibleConstraint = collectionView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 8.0)
    
            // collectionView TOP constrained to BOTTOM of container when hidden
            cvHiddenConstraint = collectionView.topAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 8.0)
    
            // setting priorities to (999) avoids auto-layout complaints when toggling active
            cvVisibleConstraint.priority = .required - 1
            cvHiddenConstraint.priority = .required - 1
    
            NSLayoutConstraint.activate([
                
                myInputAccessoryView.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                myInputAccessoryView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 8.0),
                myInputAccessoryView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -8.0),
                
                myInputAccessoryView.heightAnchor.constraint(equalToConstant: 200.0),
                
                containerView.topAnchor.constraint(equalTo: myInputAccessoryView.topAnchor, constant: 8.0),
                containerView.leadingAnchor.constraint(equalTo: myInputAccessoryView.leadingAnchor, constant: 8.0),
                containerView.trailingAnchor.constraint(equalTo: myInputAccessoryView.trailingAnchor, constant: -8.0),
                
                containerView.heightAnchor.constraint(equalToConstant: 100.0),
                
                // start with collection view showing
                cvVisibleConstraint,
                
                collectionView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8.0),
                collectionView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8.0),
    
                collectionView.heightAnchor.constraint(equalToConstant: 78.0),
                
                collectionViewSeparator.topAnchor.constraint(equalTo: collectionView.bottomAnchor, constant: 8.0),
                collectionViewSeparator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8.0),
                collectionViewSeparator.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8.0),
                
                collectionViewSeparator.heightAnchor.constraint(equalToConstant: 1.0),
    
                // no bottom constraints for collectionView or collectionViewSeparator
                
            ])
            
            collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "c")
            collectionView.dataSource = self
            collectionView.delegate = self
            
            // colors so we can see framing
            collectionView.backgroundColor = .systemGreen
            collectionViewSeparator.backgroundColor = .red
            containerView.backgroundColor = .yellow
            myInputAccessoryView.backgroundColor = .systemYellow
            
            // comment / un-comment the next line to see what's really going on
            containerView.clipsToBounds = true
        }
    
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            cvVisibleConstraint.isActive.toggle()
            cvHiddenConstraint.isActive = !cvVisibleConstraint.isActive
            UIView.animate(withDuration: 0.5, animations: {
                self.view.layoutIfNeeded()
            })
        }
    }
    
    extension ShowHideVC: UICollectionViewDataSource, UICollectionViewDelegate {
        func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
            return 10
        }
        func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
            let c = collectionView.dequeueReusableCell(withReuseIdentifier: "c", for: indexPath)
            c.contentView.backgroundColor = .green
            c.contentView.layer.cornerRadius = 8
            return c
        }
    }
    

    It will toggle between showing and hidden on any tap on the screen (animated) and look like this:

    enter image description here enter image description here

    enter image description here enter image description here

    Note the last line in viewDidLoad():

    // comment / un-comment the next line to see what's really going on
    containerView.clipsToBounds = true