scrollViewDidScroll called with strange contentOffsetY - swift

I have a UIViewController that holds a NSFetchedResultsController. After the insertion of rows to the top, I want to keep the rows visible as they where before the insertions. This means I need to make some calculations to keep the contentOffSetY right after the update. The calculation is correct, but I noticed that scrollViewDidScroll gets called after it scrolled to my specified contentOffsetY, this results in a corrupted state. This is the logging:
Will apply an corrected Y value of: 207.27359771728516
Scrolled to: 207.5
Corrected to: 207.27359771728516
Scrolled to: 79.5 <-- Why is this logline here?
You can directly clone an example project: https://github.com/Jasperav/FetchResultControllerGlitch (commit https://github.com/Jasperav/FetchResultControllerGlitch/commit/d46054040139afeeb648e1e0b5b113bd98685b4a, the newest version of the project only glitches, the weird call to the scrollViewDidScroll method is now gone. If you fix the glitch I award the bounty. Just clone the newest version, run it and scroll a little bit. You will see strange content offset's (glitches)). Run the project and you will see the strange output. This is the controllerDidChangeContent method:
public func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
let currentSize = tableView.contentSize.height
UIView.performWithoutAnimation {
tableView.endUpdates()
let newSize = tableView.contentSize.height
let correctedY = tableView.contentOffset.y + newSize - currentSize
print("Will apply an corrected Y value of: \(correctedY)")
tableView.setContentOffset(CGPoint(x: 0,
y: correctedY),
animated: false)
print("Corrected to: \(correctedY)")
}
}
If I call tableView.layoutIfNeeded right after the tableView.endUpdates(), the delegate is already called. What does it cause to call the delegate method? Is there any way it does not scroll?

I downloaded your code and made some tweaks to fix the glitching issue. Here is what I have done.
set estimatedRowHeight property of table view to some number
init() {
super.init(frame: .zero, style: .plain)
estimatedRowHeight = 50.0
register(MyTableViewCell.self, forCellReuseIdentifier: "cell")
}
created a function to handle the UITableView reload action without modifying the current contentOffset
func reloadDataWithoutScroll() {
let lastScrollOffset = contentOffset
beginUpdates()
reloadData()
layoutIfNeeded()
endUpdates()
layer.removeAllAnimations()
setContentOffset(lastScrollOffset, animated: false)
}
Updated controllerDidChangeContent function to make use of the reloadDataWithoutScroll function
UIView.performWithoutAnimation {
tableView.performBatchUpdates({
tableView.insertRows(at: inserts, with: .automatic)
})
inserts.removeAll()
tableView.reloadDataWithoutScroll()
}
When I execute with these changes, it doesn't scroll when a new row is added. However, it does scroll to show the new added row when the current contentOffset is 0. And I don't think that would be a problem, logically speaking.

Related

customizing UICollectionView.contentOffset prior to appearance

I have a UICollectionView where I want to set the collectionView.contentOffset.x value to the screen's midpoint. I want the collectionView's content to start at the screen's midpoint but to be scrollable to the left of that. Using collectionView.setContentOffset, I can do it after the view appears. However, I want it to appear on the screen when the view loads, already at that position.
I've tried using
func collectionView(_ collectionView: UICollectionView, targetContentOffsetForProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
return CGPoint(x: collectionView.frame.width/2, y: 0)
}
but it has no effect. My delegate and dataSource are set.
I've tried using collectionView.setContentOffset in viewDidLoad, then in viewDidLayoutSubviews, animated set to false. This didn't work.
Do I need a custom layout, or can I do without?
Alright, so found the answer on my own. In short, updating the UI on the main thread was the answer. Calling
DispatchQueue.main.async { [weak self] in
self?.scrubberCollectionView.setContentOffset(CGPoint(x: (self?.collectionViewStartingPoint)!, y: 0), animated: false)
}
from ViewDidLoad() worked like a charm. I saw it right after I started this morning. I really didn't think this question required me to post my code, but someone else might have seen it right away.
So, three takeaways: post code on StackOverflow, update the UI on the main thread, and take a break occasionally.

UIScrollView ContentOffset.y reset to 0 after performBatch

I'm using https://github.com/OfTheWolf/TwitterProfile <- this library on my project.
It is the little bit application of XLStripPager.
Because I have to set the header View and pager.
The layout architecture is
My Problem is this. When I set the expand the table cell label using UITableView.automaticDimension
expanding animation(?) is well worked. but after I scroll the table, the content offset.y is reset to zero. so even I tapped the bottom cell to expand, after that cell expanded when I move the scroll, the scrollView is set to the top(awkard...)
extension BottomViewController: LabelCellDelegate {
func expand(cell: LabelCell) {
let indexPath = cell.indexPath
if !expandCheck[indexPath!.row] {
expandCheck[indexPath!.row] = true
}
//offset = tableView.contentOffset.x
tableView.performBatchUpdates({ () -> Void in
cell.label.numberOfLines = 0
}, completion: nil)
}
}
I tried to print the scrollView Offset.
After click(expand) the cell the offset.y changed to 0...
I don't know how to solve this problem.
I try to assign the right before the expand contentOffset.y at the scrollViewDidScroll but breaking error.
How to prevent the reset to zero offset ? Any other method ?

how to show the middle cell of a collection view when my app starts

I have a collectionView embedded in a subview of the ViewController. The collectionView has 12 cells. Each cell takes up the whole width and height of the collection view, so that I can achieve the pagination affect. However, when the app starts, I want to show the middle cell like the 6th or 7th one of my collectionView.
P.S. I have the collectionView in a wrapper view, not in my viewController.
In my WrapperView, I added the following method but as this method is called after the collectionView is added, it shows a sudden jump.
override func didAddSubview(_ subview: UIView) {
super.didAddSubview(subview)
let indexPath = IndexPath(row: 6, section: 0)
DispatchQueue.main.async {
self.calendarCollectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
}
}
If I could do it before the collection view appear on the screen, I may be able to fix that problem, but I can't find which method is called before the didAddSubview(_:) method in UIView Life Cycle.
Can anyone give me hint on how to solve this.
After your data source has loaded use:
func selectItem(at indexPath: IndexPath?,
animated: Bool,
scrollPosition: UICollectionView.ScrollPosition)
https://developer.apple.com/documentation/uikit/uicollectionview/1618057-selectitem
Probably best used before the view appears so trigger in viewWillAppear or viewDidLoad

UITableViewController: Scrolling to bottom with dynamic row height starts animation at wrong position

I have a table view properly configured to have dynamic row heights based on Ray Wenderlich's guide found here:
I set up the constraints to have a clear line of constraints from the top to the bottom of the cell. I also set up content hugging and content compression resistance priorities and estimated row height.
This is the code I use to setup the table view:
func configureTableView() {
// its called on viewDidLoad()
tableView.rowHeight = UITableViewAutomaticDimension
tableView.estimatedRowHeight = 100.0
}
override func viewDidLoad() {
super.viewDidLoad()
configureTableView()
for i in 1...20 {
messages.append([
"title": "foo \(i)",
"message": "bla \(i)\nbla\nbla"
])
}
// this is because the actual row heights are not available until the next layout cycle or something like that
dispatch_async(dispatch_get_main_queue(), {self.scrollToBottom(false)})
}
func scrollToBottom(animated:Bool) {
let indexPath = NSIndexPath(forRow: self.messages.count-1, inSection: 0)
self.tableView.scrollToRowAtIndexPath(indexPath, atScrollPosition: UITableViewScrollPosition.Bottom, animated: animated)
}
And this is how I add new rows:
#IBAction func addMore(sender:UIBarButtonItem) {
let message = [
"title": "haiooo",
"message": "silver"]
messages.append(message)
let indexPath = NSIndexPath(forRow: messages.count-1, inSection: 0)
tableView.insertRowsAtIndexPaths([indexPath], withRowAnimation: UITableViewRowAnimation.Bottom)
scrollToBottom(true)
}
The setup with the default rows are fine. It add the rows and scrolls to the bottom as expected.
But when I add new rows after that, the scrolling seems to start above the last cell. As I add more cells, the offset seems to increase.
Here is a gif showing it happening: Imgur
It's certainly related to the scroll animation (not the insertRow animation) because it scrolls properly when the animation is turned off.
Changing the estimatedRowHeight makes difference on the scrolling offset, but I couldn't find a value that fixed it.
I also tried delaying up the scroll using dispatch_async but it didn't change anything.
Do you guys have any ideas?
Wow, that was a fun challenge. Thanks for posting the test project.
So it seems that after adding the new row there's something off with where the table view thinks it's scrolled to. Seems to me to be a bug in UIKit. So to work around that, I added some code to 'reset' the table view before applying the animation.
Here's what I ended up with:
#IBAction func addMore(sender:UIBarButtonItem) {
let message = [
"title": "haiooo",
"message": "silver"]
messages.append(message)
tableView.reloadData()
// To get the animation working as expected, we need to 'reset' the table
// view's current offset. Otherwise it gets confused when it starts the animation.
let oldLastCellIndexPath = NSIndexPath(forRow: messages.count-2, inSection: 0)
self.tableView.scrollToRowAtIndexPath(oldLastCellIndexPath, atScrollPosition: .Bottom, animated: false)
// Animate on the next pass through the runloop.
dispatch_async(dispatch_get_main_queue(), {
self.scrollToBottom(true)
})
}
I couldn't get it to work with insertRowsAtIndexPaths(_:withRowAnimation:), but reloadData() worked fine. Then you need the same delay again before animating to the new last row.

Reset scroll on UICollectionView

I have a horizontal UICollectionView which works fine and scrolls. When I tap an item I update my data and call reloadData. This works and the new data is displayed in the UICollectionView.
The problem is the scroll position doesn't change and it is still viewing the last place. I want to reset the scrolling to the top (Or left in my case). How can I do this?
You want setContentOffset:. It takes a CGPoint as and argument that you can set to what ever you want using CGPointMake, but if you wish to return to the very beginning of the collection, you can simply use CGPointZero.
[collectionView setContentOffset:CGPointZero animated:YES];
You can use this method to scroll to any item you want:
- (void)scrollToItemAtIndexPath:(NSIndexPath *)indexPath
atScrollPosition:(UICollectionViewScrollPosition)scrollPosition
animated:(BOOL)animated
I use this quite often in different parts of my app so I just extended UIScrollView so it can be used on any scroll view and scroll view subclass:
extension UIScrollView {
/// Sets content offset to the top.
func resetScrollPositionToTop() {
self.contentOffset = CGPoint(x: -contentInset.left, y: -contentInset.top)
}
}
So whenever I need to reset the position:
self.collectionView.resetScrollPositionToTop()
In Swift:
collectionView.setContentOffset(CGPointZero, animated: true)
If you leave the view that houses the collection view, make a change in the 2nd view controller, and need the collection view to update upon returning:
#IBAction func unwindTo_CollectionViewVC(segue: UIStoryboardSegue) {
//Automatic table reload upon unwind
viewDidLoad()
collectionView.reloadData()
collectionView.setContentOffset(CGPointZero, animated: true)
}
In my situation the unwind is great because the viewDidLoad call will update the CoreData context, the reloadData call will make sure the collectionView updates to reflect the new CoreData context, and then the contentOffset will make sure the table sets back to the top.
If you view controller contains safe area, code:
collectionView.setContentOffset(CGPointZero, animated: true)
doesn't work, so you can use universal extension:
extension UIScrollView {
func scrollToTop(_ animated: Bool) {
var topContentOffset: CGPoint
if #available(iOS 11.0, *) {
topContentOffset = CGPoint(x: -safeAreaInsets.left, y: -safeAreaInsets.top)
} else {
topContentOffset = CGPoint(x: -contentInset.left, y: -contentInset.top)
}
setContentOffset(topContentOffset, animated: animated)
}
}
CGPointZero in my case shifted the content of the collection view because you are not taking in count the content inset. This is what it worked for me:
CGPoint topOffest = CGPointMake(0,-self.collectionView.contentInset.top);
[self.collectionView setContentOffset:topOffest animated:YES];
Sorry I couldn't comment at the time of this writing, but I was using a UINavigationController and CGPointZero itself didn't get me to the very top so I had to use the following instead.
CGFloat compensateHeight = -(self.navigationController.navigationBar.bounds.size.height+[UIApplication sharedApplication].statusBarFrame.size.height);
[self.collectionView setContentOffset:CGPointMake(0, compensateHeight) animated:YES];
Hope this helps somebody in future. Cheers!
To deal with an UINavigationController and a transparent navigation bar, I had to calculate the extra top offset to perfectly match the position of the first element.
Swift 1.1 code:
let topOffest = CGPointMake(0, -(self.collectionView?.contentInset.top ?? O))
self.collectionView?.setContentOffset(topOffest, animated: true)
Cheers!
SWIFT 4.2 SOLUTION-
Say I have a tableView with each tableViewCell containing a HorizontalCollectionView
Due to reuse of cells, even though first tableViewCell is scrolled to say page 4, downwards, other cell is also set to page 4.
This works in such cases-
In the cellForRowAt func in tableView,
let cell = //declaring a cell using deque
cell.myCollectionView.contentOffset = CGPoint(x:0,y:0)