I'll preface this illustrating the issue visually. Here's a video of the issue: upon rotating the interface, the UICollectionViewCells overlap, generating an unpleasant animation that for sure can't be used in production.
The code was executed on iPhone 6S (NN0W2TU/A A1688) with iOS 15.8.2. I could reproduce the issue on iPhone 15 Pro with iOS 17 on simulator as well.
Please note that my scene delegate's func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { }
sets window?.rootViewController = ViewController()
.
SelfConfiguringCell.swift:
import UIKit
protocol SelfConfiguringCell: UICollectionViewCell {
static var reuseIdentifier: String { get }
func configure(with image: String)
}
ISVImageScrollView.swift:
import UIKit
public class ISVImageScrollView: UIScrollView, UIGestureRecognizerDelegate {
// MARK: - Public
public var imageView: UIImageView? {
didSet {
oldValue?.removeGestureRecognizer(self.tap)
oldValue?.removeFromSuperview()
if let imageView = self.imageView {
self.initialImageFrame = .null
imageView.isUserInteractionEnabled = true
imageView.addGestureRecognizer(self.tap)
self.addSubview(imageView)
}
}
}
// MARK: - Initialization
override init(frame: CGRect) {
super.init(frame: frame)
self.configure()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
self.configure()
}
deinit {
self.stopObservingBoundsChange()
}
// MARK: - UIScrollView
public override func layoutSubviews() {
super.layoutSubviews()
self.setupInitialImageFrame()
}
public override var contentOffset: CGPoint {
didSet {
let contentSize = self.contentSize
let scrollViewSize = self.bounds.size
var newContentOffset = contentOffset
if contentSize.width < scrollViewSize.width {
newContentOffset.x = (contentSize.width - scrollViewSize.width) * 0.5
}
if contentSize.height < scrollViewSize.height {
newContentOffset.y = (contentSize.height - scrollViewSize.height) * 0.5
}
super.contentOffset = newContentOffset
}
}
// MARK: - UIGestureRecognizerDelegate
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return otherGestureRecognizer === self.panGestureRecognizer
}
// MARK: - Private: Tap to Zoom
private lazy var tap: UITapGestureRecognizer = {
let tap = UITapGestureRecognizer(target: self, action: #selector(tapToZoom(_:)))
tap.numberOfTapsRequired = 2
tap.delegate = self
return tap
}()
@IBAction private func tapToZoom(_ sender: UIGestureRecognizer) {
guard sender.state == .ended else { return }
if self.zoomScale > self.minimumZoomScale {
self.setZoomScale(self.minimumZoomScale, animated: true)
} else {
guard let imageView = self.imageView else { return }
let tapLocation = sender.location(in: imageView)
let zoomRectWidth = imageView.frame.size.width / self.maximumZoomScale;
let zoomRectHeight = imageView.frame.size.height / self.maximumZoomScale;
let zoomRectX = tapLocation.x - zoomRectWidth * 0.5;
let zoomRectY = tapLocation.y - zoomRectHeight * 0.5;
let zoomRect = CGRect(
x: zoomRectX,
y: zoomRectY,
width: zoomRectWidth,
height: zoomRectHeight)
self.zoom(to: zoomRect, animated: true)
}
}
// MARK: - Private: Geometry
private var initialImageFrame: CGRect = .null
private var imageAspectRatio: CGFloat {
guard let image = self.imageView?.image else { return 1 }
return image.size.width / image.size.height
}
private func configure() {
self.showsVerticalScrollIndicator = false
self.showsHorizontalScrollIndicator = false
self.startObservingBoundsChange()
}
private func rectSize(for aspectRatio: CGFloat, thatFits size: CGSize) -> CGSize {
let containerWidth = size.width
let containerHeight = size.height
var resultWidth: CGFloat = 0
var resultHeight: CGFloat = 0
if aspectRatio <= 0 || containerHeight <= 0 {
return size
}
if containerWidth / containerHeight >= aspectRatio {
resultHeight = containerHeight
resultWidth = containerHeight * aspectRatio
} else {
resultWidth = containerWidth
resultHeight = containerWidth / aspectRatio
}
return CGSize(width: resultWidth, height: resultHeight)
}
private func scaleImageForTransition(from oldBounds: CGRect, to newBounds: CGRect) {
guard let imageView = self.imageView else { return}
let oldContentOffset = CGPoint(x: oldBounds.origin.x, y: oldBounds.origin.y)
let oldSize = oldBounds.size
let newSize = newBounds.size
var containedImageSizeOld = self.rectSize(for: self.imageAspectRatio, thatFits: oldSize)
let containedImageSizeNew = self.rectSize(for: self.imageAspectRatio, thatFits: newSize)
if containedImageSizeOld.height <= 0 {
containedImageSizeOld = containedImageSizeNew
}
let orientationRatio = containedImageSizeNew.height / containedImageSizeOld.height
let transform = CGAffineTransform(scaleX: orientationRatio, y: orientationRatio)
self.imageView?.frame = imageView.frame.applying(transform)
self.contentSize = imageView.frame.size;
var xOffset = (oldContentOffset.x + oldSize.width * 0.5) * orientationRatio - newSize.width * 0.5
var yOffset = (oldContentOffset.y + oldSize.height * 0.5) * orientationRatio - newSize.height * 0.5
xOffset -= max(xOffset + newSize.width - self.contentSize.width, 0)
yOffset -= max(yOffset + newSize.height - self.contentSize.height, 0)
xOffset -= min(xOffset, 0)
yOffset -= min(yOffset, 0)
self.contentOffset = CGPoint(x: xOffset, y: yOffset)
}
private func setupInitialImageFrame() {
guard self.imageView != nil, self.initialImageFrame == .null else { return }
let imageViewSize = self.rectSize(for: self.imageAspectRatio, thatFits: self.bounds.size)
self.initialImageFrame = CGRect(x: 0, y: 0, width: imageViewSize.width, height: imageViewSize.height)
self.imageView?.frame = self.initialImageFrame
self.contentSize = self.initialImageFrame.size
}
// MARK: - Private: KVO
private var boundsObserver: NSKeyValueObservation?
private func startObservingBoundsChange() {
self.boundsObserver = self.observe(
\.self.bounds,
options: [.old, .new],
changeHandler: { [weak self] (object, change) in
if let oldRect = change.oldValue,
let newRect = change.newValue,
oldRect.size != newRect.size {
self?.scaleImageForTransition(from: oldRect, to: newRect)
}
})
}
private func stopObservingBoundsChange() {
self.boundsObserver?.invalidate()
self.boundsObserver = nil
}
}
CarouselCell.swift:
import UIKit
import SnapKit
class CarouselCell: UICollectionViewCell, SelfConfiguringCell, UIScrollViewDelegate {
static var reuseIdentifier: String = "carousel.cell"
internal var image: String = "placeholder" {
didSet {
self.imageView = UIImageView(image: UIImage(named: image))
self.scrollView.imageView = self.imageView
}
}
fileprivate let scrollView: ISVImageScrollView = {
let scrollView = ISVImageScrollView()
scrollView.minimumZoomScale = 1.0
scrollView.maximumZoomScale = 30.0
scrollView.zoomScale = 1.0
scrollView.contentOffset = .zero
scrollView.bouncesZoom = true
return scrollView
}()
fileprivate var imageView: UIImageView = {
let image = UIImage(named: "placeholder")!
let imageView = UIImageView(image: image)
return imageView
}()
public func setImage(_ image: String) {
self.image = image
}
func configure(with image: String) {
self.setImage(image)
self.scrollView.snp.makeConstraints { make in
make.left.top.right.bottom.equalTo(contentView)
}
}
override init(frame: CGRect) {
super.init(frame: frame)
contentView.backgroundColor = UIColor.black
scrollView.delegate = self
scrollView.imageView = self.imageView
contentView.addSubview(scrollView)
}
required init?(coder: NSCoder) {
fatalError("Cannot init from storyboard")
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return self.imageView
}
}
ViewController:
import UIKit
class ViewController: UICollectionViewController {
private var currentPage: IndexPath? = nil
private let images = ["police", "shutters", "depot", "cakes", "sign"]
init() {
let compositionalLayout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let absoluteW = environment.container.effectiveContentSize.width
let absoluteH = environment.container.effectiveContentSize.height
// Handle landscape
if absoluteW > absoluteH {
print("landscape")
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
return section
} else {
// Handle portrait
print("portrait")
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(absoluteW * 9.0/16.0)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1.0),
heightDimension: .absolute(absoluteW * 9.0/16.0)
)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
return section
}
}
let config = UICollectionViewCompositionalLayoutConfiguration()
config.interSectionSpacing = 0
config.scrollDirection = .horizontal
compositionalLayout.configuration = config
super.init(collectionViewLayout: compositionalLayout)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
collectionView.delegate = self
collectionView.dataSource = self
collectionView.isPagingEnabled = true
// Register cell for reuse
collectionView.register(CarouselCell.self, forCellWithReuseIdentifier: CarouselCell.reuseIdentifier)
}
override func numberOfSections(in collectionView: UICollectionView) -> Int {
return 1
}
override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return self.images.count
}
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let reusableCell = collectionView.dequeueReusableCell(withReuseIdentifier: CarouselCell.reuseIdentifier, for: indexPath) as? CarouselCell else {
fatalError()
}
let index : Int = (indexPath.section * self.images.count) + indexPath.row
reusableCell.configure(with: self.images[index])
return reusableCell
}
}
I found a similar unanswered question here. I'm sure something can be done about it because if I switch to SwiftUI with a TabView
, that according to this, is using UICollectionView
under the hood, I'm not getting that ugly animation anymore. Though I can't switch to SwiftUI to use TabView
because on interface rotation it loses the page index (well known bug, see here), which probably is even trickier to workaround.
Updated dead video exhibit of the issue.
It's not entirely clear what you're running into, but try adding this to your ViewController
class:
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
let theLastIndex = Int(self.collectionView.contentOffset.x / self.collectionView.bounds.width)
coordinator.animate(
alongsideTransition: { [unowned self] _ in
self.collectionView.scrollToItem(at: IndexPath(item: theLastIndex, section: 0), at: .centeredHorizontally, animated: true)
},
completion: { [unowned self] _ in
// if we want to do something after the size transition
}
)
}
See if that fixes the problem.
Edit
What appears to be happening (an inherent issue with collection view):
When we rotate the device and change the cell width, UIKit is "holding onto" the .contentOffset
as it's re-lays-out the views:
and we get some funky animations.
Since you're not committed to using a collection view, I'm going to suggest a completely different strategy...
Add a UIPageViewController
as a child VC, add its view as a subview, and update the size of that subview when needed.
Too much code to post here - but if you add me as a collaborator on your GitHub repo (my GitHub ID is the same as here - "DonMag"), I can push a branch with a working example.