Search code examples
swiftuitableviewuiscrollview

UIScrollView contentOffset corrupted after orientation change


I have a UITableView with paging turned on so I can flip through each cell (which is the height of the entire tableView) like pages. This works amazing

Until I force an orientation change and back.

Now it pages between cells. So parts of both cells are visible. It continues to page like that until I scroll the tableView all the way up to the top. Then it resets, and you can keep scrolling down correctly.

With this information in mind, I realized I just needed to simply set the contentOffset to be the value it was before.

If i manually set the content offset to be like 3000 (lets pretend the scroll view is 1000 points high)

That should scroll to index 3

But after the rotate if I do that, it scrolls me to like index 16. But it still says the offset is 3000 when i print from scrollViewDidScroll

As I scroll up, the content offset goes nearly to zero before the scrollView recalculates and decides that it can’t be negative offset yet and adds another 1000 points to the offset. Each time i scroll up it goes to zero, and then re-adds 1000 to the offset. Eventually it repeats itself until you are actually at offset 0 at the top, and then you can scroll and everything works fine.

Here is an example of what's happening:

enter image description here

I have created a sample project to get this to work:

class TestViewController: UIViewController {
        
    var isLandscape = false
    
    var tableView: UITableView!
    var buttonRotate: UIButton!
    var labelOffset: UILabel!
    let numberOfItems = 30

    override func viewDidLoad() {
        super.viewDidLoad()
        
        tableView = UITableView()
        tableView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(tableView)
        tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
        tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
        tableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
        tableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true

        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        tableView.dataSource = self
        tableView.delegate = self
        tableView.isPagingEnabled = true
        
        
        buttonRotate = UIButton()
        buttonRotate.backgroundColor = .lightGray
        buttonRotate.addTarget(self, action: #selector(clickedRotate(_:)), for: .touchUpInside)
        buttonRotate.translatesAutoresizingMaskIntoConstraints = false
        buttonRotate.setTitle("Rotate", for: .normal)
        view.addSubview(buttonRotate)
        buttonRotate.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
        buttonRotate.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor).isActive = true

        labelOffset = UILabel()
        labelOffset.translatesAutoresizingMaskIntoConstraints = false
        labelOffset.text = "Offset: \(tableView.contentOffset.y)"
        view.addSubview(labelOffset)
        labelOffset.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
        labelOffset.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor).isActive = true

    }

    @IBAction func clickedRotate(_ sender: Any) {
        
        self.isLandscape = !self.isLandscape

        if self.isLandscape {
            let value = UIInterfaceOrientation.landscapeRight.rawValue
            UIDevice.current.setValue(value, forKey: "orientation")
        } else {
            let value = UIInterfaceOrientation.portrait.rawValue
            UIDevice.current.setValue(value, forKey: "orientation")
        }
    }
    
    //This fixes it by temporarily bringing the offset to zero, which is the same as scrolling to the top. After this it scrolls back to the correct place. It needs to be separated by 0.1 seconds to work
//    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
//        coordinator.animate(alongsideTransition: { context in
//        }) { (context) in
//            self.printScrollValues()
//            self.tableView.contentOffset.y = 0
//            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
//                self.resetContentOffset()
//            }
//        }
//
//    }
    
    
    func resetContentOffset() {
        let size = tableView.frame.size
        let index = 3
        let offset = size.height * CGFloat(index)
        print("\n\noffset: \(offset)")
        tableView.contentOffset.y = offset
    }
}

extension TestViewController: UITableViewDelegate, UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return tableView.frame.size.height
    }
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return numberOfItems
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell")!
        cell.textLabel?.text = indexPath.row.description
        cell.textLabel?.font = .systemFont(ofSize: 40)
        let index = indexPath.row
        if index % 2 == 0 {
            cell.backgroundColor = .yellow
        } else {
            cell.backgroundColor = .blue
        }
        return cell
    }
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        print(scrollView.contentOffset.y)
        labelOffset.text = "Offset: \(Int(scrollView.contentOffset.y))"
    }

}

Solution

  • You needed a couple things...

    First, we need to give the tableView an estimated row height. We can do this here:

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        // only need to call this if tableView size has changed
        if tableView.estimatedRowHeight != tableView.frame.size.height {
            tableView.estimatedRowHeight = tableView.frame.size.height
        }
    }
    

    Next, after the rotation, we need to tell the tableView to recalculate its layout. The proper place to handle "device rotation" actions is here, as the tableView can change size due to other factors (not in this test controller, but in general):

    override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        super.viewWillTransition(to: size, with: coordinator)
        coordinator.animate(alongsideTransition: nil, completion: {
            _ in
            // tell the tableView to recalculate its layout
            self.tableView.performBatchUpdates(nil, completion: nil)
            self.labelOffset.text = "Offset: \(Int(self.tableView.contentOffset.y)) ContentSize: \(Int(self.tableView.contentSize.height))"
        })
    }
    

    Here is your complete example, with those two funcs and minor edits (removed unused code, changed the labelOffset.text, etc... see the comments in the code):

    class TestRotateViewController: UIViewController {
        
        var isLandscape = false
        
        var tableView: UITableView!
        var buttonRotate: UIButton!
        var labelOffset: UILabel!
        let numberOfItems = 30
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            tableView = UITableView()
            tableView.translatesAutoresizingMaskIntoConstraints = false
            view.addSubview(tableView)
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
            tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
            tableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor).isActive = true
            tableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor).isActive = true
            
            tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
            tableView.dataSource = self
            tableView.delegate = self
            tableView.isPagingEnabled = true
            
            
            buttonRotate = UIButton()
            buttonRotate.backgroundColor = .lightGray
            buttonRotate.addTarget(self, action: #selector(clickedRotate(_:)), for: .touchUpInside)
            buttonRotate.translatesAutoresizingMaskIntoConstraints = false
            buttonRotate.setTitle("Rotate", for: .normal)
            view.addSubview(buttonRotate)
            buttonRotate.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor).isActive = true
            buttonRotate.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor).isActive = true
            
            labelOffset = UILabel()
            labelOffset.translatesAutoresizingMaskIntoConstraints = false
            labelOffset.text = "Offset: \(tableView.contentOffset.y)"
            view.addSubview(labelOffset)
            labelOffset.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true
            labelOffset.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor).isActive = true
            
        }
        
        override func viewDidLayoutSubviews() {
            super.viewDidLayoutSubviews()
            // only need to call this if tableView size has changed
            if tableView.estimatedRowHeight != tableView.frame.size.height {
                tableView.estimatedRowHeight = tableView.frame.size.height
            }
        }
        
        // viewDidAppear implemented ONLY to update the labelOffset text
        //  this is NOT needed for functionality
        override func viewDidAppear(_ animated: Bool) {
            super.viewDidAppear(animated)
            labelOffset.text = "Offset: \(Int(tableView.contentOffset.y)) ContentSize: \(Int(tableView.contentSize.height))"
        }
        
        override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
            super.viewWillTransition(to: size, with: coordinator)
            coordinator.animate(alongsideTransition: nil, completion: {
                _ in
                // tell the tableView to recalculate its layout
                self.tableView.performBatchUpdates(nil, completion: nil)
                self.labelOffset.text = "Offset: \(Int(self.tableView.contentOffset.y)) ContentSize: \(Int(self.tableView.contentSize.height))"
            })
        }
        
        @IBAction func clickedRotate(_ sender: Any) {
            
            self.isLandscape = !self.isLandscape
            
            if self.isLandscape {
                let value = UIInterfaceOrientation.landscapeRight.rawValue
                UIDevice.current.setValue(value, forKey: "orientation")
            } else {
                let value = UIInterfaceOrientation.portrait.rawValue
                UIDevice.current.setValue(value, forKey: "orientation")
            }
        }
        
    }
    
    extension TestRotateViewController: UITableViewDelegate, UITableViewDataSource {
        
        func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
            return tableView.frame.size.height
        }
        
        func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
            return numberOfItems
        }
        
        func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
            let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
            cell.textLabel?.text = indexPath.row.description
            cell.textLabel?.font = .systemFont(ofSize: 40)
            let index = indexPath.row
            if index % 2 == 0 {
                cell.backgroundColor = .yellow
            } else {
                // light-blue to make it easier to read the black label text
                cell.backgroundColor = UIColor(red: 0.0, green: 0.75, blue: 1.0, alpha: 1.0)
            }
            return cell
        }
        
        func scrollViewDidScroll(_ scrollView: UIScrollView) {
            labelOffset.text = "Offset: \(Int(scrollView.contentOffset.y)) ContentSize: \(Int(scrollView.contentSize.height))"
        }
        
    }