iosuikitswift5uistackviewuidynamicanimator

Is it possible to use UIDynamicAnimator with items in a Scroll/StackView, rather than a collection view?


Say you have a vertical collection view, you can add typical "springy" physics by incorporating UIDynamicAnimator in the UICollectionViewLayout.

Simple example below.

Now imagine just a vertical UIStackView with 20 items inside a UIScrollView

I'm afraid I have absolutely no idea how to add that dynamic behavior, to, views in a scrolling stack view.

Is it possible, or, does UIDynamicAnimator only exist for items in a UICollectionView? Can it be done?

Maybe an easy question for someone familiar with the underlying concepts of UIDynamicAnimator

// typical bouncy layout for UICollectionView

import UIKit

public class BouncyLayout: UICollectionViewFlowLayout {
    
    var damping: CGFloat = 0.75
    var frequency: CGFloat = 1.5
    
    private lazy var animator: UIDynamicAnimator = UIDynamicAnimator(collectionViewLayout: self)
    
    public override func prepare() {
        super.prepare()
        guard let view = collectionView, let attributes = super.layoutAttributesForElements(in: view.bounds)?.flatMap({ $0.copy() as? UICollectionViewLayoutAttributes }) else { return }
        
        oldBehaviors(for: attributes).forEach { animator.removeBehavior($0) }
        newBehaviors(for: attributes).forEach { animator.addBehavior($0, damping, frequency) }
    }
    
    private func oldBehaviors(for attributes: [UICollectionViewLayoutAttributes]) -> [UIAttachmentBehavior] {
        let indexPaths = attributes.map { $0.indexPath }
        return animator.behaviors.flatMap {
            guard let behavior = $0 as? UIAttachmentBehavior, let itemAttributes = behavior.items.first as? UICollectionViewLayoutAttributes else { return nil }
            return indexPaths.contains(itemAttributes.indexPath) ? nil : behavior
        }
    }
    
    private func newBehaviors(for attributes: [UICollectionViewLayoutAttributes]) -> [UIAttachmentBehavior] {
        let indexPaths = animator.behaviors.flatMap { (($0 as? UIAttachmentBehavior)?.items.first as? UICollectionViewLayoutAttributes)?.indexPath }
        return attributes.flatMap { return indexPaths.contains($0.indexPath) ? nil : UIAttachmentBehavior(item: $0, attachedToAnchor: $0.center) }
    }
    
    public override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        return animator.items(in: rect) as? [UICollectionViewLayoutAttributes]
    }
    
    public override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        return animator.layoutAttributesForCell(at: indexPath) ?? super.layoutAttributesForItem(at: indexPath)
    }
    
    public override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        guard let view = collectionView else { return false }
        
        animator.behaviors.forEach {
            guard let behavior = $0 as? UIAttachmentBehavior, let item = behavior.items.first else { return }
            update(behavior: behavior, and: item, in: view, for: newBounds)
            animator.updateItem(usingCurrentState: item)
        }
        return view.bounds.width != newBounds.width
    }
    
    private func update(behavior: UIAttachmentBehavior, and item: UIDynamicItem, in view: UICollectionView, for bounds: CGRect) {
        let delta = CGVector(dx: bounds.origin.x - view.bounds.origin.x, dy: bounds.origin.y - view.bounds.origin.y)
        let resistance = CGVector(dx: fabs(view.panGestureRecognizer.location(in: view).x - behavior.anchorPoint.x) / 1000, dy: fabs(view.panGestureRecognizer.location(in: view).y - behavior.anchorPoint.y) / 1000)
        item.center.y += delta.dy < 0 ? max(delta.dy, delta.dy * resistance.dy) : min(delta.dy, delta.dy * resistance.dy)
        item.center.x += delta.dx < 0 ? max(delta.dx, delta.dx * resistance.dx) : min(delta.dx, delta.dx * resistance.dx)
    }
}

extension UIDynamicAnimator {
    
    open func addBehavior(_ behavior: UIAttachmentBehavior, _ damping: CGFloat, _ frequency: CGFloat) {
        behavior.damping = damping
        behavior.frequency = frequency
        addBehavior(behavior)
    }
}

Solution

  • UIAttachmentBehavior does not work with AutoLayout, which a UIStackView would use to layout the arranged views. So no, UIStackView is out of the question. The dynamic behaviours will ignore the AutoLayout constraints and do their own thing and take over completely.

    However, if you are fine with handling the layout manually, it can certainly be done. Use the init(referenceView:) initialiser instead of the init(collectionViewLayout:) initialiser. Use the scroll view as the reference view.

    Instead of updating the centers in shouldInvalidateLayout, you can do it in scrollViewDidScroll instead. There is no newBounds parameter, so you need to calculate the delta some other way, e.g. compare the previous and current contentOffsets.

    Here is a simple example where I have put ten 100x100 black views in a scroll view, 10pt apart from each other.

    class Container: UIView, UIScrollViewDelegate {
        required init?(coder: NSCoder) {
            fatalError()
        }
        
        let scrollView = UIScrollView()
        lazy var animator = UIDynamicAnimator(referenceView: scrollView)
        
        override init(frame: CGRect) {
            super.init(frame: frame)
            
            scrollView.translatesAutoresizingMaskIntoConstraints = false
            scrollView.delegate = self
            addSubview(scrollView)
            NSLayoutConstraint.activate([
                scrollView.topAnchor.constraint(equalTo: self.topAnchor),
                scrollView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
                scrollView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
                scrollView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
            ])
            
            for i in 0..<10 {
                let blackView = UIView(frame: .init(x: 0, y: CGFloat(i) * 110, width: 100, height: 100))
                blackView.backgroundColor = .black
                blackView.layer.borderColor = UIColor.red.cgColor
                blackView.layer.borderWidth = 2
                scrollView.addSubview(blackView)
                
                let attachment = UIAttachmentBehavior(item: blackView, attachedToAnchor: blackView.center)
                attachment.damping = 0.75
                attachment.frequency = 1.5
                animator.addBehavior(attachment)
            }
            scrollView.contentSize = CGSize(width: 100, height: 110 * 10)
        }
        
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            for behaviour in animator.behaviors {
                let attachmentBehaviour = behaviour as! UIAttachmentBehavior
                let item = attachmentBehaviour.items[0]
                update(behavior: attachmentBehaviour, and: item)
                animator.updateItem(usingCurrentState: item)
            }
            previousOffset = scrollView.contentOffset
        }
        
        var previousOffset: CGPoint = .zero
        
        private func update(behavior: UIAttachmentBehavior, and item: any UIDynamicItem) {
            let delta = CGVector(dx: scrollView.contentOffset.x - previousOffset.x, dy: scrollView.contentOffset.y - previousOffset.y)
            let resistance = CGVector(dx: abs(scrollView.panGestureRecognizer.location(in: scrollView).x - behavior.anchorPoint.x) / 1000, dy: abs(scrollView.panGestureRecognizer.location(in: scrollView).y - behavior.anchorPoint.y) / 1000)
            item.center.y += delta.dy < 0 ? max(delta.dy, delta.dy * resistance.dy) : min(delta.dy, delta.dy * resistance.dy)
            item.center.x += delta.dx < 0 ? max(delta.dx, delta.dx * resistance.dx) : min(delta.dx, delta.dx * resistance.dx)
        }
    }