Skip to content

Instantly share code, notes, and snippets.

@OscarApeland
Created June 13, 2023 09:54
Show Gist options
  • Save OscarApeland/cd3abeb36abf9bf716a4069d39e83197 to your computer and use it in GitHub Desktop.
Save OscarApeland/cd3abeb36abf9bf716a4069d39e83197 to your computer and use it in GitHub Desktop.
An isolated reproduction of issues managing offset manually while using UIKit Dynamics
//
// ContentView.swift
// DynamicRepro
//
// Created by Oscar Apeland on 13/06/2023.
//
import SwiftUI
import UIKit
struct ContentView: View {
var body: some View {
DynamicView()
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct DynamicView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
UINavigationController(rootViewController: DynamicViewController(collectionViewLayout: DynamicLayout()))
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) { }
}
class DynamicViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout {
var items = (0...100).reversed().map { $0 }
override func viewDidLoad() {
super.viewDidLoad()
collectionView.register(Cell.self, forCellWithReuseIdentifier: "cell")
navigationItem.rightBarButtonItems = [
UIBarButtonItem(title: "Bottom", primaryAction: UIAction { [unowned self] _ in
collectionView.scrollToItem(at: IndexPath(item: items.count - 1, section: 0), at: .bottom, animated: true)
}),
UIBarButtonItem(title: "Goto", primaryAction: UIAction { [unowned self] _ in
collectionView.scrollToItem(at: IndexPath(item: items.indices.randomElement()!, section: 0), at: .bottom, animated: true)
}),
UIBarButtonItem(title: "Page", primaryAction: UIAction { [unowned self] _ in
collectionView.setContentOffset(collectionView.contentOffset, animated: false)
let beforeContentSize = collectionView.contentSize
items.insert(contentsOf: (items.count...items.count + 20).map { $0 }.reversed(), at: 0)
collectionView.reloadData()
collectionView.layoutIfNeeded()
let afterContentSize = collectionView.contentSize
let newOffset = CGPoint(
x: collectionView.contentOffset.x + (afterContentSize.width - beforeContentSize.width),
y: collectionView.contentOffset.y + (afterContentSize.height - beforeContentSize.height))
collectionView.setContentOffset(newOffset, animated: false)
}),
UIBarButtonItem(title: "Append", primaryAction: UIAction { [unowned self] _ in
items.append(items.last! - 1)
collectionView.reloadData()
collectionView.scrollToItem(at: IndexPath(item: items.count - 1, section: 0), at: .bottom, animated: false)
})
]
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
items.count
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) as! Cell
cell.label.text = String(items[indexPath.item])
return cell
}
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
CGSize(width: collectionView.frame.width, height: 50)
}
class Cell: UICollectionViewCell {
let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .systemFill
label.translatesAutoresizingMaskIntoConstraints = false
addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: centerXAnchor),
label.centerYAnchor.constraint(equalTo: centerYAnchor),
])
}
required init?(coder: NSCoder) {
nil
}
}
}
class DynamicLayout: UICollectionViewFlowLayout {
// MARK: - Debug
/// Main switch to disable all dynamics code and fall back to plain flow layout
var enableDynamics = true
/// Keep calculating dynamic attributes, but return the regular ones
var pauseDynamics = false {
didSet {
collectionView?.backgroundColor = returnDynamicAttributes ? .systemBackground : .systemOrange
clearAllBehaviors()
}
}
// MARK: - Prepare
private lazy var animator = UIDynamicAnimator(collectionViewLayout: self)
private lazy var collisions = UICollisionBehavior(items: [])
override func prepare() {
prepareDynamicBehaviors()
}
// MARK: - Scroll Handling
private var lastBoundsDelta = CGVector.zero
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
guard let collectionView else {
return true
}
lastBoundsDelta = CGVector(dx: 0, dy: newBounds.origin.y - collectionView.bounds.origin.y)
if !newBounds.size.equalTo(collectionView.bounds.size) {
return true
} else {
updateDynamicBehaviors(for: newBounds)
return false
}
}
// MARK: - Dynamic Lifecycle
private var currentTileIndexPaths = Set<IndexPath>()
private func prepareDynamicBehaviors() {
guard enableDynamics, let collectionView else {
return
}
if collisions.dynamicAnimator == nil {
animator.addBehavior(collisions)
}
let tileBounds = collectionView.bounds.insetBy(dx: 0, dy: 0)
let tileElementsAttributes = super.layoutAttributesForElements(in: tileBounds) ?? []
let tileIndexPaths = Set(tileElementsAttributes.map(\.indexPath))
var insertedIndexPaths = tileIndexPaths.subtracting(currentTileIndexPaths)
currentTileIndexPaths = tileIndexPaths
removeBehaviorsOutside(tileIndexPaths)
let indexPathsRemovedForSize = removeBehaviorsWithOutdatedSize()
insertedIndexPaths.formUnion(indexPathsRemovedForSize)
let insertedElements = tileElementsAttributes.filter { insertedIndexPaths.contains($0.indexPath) }
addBehaviors(for: insertedElements)
}
private func updateDynamicBehaviors(for newBounds: CGRect) {
animator.behaviors.forEach {
guard let behavior = $0 as? UIAttachmentBehavior, let attributes = behavior.items.first as? UICollectionViewLayoutAttributes else {
return
}
applyCurrentScroll(to: attributes)
}
}
// MARK: - Dynamic Updates
// : Create
private func addBehaviors(for attributes: [UICollectionViewLayoutAttributes]) {
for attributes in attributes {
collisions.addItem(attributes)
animator.addBehavior(attachmentBehaviour(for: attributes))
animator.addBehavior(bodyBehaviour(for: attributes))
if let collectionView, collectionView.panGestureRecognizer.location(in: collectionView).x != .zero {
applyCurrentScroll(to: attributes)
}
}
}
// : Update
private func applyCurrentScroll(to item: UICollectionViewLayoutAttributes) {
let resistance = CGVector(dx: 0, dy: abs(collectionView!.panGestureRecognizer.location(in: collectionView).y - item.center.y) / 2000)
let offset = lastBoundsDelta.dy < 0 ? max(lastBoundsDelta.dy, lastBoundsDelta.dy * resistance.dy) : min(lastBoundsDelta.dy, lastBoundsDelta.dy * resistance.dy)
item.center = CGPoint(x: item.center.x, y: item.center.y + offset)
animator.updateItem(usingCurrentState: item)
}
// : Remove
private func removeBehaviorsOutside(_ visibleIndexPaths: Set<IndexPath>) {
animator.behaviors.forEach { behavior in
switch behavior {
case let attachment as UIAttachmentBehavior:
guard let attributes = attachment.items.first as? UICollectionViewLayoutAttributes, !visibleIndexPaths.contains(attributes.indexPath) else {
return
}
animator.removeBehavior(attachment)
collisions.removeItem(attributes)
case let body as UIDynamicItemBehavior:
guard let attributes = body.items.first as? UICollectionViewLayoutAttributes, !visibleIndexPaths.contains(attributes.indexPath) else {
return
}
animator.removeBehavior(body)
default:
return
}
}
}
private func removeBehaviorsWithOutdatedSize() -> [IndexPath] {
animator.behaviors.compactMap { behavior in
switch behavior {
case let attachment as UIAttachmentBehavior:
guard
let attributes = attachment.items.first as? UICollectionViewLayoutAttributes,
let superAttributes = super.layoutAttributesForItem(at: attributes.indexPath),
superAttributes.size != attributes.size
else {
return nil
}
animator.removeBehavior(attachment)
collisions.removeItem(attributes)
return attributes.indexPath
case let body as UIDynamicItemBehavior:
guard
let attributes = body.items.first as? UICollectionViewLayoutAttributes,
let superAttributes = super.layoutAttributesForItem(at: attributes.indexPath),
superAttributes.size != attributes.size
else {
return nil
}
animator.removeBehavior(body)
return attributes.indexPath
default:
return nil
}
}
}
// : Reset
func clearAllBehaviors() {
animator.removeAllBehaviors()
collisions = UICollisionBehavior(items: [])
currentTileIndexPaths.removeAll()
}
// MARK: - Behaviors
private func bodyBehaviour(for attributes: UICollectionViewLayoutAttributes) -> UIDynamicBehavior {
let body = UIDynamicItemBehavior(items: [attributes])
body.allowsRotation = false
body.density = 100 // attributes.frame.width * attributes.frame.height
body.resistance = 1
body.elasticity = 0
return body
}
private func attachmentBehaviour(for attributes: UICollectionViewLayoutAttributes) -> UIDynamicBehavior {
let anchor = CGPoint(x: attributes.center.x, y: attributes.center.y)
let attachment = UIAttachmentBehavior(item: attributes, attachedToAnchor: anchor)
attachment.damping = 0.95
attachment.frequency = 1.6
attachment.action = {
attributes.center.x = attachment.anchorPoint.x
attributes.transform = .identity
if abs(attributes.center.y - attachment.anchorPoint.y) < 2 {
attachment.damping = 1.0
} else {
attachment.damping = 0.95
}
}
return attachment
}
// MARK: - Boilerplate
private var returnDynamicAttributes: Bool {
enableDynamics && !pauseDynamics
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
if returnDynamicAttributes, let dynamicAttributes = animator.items(in: rect) as? [UICollectionViewLayoutAttributes] {
return dynamicAttributes
} else {
return super.layoutAttributesForElements(in: rect)
}
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
if returnDynamicAttributes, let dynamicAttributes = animator.layoutAttributesForCell(at: indexPath) {
return dynamicAttributes
} else {
return super.layoutAttributesForItem(at: indexPath)
}
}
override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
if returnDynamicAttributes, let dynamicAttributes = animator.layoutAttributesForDecorationView(ofKind: elementKind, at: indexPath) {
return dynamicAttributes
} else {
return super.layoutAttributesForDecorationView(ofKind: elementKind, at: indexPath)
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment