swiftuicollectionviewuikituicollectionviewcompositionallayout

Scale cell according to visibility percentage


When a cell is fully visible at the center of the collection view, I want it to appear larger than the cells in the sides.

Currently, I'm utilizing visibleItemsInvalidationHandler closure to achieve that effect:

section.visibleItemsInvalidationHandler = { (items, offset, environment) in

            items.forEach { item in
                let frame = item.frame
                let rect = CGRect(x: offset.x, y: offset.y, width: environment.container.contentSize.width, height: frame.height)
                let inter = rect.intersection(frame)
                let ratio = (inter.width * inter.height) / (frame.width * frame.height)
                let scale = ratio > 0.8 ? ratio : 0.8
                UIView.animate(withDuration: 0.2) {
                    item.transform = CGAffineTransform(scaleX: 0.98, y: scale)
                }
            }
        }

However, I still don't get the result I'm aiming for. This is what I get:

enter image description here

The cells start to flicker when swiped, and when I try to get a cell to the center, the result value from max(ratio, scale) is less than what it was before. I want the value of max(ratio, scale) to increase, the more the cell is visible on the screen.

Ideally, this is what I'm aiming for:

enter image description here

MRE: ViewController, enums and structs:

struct BannerEntity: Hashable {
    private let id = UUID()
    let bannerTitle: String
    
    init(bannerTitle: String) {
        self.bannerTitle = bannerTitle
    }
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
    
    static func == (lhs: BannerEntity,
                    rhs: BannerEntity) -> Bool {
        return lhs.id == rhs.id
    }
}

struct HomePresentationModel {
    var section: HomeSection
    var items: [HomeSectionItem]
}

enum HomeSectionItem: Hashable {
    case banner(BannerEntity)
}

struct HomeSection: Hashable {
    var header: HomeSectionHeader
    let sectionType: HomeSectionType
}

enum HomeSectionType: Int, Hashable {
    case banner
}

enum HomeSectionHeader: Int, Hashable {
    case tappableHeader
    case empty
}

class ViewController: UIViewController {
    
    // MARK: Subviews
    private var collectionView: UICollectionView!
    
    // MARK: Properties
    private lazy var dataSource = makeDataSource()
    private var sections = [HomePresentationModel(section: .init(header: .empty, sectionType: .banner),
                                                  items: [.banner(.init(bannerTitle: "firstBanner")),
                                                          .banner(.init(bannerTitle: "secondBanner")),
                                                          .banner(.init(bannerTitle: "thirdBanner")),
                                                          .banner(.init(bannerTitle: "fourthBanner"))]
                                                 )]
    
    // MARK: Value Type
    typealias DataSource = UICollectionViewDiffableDataSource<
        HomeSection,
        HomeSectionItem
    >
    typealias Snapshot = NSDiffableDataSourceSnapshot<
        HomeSection,
        HomeSectionItem
    >
    
    // MARK: Viewcycle
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        configureCollectionView()
        applySnapshot()
    }
    
    // MARK: Helpers
    private func configureCollectionView(){
        collectionView = UICollectionView(frame: view.frame,
                                          collectionViewLayout: generateLayout())
        view.addSubview(collectionView)
        collectionView.showsHorizontalScrollIndicator = false
        collectionView.register(BannerCell.self,
                                forCellWithReuseIdentifier: "BannerCell")
    }
    
    private func generateLayout() -> UICollectionViewLayout {
        let layout = UICollectionViewCompositionalLayout {
            [self] (sectionIndex: Int,
                    layoutEnvironment: NSCollectionLayoutEnvironment)
            -> NSCollectionLayoutSection? in
            let sectionType = HomeSectionType(rawValue: sectionIndex)
            
            guard sectionType == .banner else {return nil}
            return self.generateBannersLayout()
        }
        return layout
    }
    
    func generateBannersLayout() -> NSCollectionLayoutSection {
        let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1),
                                              heightDimension: .fractionalHeight(1))
        let item = NSCollectionLayoutItem(layoutSize: itemSize)
        
        let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(0.9),
                                               heightDimension: .fractionalHeight(0.28))
        let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize,
                                                       subitems: [item])
        group.contentInsets = NSDirectionalEdgeInsets(top: 0,
                                                      leading: 0,
                                                      bottom: 0,
                                                      trailing: 0)
        group.interItemSpacing = NSCollectionLayoutSpacing.fixed(0)
        
        let section = NSCollectionLayoutSection(group: group)
        section.contentInsets = NSDirectionalEdgeInsets(top: 10,
                                                        leading: 0,
                                                        bottom: 10,
                                                        trailing: 0)
        section.orthogonalScrollingBehavior = .groupPagingCentered
        
        section.visibleItemsInvalidationHandler = { (items, offset, environment) in
            
            items.forEach { item in
                let frame = item.frame
                let rect = CGRect(x: offset.x, y: offset.y, width: environment.container.contentSize.width, height: frame.height)
                let inter = rect.intersection(frame)
                let ratio = (inter.width * inter.height) / (frame.width * frame.height)
                let scale = ratio > 0.8 ? ratio : 0.8
                UIView.animate(withDuration: 0.2) {
                    item.transform = CGAffineTransform(scaleX: 0.98, y: scale)
                }
            }
        }
        return section
    }
    
    private func makeDataSource() -> DataSource {
        let dataSource = DataSource(
            collectionView: collectionView,
            cellProvider: { (collectionView, indexPath, item) ->
                UICollectionViewCell? in
                let cell = collectionView.dequeueReusableCell(
                    withReuseIdentifier: "BannerCell",
                    for: indexPath) as? BannerCell
                return cell
            })
        
        return dataSource
    }
    
    private func applySnapshot() {
        var snapshot = Snapshot()
        snapshot.appendSections(sections.map({$0.section}))
        sections.forEach { section in
            snapshot.appendItems(section.items, toSection: section.section)
        }
        dataSource.apply(snapshot, animatingDifferences: false)
    }
}

BannerCell:

import UIKit

class BannerCell: UICollectionViewCell {

    // MARK: Init
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupSubviews()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: Helpers
    private func setupSubviews(){
        backgroundColor = .purple
        layer.cornerRadius = 10
    }
}

Solution

  • You're close...

    First, no need for the UIView.animate block. .visibleItemsInvalidationHandler is called for each layout cycle, so it's triggered (effectively) continuously as we scroll the section.

    Second, your height scale calculation is not-quite-right...

    You're getting the intersection (CGRect) of the cell frame with the view frame, so we want the height scale to match the percentage of the width of the intersecting rect. Well, in this case, the percentage of the range between 0.8 and 1.0.

    If you change your .visibleItemsInvalidationHandler to this, it should be what you're going for -- or at least close enough that you can tweak it to your satisfaction:

        section.visibleItemsInvalidationHandler = { (items, offset, environment) in
            items.forEach { item in
                let frame = item.frame
                let rect = CGRect(x: offset.x, y: offset.y, width: environment.container.contentSize.width, height: frame.height)
                let inter = rect.intersection(frame)
                let percent: CGFloat = inter.width / frame.width
                let scale = 0.8 + (0.2 * percent)
                item.transform = CGAffineTransform(scaleX: 0.98, y: scale)
            }
        }