swiftuicollectionviewuikituicollectionviewlayout

Full width UICollectionViewCells overlap during interface orientation rotations causing ugly animation


Preface

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

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
    }

}

Notes

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.

EDIT:

Updated dead video exhibit of the issue.


Solution

  • 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):

    enter image description here

    When we rotate the device and change the cell width, UIKit is "holding onto" the .contentOffset as it's re-lays-out the views:

    enter image description here

    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.