Reorder cells of UICollectionView
Asked Answered
M

4

43

Consider a UICollectionView with flow layout and horizontal direction. By default, cells are ordered from top to bottom, left to right. Like this:

1 4 7 10 13 16
2 5 8 11 14 17
3 6 9 12 15 18

In my case, the collection view is paged and it has been designed so that a specific number of cells fits in each page. Thus, a more natural ordering would be:

1 2 3   10 11 12
4 5 6 - 13 14 15
7 8 9   16 17 18

What would be the simplest to achieve this, short of implementing my own custom layout? In particular, I don't want to loose any of the functionalities that come for free with UICollectionViewFlowLayout (such as insert/remove animations).

Or in general, how do you implement a reordering function f(n) on a flow layout? The same could be applicable to a right-to-left ordering, for example.

My approach so far

My first approach was to subclass UICollectionViewFlowLayout and override layoutAttributesForItemAtIndexPath::

- (UICollectionViewLayoutAttributes *)layoutAttributesForItemAtIndexPath:(NSIndexPath *)indexPath
{
    NSIndexPath *reorderedIndexPath = [self reorderedIndexPathOfIndexPath:indexPath];
    UICollectionViewLayoutAttributes *layout = [super layoutAttributesForItemAtIndexPath:reorderedIndexPath];
    layout.indexPath = indexPath;
    return layout;
}

Where reorderedIndexPathOfIndexPath: is f(n). By calling super, I don't have to calculate the layout of each element manually.

Additionally, I had to override layoutAttributesForElementsInRect:, which is the method the layout uses to choose which elements to display.

- (NSArray *)layoutAttributesForElementsInRect:(CGRect)rect
{
    NSMutableArray *result = [NSMutableArray array];
    NSInteger sectionCount = 1;
    if ([self.collectionView.dataSource respondsToSelector:@selector(numberOfSectionsInCollectionView:)])
    {
        sectionCount = [self.collectionView.dataSource numberOfSectionsInCollectionView:self.collectionView];
    }
    for (int s = 0; s < sectionCount; s++)
    {
        NSInteger itemCount = [self.collectionView.dataSource collectionView:self.collectionView numberOfItemsInSection:s];
        for (int i = 0; i < itemCount; i++)
        {
            NSIndexPath *indexPath = [NSIndexPath indexPathForItem:i inSection:s];
            UICollectionViewLayoutAttributes *layout = [self layoutAttributesForItemAtIndexPath:indexPath];
            if (CGRectIntersectsRect(rect, layout.frame))
            {
                [result addObject:layout];
            }
        }
    }
    return result;
}

Here I just try every element and if it is within the given rect, I return it.

If this approach is the way to go, I have the following more specific questions:

  • Is there any way I can simplify the layoutAttributesForElementsInRect: override, or make it more efficient?
  • Am I missing something? At the very least swapping cells of different pages produces odd results. I suspect it's related to initialLayoutAttributesForAppearingItemAtIndexPath: and finalLayoutAttributesForDisappearingItemAtIndexPath:, but I can't pinpoint exactly what is the problem.
  • In my case, f(n) depends on the number of columns and rows of each page. Is there any way of extracting this information from UICollectionViewFlowLayout, short of hardcoding it myself? I thought of querying layoutAttributesForElementsInRect: with the bounds of the collection view, and deducing the rows and columns from there, but this also feels inefficient.
Merissa answered 30/3, 2013 at 13:43 Comment(3)
I wonder if it wouldn't be easier to have multiple collection views in a paged scroll view, so that the first collection view would contain items 1 through 9 with a horizontal layout, the second, items 10 -18, etc.Radiomicrometer
@Radiomicrometer Felt like reimplementing a lot of things that I get for free with UICollectionView. Didn't try splitting the collection in sections, though.Merissa
Unfortunately it seems that you have to reimplement a lot... See my answer. But at least it'll be very reusable. :)Franciscafranciscan
T
15

I've thought a lot about your question and came to following considerations:

Subclassing the FlowLayout seems to be the rightest and the most effective way to reorder cells and to make use of flow layout animations. And your approach works, except of two important things:

  1. Let's say you have a collection view with only 2 cells and you have designed your page so that it can contain 9 cells. First cell will be positioned at the top left corner of the view, like in original flow layout. Your second cell, however, should be positioned at the top of the view and it has an index path [0, 1]. The reordered index path would be [0, 3] (index path of original flow layout cell that would be on its place). And in your layoutAttributesForItemAtIndexPath override you would send the message like [super layoutAttributesForItemAtIndexPath:[0, 3]], you would get an nil object, just because there are only 2 cells: [0,0] and [0,1]. And this would be the problem for your last page.
  2. Even though you can implement the paging behavior by overriding targetContentOffsetForProposedContentOffset:withScrollingVelocity: and manually set properties like itemSize, minimumLineSpacing and minimumInteritemSpacing, it's much work to make your items be symmetrical, to define the paging borders and so on.

I thnik, subclassing the flow layout is preparing much implementation for you, because what you want is not a flow layout anymore. But let's think about it together. Regarding your questions:

  • your layoutAttributesForElementsInRect: override is exactly how the original apple implementation is, so there is no way to simplify it. For your case though, you could consider following: if you have 3 rows of items per page, and the frame of item in first row intersects the rect frame, then (if all items have same size) the frames of second and third row items intersect this rect.
  • sorry, I didn't understand your second question
  • in my case the reordering function looks like this: (a is the integer number of rows/columns on every page, rows=columns)

f(n) = (n % a²) + (a - 1)(col - row) + a²(n / a²); col = (n % a²) % a; row = (n % a²) / a;

Answering the question, the flow layout has no idea how many rows are in each column because this number can vary from column to column depending on size of every item. It can also say nothing about number of columns on each page because it depends on the scrolling position and can also vary. So there is no better way than querying layoutAttributesForElementsInRect, but this will include also cells, that are only partically visible. Since your cells are equal in size, you could theoretically find out how many rows has your collection view with horizontal scrolling direction: by starting iterating each cell counting them and breaking if their frame.origin.x changes.

So, I think, you have two options to achieve your purpose:

  1. Subclass UICollectionViewLayout. It seems to be much work implementing all those methods, but it's the only effective way. You could have for example properties like itemSize, itemsInOneRow. Then you could easily find a formula to calculate the frame of each item based on it's number (the best way is to do it in prepareLayout and store all frames in array, so that you cann access the frame you need in layoutAttributesForItemAtIndexPath). Implementing layoutAttributesForItemAtIndexPath, layoutAttributesForItemsInRect and collectionViewContentSize would be very simple as well. In initialLayoutAttributesForAppearingItemAtIndexPath and finalLayoutAttributesForDisappearingItemAtIndexPath you could just set the alpha attribute to 0.0. That's how standard flow layout animations work. By overriding targetContentOffsetForProposedContentOffset:withScrollingVelocity: you could implement the "paging behavior".

  2. Consider making a collection view with flow layout, pagingEnabled = YES, horizontal scrolling direction and item size equal to screen size. One item per screen size. To each cell you could set a new collection view as subview with vertical flow layout and the same data source as other collection views but with an offset. It's very efficient, because then you reuse whole collection views containing blocks of 9 (or whatever) cells instead of reusing each cell with standard approach. All animations should work properly.

Here you can download a sample project using the layout subclassing approach. (#2)

Twosided answered 9/4, 2013 at 7:9 Comment(10)
Thanks for the reply Bogdan. Are you sure layoutAttributesForItemAtIndexPath: of an inexistent returns nil? I believe I tried this and the layout still calculated the attributes.Merissa
Approach number 2 sounds sexy in theory, but it doesn't allow me to easily move cells from one page to the other. This is the same problem than your first reply.Merissa
I don't understand why I would need to override targetContentOffsetForProposedContentOffset:withScrollingVelocity. Setting pagingEnabled to YES works just fine.Merissa
You are welcome. As I tried it out, no cells were visible, which reordered index path was refering to an inexistent cell. Maybe it worked for you, because you had another reordering function than me. If pagingEnabled = YES works, than of course you don't have to implement paging yourself. I thought, if you set pagingEnabled to YES, scroll view will just stick to the bounds of every cell and it is not what you want (is it?). I think, approach #2 should work even if you try to move cells from one view to another. I will try it out later. Try approach #1, it is really worth itTwosided
I must be missing something, but I don't understand how approach 1 is different from what I'm already doing?Merissa
You are subclassing a flow layout. The layout, what are you trying to make has actually a very little to do with flow layout, so you'd better subclass a generic layout.Twosided
Apologies. I missed that. :PMerissa
I'm awarding you the bounty because you really tried. I'll give this a go in the next few days and post my results here.Merissa
Thank you. Actually, approach #2 works for me. I think, it just depends on how do you implement the moving cells. I will try the approach #1 too. It will be interesting to see how it worked for you.Twosided
I've added a link to sample project at the end of my post.Twosided
T
4

Wouldn't it be a simple solution to have 2 collection views with standart UICollectionViewFlowLayout?

Or even better: to have a page view controller with horizontal scrolling, and each page would be a collection view with normal flow layout.

The idea is following: in your UICollectionViewController -init method you create a second collection view with frame offset to the right by your original collection view width. Then you add it as subview to original collection view. To switch between collection views, just add a swipe recognizer. To calculate offset values you can store the original frame of collection view in ivar cVFrame. To identify your collection views you can use tags.

Example of init method:

CGRect cVFrame = self.collectionView.frame;
UICollectionView *secondView = [[UICollectionView alloc] 
              initWithFrame:CGRectMake(cVFrame.origin.x + cVFrame.size.width, 0, 
                             cVFrame.size.width, cVFrame.size.height) 
       collectionViewLayout:[UICollectionViewFlowLayout new]];
    [secondView setBackgroundColor:[UIColor greenColor]];
    [secondView setTag:1];
    [secondView setDelegate:self];
    [secondView setDataSource:self];
    [self.collectionView addSubview:secondView];

UISwipeGestureRecognizer *swipeRight = [[UISwipeGestureRecognizer alloc]
                      initWithTarget:self action:@selector(swipedRight)];
    [swipeRight setDirection:UISwipeGestureRecognizerDirectionRight];

UISwipeGestureRecognizer *swipeLeft = [[UISwipeGestureRecognizer alloc] 
                       initWithTarget:self action:@selector(swipedLeft)];
    [swipeLeft setDirection:UISwipeGestureRecognizerDirectionLeft];

    [self.collectionView addGestureRecognizer:swipeRight];
    [self.collectionView addGestureRecognizer:swipeLeft];

Example of swipeRight and swipeLeft methods:

-(void)swipedRight {
    // Switch to left collection view
    [self.collectionView setContentOffset:CGPointMake(0, 0) animated:YES];
}

-(void)swipedLeft {
    // Switch to right collection view
    [self.collectionView setContentOffset:CGPointMake(cVFrame.size.width, 0) 
                                 animated:YES];
}

And then it's not a big problem to implement DataSource methods (in your case you want to have 9 items on each page):

-(NSInteger)collectionView:(UICollectionView *)collectionView 
    numberOfItemsInSection:(NSInteger)section {
    if (collectionView.tag == 1) {
         // Second collection view
         return self.dataArray.count % 9;
    } else {
         // Original collection view
         return 9; // Or whatever
}

In method -collectionView:cellForRowAtIndexPath you will need to get data from your model with offset, if it's second collection view.

Also don't forget to register class for reusable cell for your second collection view as well. You can also create only one gesture recognizer and recognize swipes to the left and to the right. It's up to you.

I think, now it should work, try it out :-)

Twosided answered 5/4, 2013 at 14:9 Comment(8)
If you do this at the very least you loose insert/delete animations between collection views. Also, it adds a lot of layout logic to the view controller. Thanks for answering, though.Merissa
Well, i thought, since you see only one collection view at once, it doesn't matter. Another solution would be having two collection view controllers each having swipe gesture recognizer.Twosided
Well, it matters if you delete the last/first element of the Nth page.Merissa
When you delete the elements in your model and then call reloadItemsAtIndexPaths:deletedIndexPaths in performBathUpdates method, you will see animation.Twosided
To delete the last element of page N (and get animations): first I need to delete the last element of page N, then delete the first element of page N+1 and finally insert that element in the last position of page N. One delete operation becomes 3 operations. Complicated much?Merissa
Actually, I would need to do something similar for every delete in page N-1.Merissa
Consider deleting item from model (from your data source array) and reloading data. Since your collection views have constant offset reading from model, it will workTwosided
Wouldn't we loose the animations if we use reloadData for every insert/delete? Also, it wouldn't be very efficient.Merissa
F
2

You have an object that implements the UICollectionViewDataSource protocol. Inside collectionView:cellForItemAtIndexPath: simply return the correct item that you want to return. I don't understand where there would be a problem.

Edit: ok, I see the problem. Here is the solution: http://www.skeuo.com/uicollectionview-custom-layout-tutorial , specifically steps 17 to 25. It's not a huge amount of work, and can be reused very easily.

Franciscafranciscan answered 30/3, 2013 at 22:33 Comment(1)
Two problems, off the top of my head. 1) Insert and delete animations will not work properly. 2) If you don't have enough elements to fill a page, you have to create fake empty elements.Merissa
S
0

This is my solution, I did not test with animations but I think it will be work well with a little changes, hope it helpful

CELLS_PER_ROW = 4;
CELLS_PER_COLUMN = 5;
CELLS_PER_PAGE = CELLS_PER_ROW * CELLS_PER_COLUMN;

UICollectionViewFlowLayout* flowLayout = (UICollectionViewFlowLayout*)collectionView.collectionViewLayout;
flowLayout.scrollDirection = UICollectionViewScrollDirectionHorizontal;
flowLayout.itemSize = new CGSize (size.width / CELLS_PER_ROW - delta, size.height / CELLS_PER_COLUMN - delta);
flowLayout.minimumInteritemSpacing = ..;
flowLayout.minimumLineSpacing = ..;
..

- (UICollectionViewCell *)collectionView:(UICollectionView *)collectionView
                  cellForItemAtIndexPath:(NSIndexPath *)indexPath
{
    NSInteger index = indexPath.row;
    NSInteger page = index / CELLS_PER_PAGE;
    NSInteger indexInPage = index - page * CELLS_PER_PAGE;
    NSInteger row = indexInPage % CELLS_PER_COLUMN;
    NSInteger column = indexInPage / CELLS_PER_COLUMN;

    NSInteger dataIndex = row * CELLS_PER_ROW + column + page * CELLS_PER_PAGE;

    id cellData = _data[dataIndex];
    ...
}
Supernatant answered 22/3, 2015 at 16:16 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.