Created
June 13, 2023 09:54
-
-
Save OscarApeland/cd3abeb36abf9bf716a4069d39e83197 to your computer and use it in GitHub Desktop.
An isolated reproduction of issues managing offset manually while using UIKit Dynamics
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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