UITableView with one visible cell: determine which is most visible - ios

Given a UITableView with a single visible cell at any given time, how can I determine which cell is most in view while the table view is being scrolled?
I know I can get an array of visible cells by doing this:
NSArray *paths = [tableView indexPathsForVisibleRows];
And then get the last cell (or first, or whatever) by doing:
UITableViewCell* cell = (UITableViewCell*)[tableView cellForRowAtIndexPath:[paths lastObject]];
But how to I compare all the visible cells and determine which of them is most in view?

The following logic would get you the most visible cell at the end of the scroll:
-(void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
CGRect visibleRect = (CGRect){.origin = self.tableView.contentOffset, .size = self.tableView.bounds.size};
CGPoint visiblePoint = CGPointMake(CGRectGetMidX(visibleRect), CGRectGetMidY(visibleRect));
NSIndexPath *visibleIndexPath = [self.tableView indexPathForRowAtPoint:visiblePoint];
}

The algorithm is different depending on how many paths you get back:
If there is only one path, that's the most visible cell right there
If there are three or more paths, any of the cells in the middle (i.e. all cells except the first and the last ones) are equally visible
If there are exactly two cells, find the position of the line that separates the two in their parent view*, and compute two distances - top-to-middle and middle-to-bottom. If top-to-middle is greater, then the top cell is most visible. If middle-to-bottom is greater, then the second cell is more visible. Otherwise, the two cells are equally visible.
* Midpoint position is the bottom of the second cell. Top and bottom positions are the top and bottom of the table view.

Swift solution based on #Sebyddd's answer:
func scrollViewDidEndDecelerating(scrollView: UIScrollView) {
scrollToMostVisibleCell()
}
func scrollViewDidEndDragging(scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate{
scrollToMostVisibleCell()
}
}
func scrollToMostVisibleCell(){
let visibleRect = CGRect(origin: tableView.contentOffset, size: tableView.bounds.size)
let visiblePoint = CGPoint(x: CGRectGetMidX(visibleRect), y: CGRectGetMidY(visibleRect))
let visibleIndexPath: NSIndexPath = tableView.indexPathForRowAtPoint(visiblePoint)!
tableView.scrollToRowAtIndexPath(visibleIndexPath, atScrollPosition: .Top, animated: true)
}

You can use the table view's rectForRowAtIndexPath: to get the frame of each visible cell, then offset them (with CGRectOffset) by -contentOffset.y to account for scrolling, then intersect them with the table view's bounds to find out how much each cell is visible inside the table view.

The below logic will give you the UITableViewCell which is most visible or closet to center in UITableView every time as soon as user stops scrolling. Hope this logic would help somebody.
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate
{
if (!decelerate)
{
if (isScrollingStart)
{
isScrollingStart=NO;
isScrollingEnd=YES;
[self scrollingStopped];
}
}
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView
{
if (isScrollingStart)
{
isScrollingStart=NO;
isScrollingEnd=YES;
[self scrollingStopped];
}
}
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
isScrollingStart=YES;
}
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
{
isScrollingStart=YES;
}
-(void)scrollingStopped
{
NSMutableArray* arrVideoCells=[NSMutableArray array];
NSLog(#"Scrolling stopped");
NSArray* arrVisibleCells=[self.tableTimeline visibleCells];
for (TimeLineCell* cell in arrVisibleCells)
{
if ([cell isKindOfClass:[TimeLineCellMediaVideo class]])
{
[arrVideoCells addObject:cell];
}
}
TimeLineCellMediaVideo* videoCell=[self getCellNearCenterOfScreen:arrVideoCells];
}
-(TimeLineCellMediaVideo*)getCellNearCenterOfScreen:(NSMutableArray*)arrCells
{
TimeLineCellMediaVideo* closetCellToCenter;
CGRect filterCGRect;
for (TimeLineCellMediaVideo* videoCell in arrCells)
{
if (arrCells.count==1)
closetCellToCenter= videoCell;
NSIndexPath* cellIndexPath=[self.tableTimeline indexPathForCell:videoCell];
CGRect rect = [self.tableTimeline convertRect:[self.tableTimeline rectForRowAtIndexPath:cellIndexPath] toView:[self.tableTimeline superview]];
if (closetCellToCenter)
{
CGRect intersect = CGRectIntersection(self.tableTimeline.frame, filterCGRect);
float visibleHeightFilterCell = CGRectGetHeight(intersect);
intersect = CGRectIntersection(self.tableTimeline.frame, rect);
float visibleHeightCurrentCell = CGRectGetHeight(intersect);
if (visibleHeightCurrentCell>visibleHeightFilterCell)
{
filterCGRect=rect;
closetCellToCenter= videoCell;
}
}
else
{
closetCellToCenter=videoCell;
filterCGRect=rect;
}
}
return closetCellToCenter;
}

I did the following to find indexPath for most visible cell and it is working correctly.
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
guard let tableView = scrollView as? UITableView else {
return
}
let visibleHeights = tableView.visibleCells.compactMap { cell -> (indexPath: IndexPath, visibleHeight: CGFloat)? in
guard let indexPath = tableView.indexPath(for: cell) else {
return nil
}
let cellRect = tableView.rectForRow(at: indexPath)
let superView = tableView.superview
let convertedRect = tableView.convert(cellRect, to: superView)
let intersection = tableView.frame.intersection(convertedRect)
let visibleHeight = intersection.height
return (indexPath, visibleHeight)
}
guard let maxVisibleIndexPath = visibleHeights.max(by: { $0.visibleHeight < $1.visibleHeight })?.indexPath else {
return
}
print("maxVisibleIndexPath: \(maxVisibleIndexPath)")
}

Related

How to stop collection triggering code when scrolling back in to view swift

I'm pretty new so apologies if my title doesn't phrase things correctly. I've been hacking away and haven't been able to find an answer to this problem.
I have a horizontally scrolling collection. I'm trying to visually show poll results programatically adding CGRect to the relevant item in the collection based on voting data held in a Firestore array.
This is working, but the problem is when you scroll away (so the item in the collection is off screen) and then back, the code to draw the CGRects gets triggered again and more graphics get added to the view. Is there a way to delete these CGRects when the user scrolls an item collection off screen so when the user scrolls the item back into view, code is triggered again it doesn't create duplicates?
Here are a couple of screenshots showing first and second load
Here is my code (cell b is where the CGrect gets triggered)
//COLLECTION VIEW CODE
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
if collectionView == self.sentCollectionView {
let cellA = collectionView.dequeueReusableCell(withReuseIdentifier: "sCell", for: indexPath) as! SentCollectionViewCell
cellA.sentQuestionLabel.text = sentMessages[indexPath.row].shoutText
// Set up cell
return cellA
}
else {
let cellB = receivedCollectionView.dequeueReusableCell(withReuseIdentifier: "pCell", for: indexPath) as! ReceivedCollectionViewCell
receivedMessages[indexPath.row].pollTotal = receivedMessages[indexPath.row].pollResults.reduce(0, +)
print("Sum of Array is : ", receivedMessages[indexPath.row].pollTotal!)
cellB.receivedShoutLabel.text = receivedMessages[indexPath.row].shoutText
print(receivedMessages[indexPath.row].pollResults.count)
if receivedMessages[indexPath.row].pollResults != [] {
for i in 0...receivedMessages[indexPath.row].pollResults.count - 1 {
cellB.resultsView.addSubview(sq(pollSum: receivedMessages[indexPath.row].pollTotal!, pollResult: receivedMessages[indexPath.row].pollResults[i]))
}
}
return cellB
}
}
//THIS DRAWS THE CGRECT
func sq(pollSum: Int, pollResult: Int) -> UIView {
// divide the width by total responses
let screenDivisions = Int(view.frame.size.width) / pollSum
// the rectangle top left point x axis position.
let xPos = 0
// the rectangle width.
let rectWidth = pollResult * screenDivisions
// the rectangle height.
let rectHeight = 10
// Create a CGRect object which is used to render a rectangle.
let rectFrame: CGRect = CGRect(x:CGFloat(xPos), y:CGFloat(yPos), width:CGFloat(rectWidth), height:CGFloat(rectHeight))
// Create a UIView object which use above CGRect object.
let greenView = UIView(frame: rectFrame)
// Set UIView background color.
greenView.backgroundColor = UIColor.green
//increment y position
yPos = yPos + 25
return greenView
}
Cells of collection are dequeued dequeueReusableCell , you need to override prepareForReuse
Or set a tag
greenView.tag = 333
And inside cellForItemAt do this
cellB.resultsView.subviews.forEach {
if $0.tag == 333 {
$0.removeFromSuperview()
}
}
if receivedMessages[indexPath.row].pollResults != [] {
for i in 0...receivedMessages[indexPath.row].pollResults.count - 1 {
cellB.resultsView.addSubview(sq(pollSum: receivedMessages[indexPath.row].pollTotal!, pollResult: receivedMessages[indexPath.row].pollResults[i]))
}
}

Place UITableViewCell beneath siblings

I'm trying to build a custom UITableView that acts in a similar way to the reminders app.
Instead of the top-most visible cell scrolling off the display, I want it to be covered by the next cell so the cells appear to stack on top of one another as you scroll.
Currently I am using:
override func scrollViewDidScroll(scrollView: UIScrollView) {
let topIndexPath: NSIndexPath = tableView.indexPathsForVisibleRows()?.first as! NSIndexPath
let topCell = tableView.cellForRowAtIndexPath(topIndexPath)
let frame = topCell!.frame
topCell!.frame = CGRectMake(frame.origin.x, scrollView.contentOffset.y, frame.size.width, frame.size.height)
}
But the top cell is always above the second cell - causing the second cell to scroll under it.
Also if I scroll quickly this seems to misplace all of my cells.
EDIT: Have fixed this. Answer posted below for future reference.
For anybody searching this in the future.
Simply loop through all the visible cells and set their z position to their row number (so each cell stacks above the previous one).
The if statement tells the top cell to remain at the contentOffset of the scrollview and for all other cells to assume their expected position. This stops the other cells being offset if you scroll too quickly.
override func scrollViewDidScroll(scrollView: UIScrollView) {
// Grab visible index paths
let indexPaths: Array = tableView.indexPathsForVisibleRows()!
var i = 0
for path in indexPaths {
let cell = tableView.cellForRowAtIndexPath(path as! NSIndexPath)
// set zPosition to row value (for stacking)
cell?.layer.zPosition = CGFloat(path.row)
// Check if top cell (first in indexPaths)
if (i == 0) {
let frame = cell!.frame
// keep top cell at the contentOffset of the scrollview (top of screen)
cell!.frame = CGRectMake(frame.origin.x,
scrollView.contentOffset.y,
frame.size.width,
frame.size.height)
} else {
// set cell's frame to expected value
cell!.frame = tableView.rectForRowAtIndexPath(path as! NSIndexPath)
}
i++
}
}

UICollectionview scroll horizontal and vertical

I have to build a UICollectionView scrollable horizontal and vertical, I know that the grid layout scrolls along one axis only, either horizontally or vertically, so I have read some posts and I have tried different solutions but the most simple is to put the UICollectionview inside a UIScrollView. In this way the CollectionView scroll vertically and the UIScrollView horizontally.
The problem is that the vertical scroll is difficult, not fluid and often is stop until you tap again and drag again.
Can you suggest a solution? Thanks
UICollectionViewFlowLayout *layout = [[UICollectionViewFlowLayout alloc] init];
UIScrollView *backgroundScroll = [[UIScrollView alloc] initWithFrame:CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, [UIScreen mainScreen].bounds.size.height)];
backgroundScroll.scrollEnabled = YES;
[self.view addSubview:backgroundScroll];
_collectionView = [[UICollectionView alloc] initWithFrame:CGRectMake(10, 15, 1020, [UIScreen mainScreen].bounds.size.height - 35) collectionViewLayout:layout];
[backgroundScroll addSubview:_collectionView];
_collectionView.contentInset = UIEdgeInsetsMake(0, 0, 50, 0);
_collectionView.scrollEnabled = YES;
And I have implemented the method:
- (void)viewDidLayoutSubviews {
backgroundScroll.contentSize = self.collectionView.frame.size;
}
The way to do this is to create a custom UICollectionViewLayout subclass.
I had to do this recently.
Let me go get the files... One sec...
First of all, you can't use a subclass of UICollectionViewFlowLayout easily for this. Flow layout is designed to fit the content in one direction and scroll in the other direction. This isn't what you want.
It isn't very difficult though to create a custom layout to do this for you.
Header File
#interface GridCollectionViewLayout : UICollectionViewLayout
// properties to configure the size and spacing of the grid
#property (nonatomic) CGSize itemSize;
#property (nonatomic) CGFloat itemSpacing;
// this method was used because I was switching between layouts
- (void)configureCollectionViewForLayout:(UICollectionView *)collectionView;
#end
Implementation
#import "GridCollectionViewLayout.h"
#interface GridCollectionViewLayout ()
#property (nonatomic, strong) NSDictionary *layoutInfo;
#end
#implementation GridCollectionViewLayout
Create inits for code and interface builder...
- (id)init
{
self = [super init];
if (self) {
[self setup];
}
return self;
}
- (id)initWithCoder:(NSCoder *)aDecoder
{
self = [super init];
if (self) {
[self setup];
}
return self;
}
Setup defaults property values...
- (void)setup
{
self.itemSize = CGSizeMake(50.0, 50.0);
self.itemSpacing = 10.0;
}
This was used because I was changing between different layouts but it shows what is needed to set the layout..
- (void)configureCollectionViewForLayout:(UICollectionView *)collectionView
{
collectionView.alwaysBounceHorizontal = YES;
[collectionView setCollectionViewLayout:self animated:NO];
}
Required method. This iterates the items and creates frames CGRect for each one. Saving them into a dictionary.
- (void)prepareLayout
{
NSMutableDictionary *cellLayoutInfo = [NSMutableDictionary dictionary];
NSInteger sectionCount = [self.collectionView numberOfSections];
NSIndexPath *indexPath = [NSIndexPath indexPathForItem:0 inSection:0];
for (NSInteger section = 0; section < sectionCount; section++) {
NSInteger itemCount = [self.collectionView numberOfItemsInSection:section];
for (NSInteger item = 0; item < itemCount; item++) {
indexPath = [NSIndexPath indexPathForItem:item inSection:section];
UICollectionViewLayoutAttributes *itemAttributes =
[UICollectionViewLayoutAttributes layoutAttributesForCellWithIndexPath:indexPath];
itemAttributes.frame = [self frameForAssessmentAtIndexPath:indexPath];
cellLayoutInfo[indexPath] = itemAttributes;
}
}
self.layoutInfo = cellLayoutInfo;
}
This is a convenience method for quickly getting a frame at a given index.
- (CGRect)frameForIndexPath:(NSIndexPath *)indexPath
{
NSInteger column = indexPath.section;
NSInteger row = indexPath.item;
CGFloat originX = column * (self.itemSize.width + self.itemSpacing);
CGFloat originY = row * (self.itemSize.height + self.itemSpacing);
return CGRectMake(originX, originY, self.itemSize.width, self.itemSize.height);
}
Required method to calculate the content size. This just multiplies the number of sections or items by the size and spacing properties. This is what allows scrolling in both directions because the content size can be bigger than the collection view's width AND height.
- (CGSize)collectionViewContentSize
{
NSInteger sectionCount = [self.collectionView numberOfSections];
if (sectionCount == 0) {
return CGSizeZero;
}
NSInteger itemCount = [self.collectionView numberOfItemsInSection:0];
CGFloat width = (self.itemSize.width + self.itemSpacing) * sectionCount - self.itemSpacing;
CGFloat height = (self.itemSize.height + self.itemSpacing) * itemCount - self.itemSpacing;
return CGSizeMake(width, height);
}
Required methods. These tell the collection view where each item needs to be placed.
- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath
{
return self.layoutInfo[indexPath];
}
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect
{
NSMutableArray *allAttributes = [NSMutableArray array];
[self.layoutInfo enumerateKeysAndObjectsUsingBlock:^(NSIndexPath *indexPath, UICollectionViewLayoutAttributes *attributes, BOOL *stop) {
if (CGRectIntersectsRect(attributes.frame, rect)) {
[allAttributes addObject:attributes];
}
}];
return allAttributes;
}
#end
Of course, the layout in this case is specific to my individual problem.
The layout worked by having each section be a column and the items in each section were the rows. So something like this...
xy = item y in section x
00 10 20 30 ...
01 11 21 31 ...
02 12 22 32 ...
. . . .
. . . .
. . . .
Obviously there can be an unlimited number of sections or items in sections so I had to have scrolling in both directions.
Once you have created your layout class you just need to set it as the layout for your collection view. You can do this in code collectionView.collectionViewLayout = myLayout or you can do it in Interface Builder with the "layout" property on the collection view.
I'd like to introduce a different approach to creating a UICollectionView that scrolls in one direction while displaying cells containing a CollectionView that scrolls in the opposite direction. By implementing this collection view, setting the scrollDirection on the UICollectionViewFlowLayout instance used for the collection view in question this solution provides a seamless response to the user's interaction.
The solution subclasses the UICollectionView, and adds a delay gesture recognizer that intercepts the user's touches, delays them for a split seconds to figure out which direction the user is intending to scroll, then cancelling panningRecognizer on the collection view that don't scroll in that specific direction.
import Foundation
import UIKit
class UIDirectionAbidingCollectionView : UICollectionView {
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
super.init(frame: frame, collectionViewLayout: layout)
setupDelayRecognizer()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setupDelayRecognizer()
}
func setupDelayRecognizer() {
addGestureRecognizer(delayPanGestureRecognizer)
// Delay the touches on the default recognizer on the collection view
panGestureRecognizer.delaysTouchesBegan = true
}
// This gesture recognizer controls the response to the user's touches
// by cancelling by failing panGesture recognizer on the collection view
// that scrolls in the opposite direction.
lazy var delayPanGestureRecognizer: UIPanGestureRecognizer = {
var recognizer = UIPanGestureRecognizer()
recognizer.delegate = self
return recognizer
}()
}
extension UIDirectionAbidingCollectionView: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
// Ensure that the delay recognizer needs to fails for the
// default panning recognizers to receives the touches
if (gestureRecognizer == delayPanGestureRecognizer &&
otherGestureRecognizer == panGestureRecognizer)
{
return true
}
return false
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
// If the recognizer in question is our delay recognizer
// lets check to see if it should begin receiving touches
if gestureRecognizer == delayPanGestureRecognizer {
// First retrieve the direction our flowlayout intends to scroll
if let flowLayout = self.collectionViewLayout as? UICollectionViewFlowLayout {
let scrollDirection = flowLayout.scrollDirection
// Retrieve the translation of the delayPanningRecognizer
let translation = delayPanGestureRecognizer.translation(in: self)
// Calculate the magnitude of change for the y and x axis
let xTransaltionValue = (translation.x * translation.x)
let yTransaltionValue = (translation.y * translation.y)
if scrollDirection == .vertical && xTransaltionValue > yTransaltionValue {
// If the scroll direction of the flowlayout is vertical,
// and the magnitude in the horizontal direction
// is greater than the horizontal, begin receiving touches.
// Since the delay recognizer doesn't fail, the vertical
// panning recognizer will fail to start on the collection view
return true
}
else if scrollDirection == .horizontal && xTransaltionValue < yTransaltionValue {
// If the scroll direction of the flowlayout is horizontal,
// and the magnitude in the vertical direction
// is greater than the horizontal, begin receiving touches.
// Since the delay recognizer doesn't fail, the horizontal
// panning recognizer will fail to start on the collection view
return true
}
else {
// Fail the delay recognizer, and allows the collection
// view to continue as usual
return false
}
}
}
return true
}
}

UICollectionView insert cells above maintaining position (like Messages.app)

By default Collection View maintains content offset while inserting cells. On the other hand I'd like to insert cells above the currently displaying ones so that they appear above the screen top edge like Messages.app do when you load earlier messages. Does anyone know the way to achieve it?
This is the technique I use. I've found others cause strange side effects such as screen flicker:
CGFloat bottomOffset = self.collectionView.contentSize.height - self.collectionView.contentOffset.y;
[CATransaction begin];
[CATransaction setDisableActions:YES];
[self.collectionView performBatchUpdates:^{
[self.collectionView insertItemsAtIndexPaths:indexPaths];
} completion:^(BOOL finished) {
self.collectionView.contentOffset = CGPointMake(0, self.collectionView.contentSize.height - bottomOffset);
}];
[CATransaction commit];
James Martin’s fantastic version converted to Swift 2:
let amount = 5 // change this to the amount of items to add
let section = 0 // change this to your needs, too
let contentHeight = self.collectionView!.contentSize.height
let offsetY = self.collectionView!.contentOffset.y
let bottomOffset = contentHeight - offsetY
CATransaction.begin()
CATransaction.setDisableActions(true)
self.collectionView!.performBatchUpdates({
var indexPaths = [NSIndexPath]()
for i in 0..<amount {
let index = 0 + i
indexPaths.append(NSIndexPath(forItem: index, inSection: section))
}
if indexPaths.count > 0 {
self.collectionView!.insertItemsAtIndexPaths(indexPaths)
}
}, completion: {
finished in
print("completed loading of new stuff, animating")
self.collectionView!.contentOffset = CGPointMake(0, self.collectionView!.contentSize.height - bottomOffset)
CATransaction.commit()
})
My approach leverages subclassed flow layout. This means that you don't have to hack scrolling/layout code in a view controller. Idea is that whenever you know that you are inserting cells on top you set custom property you flag that next layout update will be inserting cells to top and you remember content size before update. Then you override prepareLayout() and set desired content offset there. It looks something like this:
define variables
private var isInsertingCellsToTop: Bool = false
private var contentSizeWhenInsertingToTop: CGSize?
override prepareLayout() and after calling super
if isInsertingCellsToTop == true {
if let collectionView = collectionView, oldContentSize = contentSizeWhenInsertingToTop {
let newContentSize = collectionViewContentSize()
let contentOffsetY = collectionView.contentOffset.y + (newContentSize.height - oldContentSize.height)
let newOffset = CGPointMake(collectionView.contentOffset.x, contentOffsetY)
collectionView.setContentOffset(newOffset, animated: false)
}
contentSizeWhenInsertingToTop = nil
isInsertingMessagesToTop = false
}
I did this in two lines of code (although it was on a UITableView) but I think you'd be able to do it the same way.
I rotated the tableview 180 degrees.
Then I rotated each tableview cell by 180 degrees also.
This meant that I could treat it as a standard top to bottom table but the bottom was treated like the top.
Swift 3 version code: based on James Martin answer
let amount = 1 // change this to the amount of items to add
let section = 0 // change this to your needs, too
let contentHeight = self.collectionView.contentSize.height
let offsetY = self.collectionView.contentOffset.y
let bottomOffset = contentHeight - offsetY
CATransaction.begin()
CATransaction.setDisableActions(true)
self.collectionView.performBatchUpdates({
var indexPaths = [NSIndexPath]()
for index in 0..<amount {
indexPaths.append(NSIndexPath(item: index, section: section))
}
if indexPaths.count > 0 {
self.collectionView.insertItems(at: indexPaths as [IndexPath])
}
}, completion: {
finished in
print("completed loading of new stuff, animating")
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
CATransaction.commit()
})
Here's a slightly tweaked version of Peter's solution (subclassing flow layout, no upside-down, lightweight approach). It's Swift 3. Note UIView.animate with zero duration - that's to allow the animation of the even/oddness of the cells (what's on a row) animate, but stop the animation of the viewport offset changing (which would look terrible)
Usage:
let layout = self.collectionview.collectionViewLayout as! ContentSizePreservingFlowLayout
layout.isInsertingCellsToTop = true
self.collectionview.performBatchUpdates({
if let deletionIndexPaths = deletionIndexPaths, deletionIndexPaths.count > 0 {
self.collectionview.deleteItems(at: deletionIndexPaths.map { return IndexPath.init(item: $0.item+twitterItems, section: 0) })
}
if let insertionIndexPaths = insertionIndexPaths, insertionIndexPaths.count > 0 {
self.collectionview.insertItems(at: insertionIndexPaths.map { return IndexPath.init(item: $0.item+twitterItems, section: 0) })
}
}) { (finished) in
completionBlock?()
}
Here's ContentSizePreservingFlowLayout in its entirety:
class ContentSizePreservingFlowLayout: UICollectionViewFlowLayout {
var isInsertingCellsToTop: Bool = false {
didSet {
if isInsertingCellsToTop {
contentSizeBeforeInsertingToTop = collectionViewContentSize
}
}
}
private var contentSizeBeforeInsertingToTop: CGSize?
override func prepare() {
super.prepare()
if isInsertingCellsToTop == true {
if let collectionView = collectionView, let oldContentSize = contentSizeBeforeInsertingToTop {
UIView.animate(withDuration: 0, animations: {
let newContentSize = self.collectionViewContentSize
let contentOffsetY = collectionView.contentOffset.y + (newContentSize.height - oldContentSize.height)
let newOffset = CGPoint(x: collectionView.contentOffset.x, y: contentOffsetY)
collectionView.contentOffset = newOffset
})
}
contentSizeBeforeInsertingToTop = nil
isInsertingCellsToTop = false
}
}
}
Adding to Fogmeister's answer (with code), the cleanest approach is to invert (turn upside-down) the UICollectionView so that you have a scroll view that is sticky to the bottom rather than the top. This also works for UITableView, as Fogmeister points out.
- (void)viewDidLoad
{
[super viewDidLoad];
self.collectionView.transform = CGAffineTransformMake(1, 0, 0, -1, 0, 0);
}
In Swift:
override func viewDidLoad() {
super.viewDidLoad()
collectionView.transform = CGAffineTransformMake(1, 0, 0, -1, 0, 0)
}
This has the side effect of also displaying your cells upside-down so you have to flip those as well. So we transfer the trasform (cell.transform = collectionView.transform) like so:
- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
UICollectionViewCell *cell = [collectionView dequeueReusableCellWithReuseIdentifier:#"Cell" forIndexPath:indexPath];
cell.transform = collectionView.transform;
return cell;
}
In Swift:
func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
var cell = collectionView.dequeueReusableCellWithReuseIdentifier("Cell", forIndexPath: indexPath) as! UICollectionViewCell
cell.transform = collectionView.transform
return cell
}
Lastly, the main thing to remember when developing under this design is that the NSIndexPath parameters in delegates are reversed. So indexPath.row == 0 is the row at on the bottom of the collectionView where it is normally at the top.
This technique is used in many open source projects to produce the behavior described including the popular SlackTextViewController (https://github.com/slackhq/SlackTextViewController) maintained by Slack
Thought I would add some code context to Fogmeister's fantastic answer!
This is what I learned from JSQMessagesViewController: How maintain scroll position?. Very simple, useful and NO flicker!
// Update collectionView dataSource
data.insert(contentsOf: array, at: startRow)
// Reserve old Offset
let oldOffset = self.collectionView.contentSize.height - self.collectionView.contentOffset.y
// Update collectionView
collectionView.reloadData()
collectionView.layoutIfNeeded()
// Restore old Offset
collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - oldOffset)
Love James Martin’s solution. But for me it started to breakdown when inserting/deleting above/below a specific content window. I took a stab at subclassing UICollectionViewFlowLayout to get the behavior I wanted. Hope this helps someone. Any feedback appreciated :)
#interface FixedScrollCollectionViewFlowLayout () {
__block float bottomMostVisibleCell;
__block float topMostVisibleCell;
}
#property (nonatomic, assign) BOOL isInsertingCellsToTop;
#property (nonatomic, strong) NSArray *visableAttributes;
#property (nonatomic, assign) float offset;;
#end
#implementation FixedScrollCollectionViewFlowLayout
- (id)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecoder];
if (self) {
_isInsertingCellsToTop = NO;
}
return self;
}
- (id)init {
self = [super init];
if (self) {
_isInsertingCellsToTop = NO;
}
return self;
}
- (void)prepareLayout {
NSLog(#"prepareLayout");
[super prepareLayout];
}
- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect {
NSLog(#"layoutAttributesForElementsInRect");
self.visableAttributes = [super layoutAttributesForElementsInRect:rect];
self.offset = 0;
self.isInsertingCellsToTop = NO;
return self.visableAttributes;
}
- (void)prepareForCollectionViewUpdates:(NSArray *)updateItems {
bottomMostVisibleCell = -MAXFLOAT;
topMostVisibleCell = MAXFLOAT;
CGRect container = CGRectMake(self.collectionView.contentOffset.x, self.collectionView.contentOffset.y, self.collectionView.frame.size.width, self.collectionView.frame.size.height);
[self.visableAttributes enumerateObjectsUsingBlock:^(UICollectionViewLayoutAttributes *attributes, NSUInteger idx, BOOL *stop) {
CGRect currentCellFrame = attributes.frame;
CGRect containerFrame = container;
if(CGRectIntersectsRect(containerFrame, currentCellFrame)) {
float x = attributes.indexPath.row;
if (x < topMostVisibleCell) topMostVisibleCell = x;
if (x > bottomMostVisibleCell) bottomMostVisibleCell = x;
}
}];
NSLog(#"prepareForCollectionViewUpdates");
[super prepareForCollectionViewUpdates:updateItems];
for (UICollectionViewUpdateItem *updateItem in updateItems) {
switch (updateItem.updateAction) {
case UICollectionUpdateActionInsert:{
NSLog(#"UICollectionUpdateActionInsert %ld",updateItem.indexPathAfterUpdate.row);
if (topMostVisibleCell>updateItem.indexPathAfterUpdate.row) {
UICollectionViewLayoutAttributes * newAttributes = [self layoutAttributesForItemAtIndexPath:updateItem.indexPathAfterUpdate];
self.offset += (newAttributes.size.height + self.minimumLineSpacing);
self.isInsertingCellsToTop = YES;
}
break;
}
case UICollectionUpdateActionDelete: {
NSLog(#"UICollectionUpdateActionDelete %ld",updateItem.indexPathBeforeUpdate.row);
if (topMostVisibleCell>updateItem.indexPathBeforeUpdate.row) {
UICollectionViewLayoutAttributes * newAttributes = [self layoutAttributesForItemAtIndexPath:updateItem.indexPathBeforeUpdate];
self.offset -= (newAttributes.size.height + self.minimumLineSpacing);
self.isInsertingCellsToTop = YES;
}
break;
}
case UICollectionUpdateActionMove:
NSLog(#"UICollectionUpdateActionMoveB %ld", updateItem.indexPathBeforeUpdate.row);
break;
default:
NSLog(#"unhandled case: %ld", updateItem.indexPathBeforeUpdate.row);
break;
}
}
if (self.isInsertingCellsToTop) {
if (self.collectionView) {
[CATransaction begin];
[CATransaction setDisableActions:YES];
}
}
}
- (void)finalizeCollectionViewUpdates {
CGPoint newOffset = CGPointMake(self.collectionView.contentOffset.x, self.collectionView.contentOffset.y + self.offset);
if (self.isInsertingCellsToTop) {
if (self.collectionView) {
self.collectionView.contentOffset = newOffset;
[CATransaction commit];
}
}
}
Inspired by Bryan Pratte's solution I developed subclass of UICollectionViewFlowLayout to get chat behavior without turning collection view upside-down. This layout is written in Swift 3 and absolutely usable with RxSwift and RxDataSources because UI is completely separated from any logic or binding.
Three things were important for me:
If there is a new message, scroll down to it. It doesn't matter where you are in the list in this moment. Scrolling is realized with setContentOffset instead of scrollToItemAtIndexPath.
If you do "Lazy Loading" with older messages, then the scroll view shouldn't change and stays exactly where it is.
Add exceptions for the beginning. The collection view should behave "normal" till there are more messages than space on the screen.
My solution:
https://gist.github.com/jochenschoellig/04ffb26d38ae305fa81aeb711d043068
While all solutions above are worked for me, the main reason of those to fail is that when user is scrolling while those items are being added, scroll will either stop or there'll be noticeable lag
Here is a solution that helps to maintain (visual)scroll position while adding items to the top.
class Layout: UICollectionViewFlowLayout {
var heightOfInsertedItems: CGFloat = 0.0
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
var offset = proposedContentOffset
offset.y += heightOfInsertedItems
heightOfInsertedItems = 0.0
return offset
}
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint, withScrollingVelocity velocity: CGPoint) -> CGPoint {
var offset = proposedContentOffset
offset.y += heightOfInsertedItems
heightOfInsertedItems = 0.0
return offset
}
override func prepare(forCollectionViewUpdates updateItems: [UICollectionViewUpdateItem]) {
super.prepare(forCollectionViewUpdates: updateItems)
var totalHeight: CGFloat = 0.0
updateItems.forEach { item in
if item.updateAction == .insert {
if let index = item.indexPathAfterUpdate {
if let attrs = layoutAttributesForItem(at: index) {
totalHeight += attrs.frame.height
}
}
}
}
self.heightOfInsertedItems = totalHeight
}
}
This layout remembers the height of items those are about to be inserted, and then next time, when layout will be asked for offset, it will compensate offset by the height of added items.
Not the most elegant but quite simple and working solution I stuck with for now. Works only with linear layout (not grid) but it's fine for me.
// retrieve data to be inserted
NSArray *fetchedObjects = [managedObjectContext executeFetchRequest:fetchRequest error:nil];
NSMutableArray *objects = [fetchedObjects mutableCopy];
[objects addObjectsFromArray:self.messages];
// self.messages is a DataSource array
self.messages = objects;
// calculate index paths to be updated (we are inserting
// fetchedObjects.count of objects at the top of collection view)
NSMutableArray *indexPaths = [NSMutableArray new];
for (int i = 0; i < fetchedObjects.count; i ++) {
[indexPaths addObject:[NSIndexPath indexPathForItem:i inSection:0]];
}
// calculate offset of the top of the displayed content from the bottom of contentSize
CGFloat bottomOffset = self.collectionView.contentSize.height - self.collectionView.contentOffset.y;
// performWithoutAnimation: cancels default collection view insertion animation
[UIView performWithoutAnimation:^{
// capture collection view image representation into UIImage
UIGraphicsBeginImageContextWithOptions(self.collectionView.bounds.size, NO, 0);
[self.collectionView drawViewHierarchyInRect:self.collectionView.bounds afterScreenUpdates:YES];
UIImage *snapshotImage = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
// place the captured image into image view laying atop of collection view
self.snapshot.image = snapshotImage;
self.snapshot.hidden = NO;
[self.collectionView performBatchUpdates:^{
// perform the actual insertion of new cells
[self.collectionView insertItemsAtIndexPaths:indexPaths];
} completion:^(BOOL finished) {
// after insertion finishes, scroll the collection so that content position is not
// changed compared to such prior to the update
self.collectionView.contentOffset = CGPointMake(0, self.collectionView.contentSize.height - bottomOffset);
[self.collectionView.collectionViewLayout invalidateLayout];
// and hide the snapshot view
self.snapshot.hidden = YES;
}];
}];
if ([newMessages count] > 0)
{
[self.collectionView reloadData];
if (hadMessages)
[self.collectionView scrollToItemAtIndexPath:[NSIndexPath indexPathForItem:[newMessages count] inSection:0] atScrollPosition:UICollectionViewScrollPositionTop animated:NO];
}
This seems to be working so far. Reload the collection, scroll the previously first message to the top without animation.
I managed to write a solution which works for cases when inserting cells at the top and bottom at the same time.
Save the position of the top visible cell. Compute the height of the cell which is underneath the navBar (the top view. in my case it is the self.participantsView)
// get the top cell and save frame
NSMutableArray<NSIndexPath*> *visibleCells = [self.collectionView indexPathsForVisibleItems].mutableCopy;
NSSortDescriptor *sortDescriptor = [[NSSortDescriptor alloc] initWithKey:#"item" ascending:YES];
[visibleCells sortUsingDescriptors:#[sortDescriptor]];
ChatMessage *m = self.chatMessages[visibleCells.firstObject.item];
UICollectionViewCell *topCell = [self.collectionView cellForItemAtIndexPath:visibleCells.firstObject];
CGRect topCellFrame = topCell.frame;
CGRect navBarFrame = [self.view convertRect:self.participantsView.frame toView:self.collectionView];
CGFloat offset = CGRectGetMaxY(navBarFrame) - topCellFrame.origin.y;
Reload your data.
[self.collectionView reloadData];
Get the new position of the item. Get the attributes for that index. Extract the offset and change contentOffset of the collectionView.
// scroll to the old cell position
NSUInteger messageIndex = [self.chatMessages indexOfObject:m];
UICollectionViewLayoutAttributes *attr = [self.collectionView layoutAttributesForItemAtIndexPath:[NSIndexPath indexPathForItem:messageIndex inSection:0]];
self.collectionView.contentOffset = CGPointMake(0, attr.frame.origin.y + offset);
// stop scrolling
setContentOffset(contentOffset, animated: false)
// calculate the offset and reloadData
let beforeContentSize = contentSize
reloadData()
layoutIfNeeded()
let afterContentSize = contentSize
// reset the contentOffset after data is updated
let newOffset = CGPoint(
x: contentOffset.x + (afterContentSize.width - beforeContentSize.width),
y: contentOffset.y + (afterContentSize.height - beforeContentSize.height))
setContentOffset(newOffset, animated: false)
I found the five steps work seamlessly:
Prepare data for your new cells, and insert the data as appropriate
Tell UIView to stop animation
UIView.setAnimationsEnabled(false)
Actually insert those cells
collectionView?.insertItems(at: indexPaths)
Scroll the collection view (which is a subclass of UIScrollView)
scrollView.contentOffset.y += CELL_HEIGHT * CGFloat(ITEM_COUNT)
Notice to substitute CELL_HEIGHT with the height of your cells (which is only easy if cells are of a fixed size). It is important to add any cell-to-cell margin / insets.
Remember to tell UIView to start animation again:
UIView.setAnimationsEnabled(true)
A few of the suggested approaches had varying degrees of success for me. I eventually used a variation of the subclassing and prepareLayout option Peter Stajger putting my offset correction in finalizeCollectionViewUpdates. However today as I was looking at some additional documentation I found targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) and I think that feels a lot more like the intended location for this type of correction. So this is my implementation using that. Note my implmentation was for a horizontal collection but cellsInsertingToTheLeft could be easily updated as cellsInsertingAbove and the offset corrected accordingly.
class GCCFlowLayout: UICollectionViewFlowLayout {
var cellsInsertingToTheLeft: Int?
override func targetContentOffset(forProposedContentOffset proposedContentOffset: CGPoint) -> CGPoint {
guard let cells = cellsInsertingToTheLeft else { return proposedContentOffset }
guard let collectionView = collectionView else { return proposedContentOffset }
let contentOffsetX = collectionView.contentOffset.x + CGFloat(cells) * (collectionView.bounds.width - 45 + 8)
let newOffset = CGPoint(x: contentOffsetX, y: collectionView.contentOffset.y)
cellsInsertingToTheLeft = nil
return newOffset
}
}
Based on #Steven answer, I managed to make insert cell with scroll to the bottom, without any flickering (and using auto cells), tested on iOS 12
let oldOffset = self.collectionView!.contentOffset
let oldOffsetDelta = self.collectionView!.contentSize.height - self.collectionView!.contentOffset.y
CATransaction.begin()
CATransaction.setCompletionBlock {
self.collectionView!.setContentOffset(CGPoint(x: 0, y: self.collectionView!.contentSize.height - oldOffsetDelta), animated: true)
}
collectionView!.reloadData()
collectionView!.layoutIfNeeded()
self.collectionView?.setContentOffset(oldOffset, animated: false)
CATransaction.commit()
I have used the #James Martin approach, but if you use coredata and NSFetchedResultsController the right approach is store the number of earlier messages loaded in _earlierMessagesLoaded and check the value in the controllerDidChangeContent:
#pragma mark - NSFetchedResultsController
- (void)controllerDidChangeContent:(NSFetchedResultsController *)controller
{
if(_earlierMessagesLoaded)
{
__block NSMutableArray * indexPaths = [NSMutableArray new];
for (int i =0; i<[_earlierMessagesLoaded intValue]; i++)
{
[indexPaths addObject:[NSIndexPath indexPathForRow:i inSection:0]];
}
CGFloat bottomOffset = self.collectionView.contentSize.height - self.collectionView.contentOffset.y;
[CATransaction begin];
[CATransaction setDisableActions:YES];
[self.collectionView performBatchUpdates:^{
[self.collectionView insertItemsAtIndexPaths:indexPaths];
} completion:^(BOOL finished) {
self.collectionView.contentOffset = CGPointMake(0, self.collectionView.contentSize.height - bottomOffset);
[CATransaction commit];
_earlierMessagesLoaded = nil;
}];
}
else
[self finishReceivingMessageAnimated:NO];
}
CGPoint currentOffset = _collectionView.contentOffset;
CGSize contentSizeBeforeInsert = [_collectionView.collectionViewLayout collectionViewContentSize];
[_collectionView reloadData];
CGSize contentSizeAfterInsert = [_collectionView.collectionViewLayout collectionViewContentSize];
CGFloat deltaHeight = contentSizeAfterInsert.height - contentSizeBeforeInsert.height;
currentOffset.y += MAX(deltaHeight, 0);
_collectionView.contentOffset = currentOffset;

Want UITableView to "snap to cell"

I am displaying fairly large images in a UITableView. As the user scrolls, I'd like to the table view to always snap the center-most photo in the middle. That is, when the table is in a resting state, it will always have a UITableViewCell snapped to the center.
How does one do this?
You can use the UIScrollViewDelegate methods on UITableView to do this:
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
// if decelerating, let scrollViewDidEndDecelerating: handle it
if (decelerate == NO) {
[self centerTable];
}
}
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
[self centerTable];
}
- (void)centerTable {
NSIndexPath *pathForCenterCell = [self.tableView indexPathForRowAtPoint:CGPointMake(CGRectGetMidX(self.tableView.bounds), CGRectGetMidY(self.tableView.bounds))];
[self.tableView scrollToRowAtIndexPath:pathForCenterCell atScrollPosition:UITableViewScrollPositionMiddle animated:YES];
}
There is a UIScrollView delegate method especially for this!
Edit: if you just want the code, look at the answers below which build off this.
The table view (which is a scroll view) will call - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset when the user stops scrolling. You can set manipulate the targetContentOffset to ensure it ends up where you want, and it will decelerate ending at that position (just like a paging UIScrollView).
For example, if your cells were all 100 points high, you could do:
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
targetContentOffset->y = 100 * (int)targetContentOffset->y/100;
}
Of course, you can also inspect the targetContentOffset passed in to see where it was going to land, and then find the cell that is in and alter it appropriately.
Building from what #jszumski posted, if you want the snap to occur mid drag, use this code:
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
[self centerTable];
}
- (void)scrollViewWillBeginDecelerating:(UIScrollView *)scrollView {
[self centerTable];
}
- (void)centerTable {
NSIndexPath *pathForCenterCell = [self.tableView indexPathForRowAtPoint:CGPointMake(CGRectGetMidX(self.tableView.bounds), CGRectGetMidY(self.tableView.bounds))];
[self.tableView scrollToRowAtIndexPath:pathForCenterCell atScrollPosition:UITableViewScrollPositionMiddle animated:YES];
}
Extending #jesse-rusak's answer above, this is the code you would need to add to your UITableViewController subclass if you have cells with variable heights. This will avoid the double-scroll issue in the accepted answer.
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
NSIndexPath *pathForTargetTopCell = [self.tableView indexPathForRowAtPoint:CGPointMake(CGRectGetMidX(self.tableView.bounds), targetContentOffset->y)];
targetContentOffset->y = [self.tableView rectForRowAtIndexPath:pathForTargetTopCell].origin.y;
}
Extending #mikepj answer, (which in turn extended the great answer by #JesseRusak), this code lets you snap to a cell, even when cells have a variable (or unknown) height, and will snap to the next row if you'll scroll over the bottom half of the row, making it more "natural".
Original Swift 4.2 code: (for convenience, this is the actual code I developed and tested)
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
guard var scrollingToIP = table.indexPathForRow(at: CGPoint(x: 0, y: targetContentOffset.pointee.y)) else {
return
}
var scrollingToRect = table.rectForRow(at: scrollingToIP)
let roundingRow = Int(((targetContentOffset.pointee.y - scrollingToRect.origin.y) / scrollingToRect.size.height).rounded())
scrollingToIP.row += roundingRow
scrollingToRect = table.rectForRow(at: scrollingToIP)
targetContentOffset.pointee.y = scrollingToRect.origin.y
}
(translated) Objective-C code: (since this question is tagged objective-c)
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
NSIndexPath *scrollingToIP = [self.tableView indexPathForRowAtPoint:CGPointMake(0, targetContentOffset->y)];
if (scrollingToIP == nil)
return;
CGRect scrollingToRect = [table rectForRowAtIndexPath:scrollingToIP];
NSInteger roundingRow = (NSInteger)(round(targetContentOffset->y - scrollingToRect.origin.y) / scrollingToRect.size.height));
scrollingToIP.row += roundingRow;
scrollingToRect = [table rectForRowAtIndexPath:scrollingToIP];
targetContentOffset->y = scrollingToRect.origin.y;
}
for the swift peeps
override func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool)
{
if decelerate == false
{
self.centerTable()
}
}
override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
self.centerTable()
}
func centerTable()
{
let midX:CGFloat = self.tableView.bounds.midX
let midY:CGFloat = self.tableView.bounds.midY
let midPoint:CGPoint = CGPoint(x: midX, y: midY)
if let pathForCenterCell:IndexPath = self.tableView .indexPathForRow(at: midPoint)
{
self.tableView.scrollToRow(at: pathForCenterCell, at: .middle, animated: true)
}
}//eom
UITableView extends UIScrollView...
myTableView.pagingEnabled = YES

Resources