Slavcho
Slavcho

Reputation: 2812

UICollectionViewCells initialized when in ScrollView with constant height constraint

In my ViewController I have a UIScrollView with some elements on the top and under them I have a UICollectionView with 2 items per row. The bottom constraint of the UICollectionView is connected to the bottom of the scroll view. So I want to have one scroll bar and in that case I'm calculating the height of the UICollectionView, for ex. if I have 11 items the height (my UICollectionView height constraint) should be 6 heights of the items + the spacing. That works perfect. But my concern is because I'm loading images from my local database Realm, i want to do lazy load, but there is no reusing of cells because on the first time they are all initialized and filled with data and despite scrolling up and down, they are not reused, they are always in memory. So sometimes when I load 50+ of them I got memory warnings and sometimes the app crashes. I hope thats because of the approach with the UICollectionView height I've used to have 1 global scroll bar.

So my concern is, if it is possible to have that feature with the scroll bar and the constant height constraint but the items/cells to keep reusing?

Upvotes: 0

Views: 1041

Answers (3)

nyrd
nyrd

Reputation: 498

UICollectionView (and UITableView) optimizes memory by instantiating a pool of cells that get reused. It pulls the amount of cells it needs to display from the pool given a change on the scroll position and/or space available (by changing its size). By "display" I don't necessarily mean visible. In your case, the collectionView may not be visible when embedded in a UIScrollView and placed down below the screen bounds. A cell is displayed when the collectionView.bounds contains the "cell.frame" (even partially). I quoted "cell.frame" because it's not really a cell instance we're talking about but rather an internal mechanism of UICollectionView to keep track of each cell's frame.

What you are doing is give your collectionView enough space (by adjusting its height) to display all that content you want it to display. So no optimization is being done there.

I can think of three options:

1) Instead of embedding a UICollectionView in a UIScrollView, it's better to use only one UICollectionView. Then, make your content in terms of cells. That content above your collectionView? Make it a cell (or a bunch of cells). Make it live in its own section with its own insets and stuff. When implementing this option I usually define an enum with cases being the needed types of content.

enum ContentType {
    case header, cell(Any)
}

var data: [ContentType] = [...]

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    switch data[indexPath.row] {
        case .header:
            return headerDataSource.cell // Some object you can obtain the cell from
        case .cell(value):
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath)
            cell.fill(value)
            return cell
    }
}

2) This option is personal preference on a design standpoint. I suggest you set a limit to the amount of cells your collectionView displays, disable collectionView's scrolling, set collectionView's height to contentSize.height and add a "More" cell that sends the user to a UICollectionViewController that displays the rest of the content and take advantage of the optimization. Since we're limiting the amount of cells displayed it won't impact memory, unless the limit is set too high (obviously). I implemented this option once when I needed to display a gallery with some content above it, a bunch of other kind of cells below it, and some review entries further down (cells too), and I didn't wanted the user to scroll, say, 1000 gallery cells to reach the reviews. Nor wanted the user to scroll up again those 1000 cells to reach the content above the gallery. I set a limit of 8 gallery cells plus the "More" cell to make a 3x3 grid which looks nice and simple.

3) I think this one is overkill. Not worth when you got option 1. You can implement scrollViewDidScroll:, calculate frame of embedded collectionView (in terms of the view controller's view coordinate system) using convert(_:to:) and adjust height constraint of collectionView to be the delta of scrollView frame's height minus collectionView previously calculated frame's minY and ensuring collectionView height is not greater than scrollView frame's height. When that happens, the extra delta needs to be added to the bounds of collectionView as a mean of scrolling it programmatically. There's many more considerations I wasn't able to solve nor had the interest to do so, given I can implement option 1.

// This code is not working in an acceptable way but I think is a reasonable starting point.
// Also, when bouncing is disabled, a scrollView doesn't call this method when trying to scroll past the edges.
extension ViewController: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        guard !(scrollView is UICollectionView) else {return}
        let height = view.bounds.height
        let minY = collectionView.convert(collectionView.bounds, to: view).minY
        let delta = height - minY
        constraint_collection_height.constant = delta
        if delta > height {
            constraint_collection_height.constant = height
            let offset = collectionView.convert(CGPoint.zero, to: scrollView).y
            let scroll = scrollView.bounds.minY - offset
            let bounds = CGRect(origin: CGPoint(x: 0, y: scroll), size: collectionView.bounds.size)
            collectionView.bounds = bounds
        }
    }
}

Edit: - An example to respond a comment.

Instead of embedding a collectionView in a cell to manage another kind of layout. One should be managing a new section and define the new layout there.

enum ContentType {
    case header, cell(Any), image(UIImage)
}

var data_section0: [ContentType] = [...]
var data_section1: [ContentType] = [...]

func retrieve_cell(_ collectionView: UICollectionView, _ data: [ContentType], _ row: Int) -> UICollectionViewCell {
    switch data[row] {
        case .header:
            return headerDataSource.cell // Some object you can obtain the cell from
        case .cell(value):
            let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath)
            cell.fill(value)
            return cell
        case .image(let image):
            let cell = ...
    }
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    switch indexPath.section {
        case 0: return retrieve_cell(collectionView, data_section0, indexPath.row)
        case 1: return retrieve_cell(collectionView, data_section1, indexPath.row)
        default: fatalError("There' shouldn't be a 3rd section")
    }
}

func numberOfSections(in collectionView: UICollectionView) -> Int {return 2}

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    switch section {
        case 0: return data_section0.count
        case 1: return data_section1.count
        default: fatalError("There' shouldn't be a 3rd section")
    }
}

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
    switch indexPath.section {
        case 0: return collectionView.bounds.width
        case 1: return collectionView.bounds.width / 2
        default: fatalError("There' shouldn't be a 3rd section")
    }
}

Upvotes: 1

Prashant Tukadiya
Prashant Tukadiya

Reputation: 16456

Issue

What is happening is You have provided height of UICollectionview = content size. hence it is loading every cell in memory and will never reuse your cell, So your memory keep increasing.

Solutions

You have several options to fix it.

1) remove outer scrollview and add all that item in your header of collectionview

2) load only thumbnail image in colllectionview

3) Recommended : You have provided height of collectionview has the content size of it. instead of it. You should provide screen height + top items height. i.e If your top items has height of 150 so you can add 667 (6s) screen height to collectionview is 667 + 150 = 817 (it will be content size of outer scrollview).
with this approach your collectionview is now able to reuse the cell if you load thumbnail in it it will be bonus.

issue with third approach is you have to scroll twice first time it will scroll top header (outer scrollview) and then it will start scrolling the collectionview

Hope it is helpful

Upvotes: 1

bompf
bompf

Reputation: 1514

Since you're expanding the collection view to its full height, you've esentially disabled all optimizations it can provide through reusable cells. UITableView and UICollectionView will always refer to their own respective bounds when making decisions about which cells to reuse and, as far as I know, there is no way to delegate this task to an outer scrollview.

Depending on the content of your cells, you could try to query the UICollectionViewLayout's visible index paths by using layoutAttributesForElementsInRect: and load the memory-intensive content for those cells once your outer scrollview scrolls them into view. You can set your outer scrollview's delegate property for that and get updated through scrollViewDidScroll. Of course, you'd also have to worry about unloading images once they're scrolled off-screen.

Upvotes: 1

Related Questions