UICollectionView/UIScrollView ignores fractional content offsets in some cases

Originator:smiller
Number:rdar://32547794 Date Originated:June 2 2017, 4:25 PM
Status:DUPLICATE OF 23255528 | OPEN Resolved:
Product:iOS + SDK Product Version:iOS 10
Classification:UIKit Reproducible:Always
 
Area:
UIKit

Summary:
We are using UICollectionView to focus on certain transformed cells. To do this, we sometimes need to scroll to an offset with a fractional value (to have pixel precise layout). Sometimes, the UICollectionView does not respect the given fraction offset and rounds to the nearest integer.

This problem seems to occur before the UICollectionView has been added to a UIWindow. However, after it has been added to any UIWindow, it will accept fractional offsets without issue, even after being removed from the window.

Due to this issue we cannot rely upon the offset we give to the UICollectionView even when setting without animation. So in addition to the potential for UI bugs due to offset being off by a pixel, there are also bugs when components rely upon the UICollectionView being at a certain offset for state management.

One attempted workaround for this bug was correcting the offset after `viewDidAppear` for the UICollectionView's UIViewController or `didMoveToWindow()` for the UICollectionView. However, neither of these fix the issue, the fractional offset is still not respected at those points. The only thing that seemed to actually work was to do:
```
override func didMoveToWindow() {
    super.didMoveToWindow()
    DispatchQueue.main.async {
        self.setContentOffset(<correct offset>, animated: false)
    }
}
```
However this workaround does not seem reliable and results in a very brief flash of the offset being incorrect. This is not an acceptable solution for production.

Steps to Reproduce:
Open ScrollingBugExample (the included demo project) and run it on any simulator or device (I can confirm iOS 10.3 on iPhone 7). These steps then illustrate the issue:

1. Run the app
2. Touch button "A"
    - It should add the UICollectionView's UIViewController to the screen
3. Check the logs
    - You should see an attempt to `setContentOffset` to a fractional value before the "CollectionView.willMove(toWindow: )" log.
    - It should look like: 

        before setContentOffset((0.0, -200.5), animated: false) offset is: (0.0, 0.0)
        contentOffset.willSet: (0.0, -200.5)
        contentOffset.didSet: (0.0, -201.0)
        after setContentOffset((0.0, -200.5), animated: false) offset is: (0.0, -201.0)

    - Notice that it internally drops the fractional value before setting the `contentOffset`.
4. Touch button "C", it should properly scroll to a fractional `contentOffset`.
5. Touch button "B", it will remove the UICollectionView from the window. You can see that it maintains the fractional `contentOffset` now even though it doesn't have a window.
6. Touch button "A" again, you will see that this time before the UICollectionView is added to the window, it correctly scrolls to a fractional offset.

Expected Results:
1. Scrolling to a fractional offset works as expected at all points OR there is an override to allow fractional offsets before adding to a window.
2. There is consistency for how fractional offsets are handled between when a UICollectionView has a window and does not have a window.
3. If a window is necessary to accept fractional offsets, setting a fractional offset in `didMoveToWindow()` is successful.
4. Documentation about which values are accepted for a contentOffset at which periods in the lifecycle of UICollectionView.

Observed Results:
1. Scrolling to a fractional offset does not work at some periods of the UICollectionView lifecycle, the value is rounded.
2. Scrolling to a fractional offset works sometimes when the UICollectionView is in a certain state, but other times does not.
3. Setting a fractional offset in `didMoveToWindow()` does not work, the requested value is rounded.
4. There is no documentation about what `contentOffset` values are acceptable.

Version:
iOS 10.3, iOS 10.2, iOS 10.2.1, iOS 10.3.2, iPhone 7 Simulator, iPhone 7 Plus,  Xcode 8.3.2, Xcode 8.2.1

Notes:


Configuration:
Occurs in a variety of hardware and software configurations.

======================================================================================
Example project included with bug (all code in single ViewController):

//
//  TestFractionalScrollingViewController.swift
//
//
//  A demo root view controller for testing fractional contentOffsets.
//  This is for testing a bug where fractional contentOffset is ignored at certain
//  times. It seems that it happens before the layoutSubviews after the UICollectionView is
//  added to a window.
//
//  To use this ViewController
//  - Set it as the rootViewController in a new project
//  - Run the app
//  - Click button "A"
//  - It should add the UICollectionView ViewController to the screen
//  - Check the logs, you should see an attempt to setContentOffset to a fractional value before
//  the ColletionView.willMove(toWindow:). You should notice that internally it drops the fraction.
//  - Then press button "C", it should properly scroll to a fractional contentOffset
//  - Then press button "B", it will remove the CollectionView. You can see that it maintains the
//  fractional contentOffset after its removed from the window.
//  - Press button "A" again, you will see that this time it is added to the window, it correctly
//  scrolls to the fractional contentOffset. ????

import UIKit

class CollectionView: UICollectionView {

    override var contentOffset: CGPoint {
        willSet {
            NSLog("contentOffset.willSet: \(newValue)")
        }
        didSet {
            NSLog("contentOffset.didSet: \(contentOffset)")
        }
    }

    override func layoutSubviews() {
        NSLog("CollectionView.layoutSubviews contentOffset: \(contentOffset)")
        super.layoutSubviews()
    }

    override func performBatchUpdates(_ updates: (() -> Void)?, completion: ((Bool) -> Void)? = nil) {
        NSLog("CollectionView.performBatchUpdates")
        super.performBatchUpdates(updates, completion: completion)
    }

    override func reloadData() {
        NSLog("CollectionView.reloadData")
        super.reloadData()
    }

    override func reloadSections(_ sections: IndexSet) {
        NSLog("CollectionView.reloadSections")
        super.reloadSections(sections)
    }

    override func didMoveToWindow() {
        NSLog("CollectionView didMoveToWindow")
        super.didMoveToWindow()
    }

    override func willMove(toWindow newWindow: UIWindow?) {
        NSLog("CollectionView willMoveToWindow: \(String(describing: newWindow))")
        super.willMove(toWindow: window)
    }

    override func willMove(toSuperview newSuperview: UIView?) {
        NSLog("CollectionView willMoveToSuperview: \(String(describing: newSuperview))")
        super.willMove(toSuperview: newSuperview)
    }

    override func didMoveToSuperview() {
        NSLog("CollectionView didMoveToSuperview")
        super.didMoveToSuperview()
    }

    override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) {
        NSLog("before setContentOffset(\(contentOffset), animated: \(animated)) offset is: \(self.contentOffset)")
        super.setContentOffset(contentOffset, animated: animated)
        NSLog("after setContentOffset(\(contentOffset), animated: \(animated)) offset is: \(self.contentOffset)")
    }
}

class TestFractionalScrollingViewController: UIViewController {
    var collectionViewController = CollectionViewController()
    let header = UIView()
    let button1 = UIButton(type: UIButtonType.custom)
    let button2 = UIButton(type: UIButtonType.custom)
    let button3 = UIButton(type: UIButtonType.custom)

    override func viewDidLoad() {
        super.viewDidLoad()

        setupHeader()
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        collectionViewController.view.frame = CGRect(x: 0,
                                                     y: 0,
                                                     width: view.bounds.width,
                                                     height: view.bounds.height - 50)
        layoutHeader()
    }

    // MARK: Actions

    func addCollectionView() {

        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1) {
            NSLog("starting off screen scroll")
            self.collectionViewController.collectionView.setContentOffset(CGPoint(x: 0, y: -200.5), animated: false)
            DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.001) {
                NSLog("starting present")
                self.presentCV()
            }
        }
    }

    func presentCV() {
        addChildViewController(collectionViewController)
        view.addSubview(collectionViewController.view)
        collectionViewController.didMove(toParentViewController: self)
    }

    func removeCollectionView() {
        NSLog("collectionView contentOffset before remove: \(collectionViewController.collectionView.contentOffset)")
        collectionViewController.willMove(toParentViewController: nil)
        collectionViewController.view.removeFromSuperview()
        collectionViewController.removeFromParentViewController()
        NSLog("collectionView contentOffset after remove: \(collectionViewController.collectionView.contentOffset)")
    }

    // MARK: Buttons

    func handleButton1() {
        addCollectionView()
    }

    func handleButton2() {
        removeCollectionView()
    }

    func handleButton3() {
        collectionViewController.collectionView.setContentOffset(CGPoint(x: 0, y: 99.5), animated: true)
    }

    // MARK: Buttons Header

    func setupHeader() {
        header.backgroundColor = .lightGray
        view.addSubview(header)

        header.addSubview(button1)
        button1.setTitle("A", for: .normal)
        button1.addTarget(self, action: #selector(handleButton1), for: .touchUpInside)
        button1.backgroundColor = .darkGray

        header.addSubview(button2)
        button2.setTitle("B", for: .normal)
        button2.addTarget(self, action: #selector(handleButton2), for: .touchUpInside)
        button2.backgroundColor = .darkGray

        header.addSubview(button3)
        button3.setTitle("C", for: .normal)
        button3.addTarget(self, action: #selector(handleButton3), for: .touchUpInside)
        button3.backgroundColor = .darkGray
    }

    func layoutHeader() {
        header.frame = view.bounds
        header.frame.origin.y = view.bounds.height - 50

        header.frame.size.height = 50

        let buttonWidth = (header.bounds.width - 5) / 3 - 5
        button1.frame = CGRect(x: (5 + buttonWidth) * 0 + 5, y: 5, width: buttonWidth, height: 40)
        button2.frame = CGRect(x: (5 + buttonWidth) * 1 + 5, y: 5, width: buttonWidth, height: 40)
        button3.frame = CGRect(x: (5 + buttonWidth) * 2 + 5, y: 5, width: buttonWidth, height: 40)
    }
}

class CollectionViewController: UIViewController, UICollectionViewDelegateFlowLayout, UICollectionViewDataSource {
    let collectionViewLayout = UICollectionViewFlowLayout()
    let collectionView: UICollectionView

    init() {
        collectionView = CollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        NSLog("ViewController viewDidLoad")
        super.viewDidLoad()
        view.backgroundColor = .white
        collectionView.backgroundColor = .white
        view.addSubview(collectionView)

        collectionView.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "cell")
        collectionView.dataSource = self
        collectionView.delegate = self
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        collectionView.frame = view.bounds
    }

    // MARK: CollectionView data

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 6
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let c = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)

        let color: UIColor
        switch indexPath.item {
        case 0: color = .red
        case 1: color = .black
        case 2: color = .orange
        case 3: color = .blue
        case 4: color = .green
        case 5: color = .magenta
        default: color = .gray
        }

        c.backgroundColor = color

        return c
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: collectionView.bounds.width, height: 200)
    }
    
    func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
        NSLog("didEndScrollingAnimation at \(scrollView.contentOffset)")
    }
}

Comments


Please note: Reports posted here will not necessarily be seen by Apple. All problems should be submitted at bugreport.apple.com before they are posted here. Please only post information for Radars that you have filed yourself, and please do not include Apple confidential information in your posts. Thank you!