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
}
I think you're going to run into a couple problems...
you're setting constraints and explicitly setting frames -- pretty much always asking for trouble
changing layer.zPosition
does not change the object's order in the collection of subviews
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:
insertSubview(_ view: UIView, belowSubview siblingSubview: UIView)
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
}
}