iosswiftanimationuikitzposition

Changing zPosition does not change the view hierarchy


I am building a card view - the selected card is on the top, the rest are on the bottom, stacked on top of each other. They all have the same superview.

The selected card has zPosition = 0, cards in the stack have increasing zPositions: 1,2,3 etc. Pre-Swap CardStack

When I pick a card from the stack, I animate its swap with the selected one (along with their zPositions) - something like Apple Wallet. Post-Swap CardStack - correct zPositions

After an animation, zPositions are set to the correct values, but the view hierarchy is invalid. View Hierarchy - Xcode visual debugger

Is it possible to achieve such animation using zPosition?

Swap animation code:

func didSelect(cardToBeSelected: CardView) {
    guard alreadySelectedCard !== cardToBeSelected else {
        return
    }
    
    guard let alreadySelectedCard = alreadySelectedCard else { return }
    
    let destinationOriginY = alreadySelectedCard.frame.origin.y
    let destinationZPosition = alreadySelectedCard.layer.zPosition

    alreadySelectedCard.layer.zPosition = cardToBeSelected.layer.zPosition
    
    let animator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) {
        self.alreadySelectedCard.frame.origin.y = cardToBeSelected.frame.origin.y
        cardToBeSelected.frame.origin.y = destinationOriginY
        
        self.view.layoutSubviews()
    }
    
    animator.addCompletion { (position) in
        switch position {
        case .end:
            cardToBeSelected.layer.zPosition = destinationZPosition
        default:
            break
        }
    }
    
    animator.startAnimation()
    
    self.alreadySelectedCard = cardToBeSelected
}

Solution

  • I think you're going to run into a couple problems...

    1. you're setting constraints and explicitly setting frames -- pretty much always asking for trouble

    2. changing layer.zPosition does not change the object's order in the collection of subviews

    3. using vertical constraints relative to the bottom of the "top card" can get complicated when trying to change position / order of the cards

    What I think would be a better approach:

    I see you're using SnapKit (personally, I don't like it, but anyway...)

    From my quick searching, it seems really difficult to get a reference to a SnapKit constraint "on-the-fly" to get its .constant value. To get around that, you can add a property to your CardView class to keep a reference to its "snap top constraint."

    Here's your code from your pastebin link, modified as I described above. Please consider it example code -- but it may get your on your way. Much of it is the same - I added comments that will hopefully clarify the code I added / changed:

    class ViewController: UIViewController {
        private let contentInset: CGFloat = 20.0
        private var scrollView: UIScrollView!
        private var contentContainerView: UIView!
        private var mainCardView: CardView!
        
        private var alreadySelectedCard: CardView!
        private let colors: [UIColor] = [.black, .green, .blue, .red, .yellow, .orange, .brown, .cyan, .magenta, .purple]
        
        override func viewDidLoad() {
            super.viewDidLoad()
            initializeScrollView()
            initializeContentContainerView()
    
            generateCards(count: colors.count)
            
            alreadySelectedCard = cards[0]
        }
    
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
    
            // first card is at the top of the view, so we'll set its offset
            //  inside the forEach loop to contentInset
            
            // start top of 2nd card at bottom of first card + cardOffset
            //  since first card is not "at the top" yet, calculate it
            var topOffset = contentInset + alreadySelectedCard.frame.height + cardOffset
            
            // update the top offset for the rest of the cards
            cards.forEach { card in
                guard let thisTopConstraint = card.topConstraint else {
                    fatalError("Cards were not initialized correctly!!!")
                }
                if card == alreadySelectedCard {
                    thisTopConstraint.update(offset: contentInset)
                } else {
                    thisTopConstraint.update(offset: topOffset)
                    topOffset += cardOffset
                }
            }
            // animate them into view
            let animator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) {
                self.contentContainerView.layoutSubviews()
            }
            animator.startAnimation()
    
        }
        
        private let cardOffset: CGFloat = 100.0
        private var cards = [CardView]()
        
        private func add(_ card: CardView) {
            cards.append(card)
            contentContainerView.addSubview(card)
            
            // position all cards below the bottom of the screen
            //  animate them into view in viewDidAppear
            
            let topOffset = UIScreen.main.bounds.height + 10
            
            card.snp.makeConstraints { (make) in
                let t = make.top.equalToSuperview().offset(topOffset).constraint
                card.topConstraint = t
                make.left.equalToSuperview().offset(contentInset)
                make.right.equalToSuperview().offset(-contentInset)
                make.height.equalTo(card.snp.width).multipliedBy(0.5)
                make.bottom.lessThanOrEqualToSuperview()
            }
            
        }
        
        private func generateCards(count: Int) {
            for index in 0..<count {
                let card = CardView(delegate: self)
                card.backgroundColor = colors[index % colors.count]
                card.layer.cornerRadius = 10
                add(card)
            }
        }
    }
    
    extension ViewController: CardViewDelegate {
        func didSelect(cardToBeSelected: CardView) {
    
            guard alreadySelectedCard !== cardToBeSelected else {
                return
            }
    
            guard
                // get the top "snap constraint" from alreadySelectedCard
                let alreadySnapConstraint = alreadySelectedCard.topConstraint,
                // get its constraint reference so we can get its .constant
                let alreadyConstraint = alreadySnapConstraint.layoutConstraints.first,
                // get the top "snap constraint" from cardToBeSelected
                let toBeSnapConstraint = cardToBeSelected.topConstraint,
                // get its constraint reference so we can get its .constant
                let toBeConstraint = toBeSnapConstraint.layoutConstraints.first
                else { return }
    
            // save the constant (the Top Offset) from cardToBeSelected
            let tmpOffset = toBeConstraint.constant
    
            // update the Top Offset for cardToBeSelected with the
            //  constant from alreadySelectedCard (it will be contentInset unless something has changed)
            toBeSnapConstraint.update(offset: alreadyConstraint.constant)
            
            // update the Top Offset for alreadySelectedCard
            alreadySnapConstraint.update(offset: tmpOffset)
    
            // swap the "z-order" of the views, instead of the view layers
            contentContainerView.insertSubview(alreadySelectedCard, belowSubview: cardToBeSelected)
            
            // animate the change
            let animator = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) {
                self.contentContainerView.layoutSubviews()
            }
            animator.startAnimation()
    
            // update alreadySelectedCard
            self.alreadySelectedCard = cardToBeSelected
    
        }
    }
    
    extension ViewController {
        private func initializeScrollView() {
            scrollView = UIScrollView()
            view.addSubview(scrollView)
            scrollView.backgroundColor = .lightGray
            scrollView.contentInsetAdjustmentBehavior = .never
            
            scrollView.snp.makeConstraints { (make) in
                make.edges.equalTo(view.safeAreaLayoutGuide)
            }
        }
        
        private func initializeContentContainerView() {
            contentContainerView = UIView()
            scrollView.addSubview(contentContainerView)
            
            contentContainerView.snp.makeConstraints { (make) in
                make.edges.equalToSuperview()
                make.width.equalToSuperview()
            }
        }
    }
    
    protocol CardViewDelegate {
        func didSelect(cardToBeSelected: CardView)
    }
    
    class CardView: UIView {
        var tapGestureRecognizer: UITapGestureRecognizer!
        var delegate: CardViewDelegate?
        
        // snap constraint reference so we can modify it later
        weak var topConstraint: Constraint?
        
        convenience init(delegate: CardViewDelegate) {
            self.init(frame: .zero)
            self.delegate = delegate
        }
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapCard))
            tapGestureRecognizer.delegate = self
            addGestureRecognizer(tapGestureRecognizer)
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        @objc private func didTapCard() {
            delegate?.didSelect(cardToBeSelected: self)
        }
    }
    
    extension CardView: UIGestureRecognizerDelegate {
        func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
            return true
        }
    }