Changing zPosition does not change the view hierarchy

686 Views Asked by At

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
}
1

There are 1 best solutions below

0
On BEST ANSWER

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:

  • update constraint constants instead of frames
  • swap the subview "z-order" order using insertSubview(_ view: UIView, belowSubview siblingSubview: UIView)
  • swap the top constraint constant values from the "selected" card with the "to be selected" card

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
    }
}