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)
}
}
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 center
s 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 contentOffset
s.
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)
}
}