6

I want to make all the right side cells of my UICollectionView fade out as they scroll similar to Apple's messages app but not effect the color or transparency of the other cells in the collectionView. Is there a way to adjust the transparency of a UICollectionViewCell based on it's scroll position to achieve that effect?

alionthego
  • 8,508
  • 9
  • 52
  • 125

3 Answers3

7

You can do a lot of fun stuff to collection views. I like to subclass UICollectionViewFlowLayout. Here is an example that fades the top and the bottom of the collection view based on distance from center. I could modify it to fade only the very edges but you should figure it after you look through the code.

import UIKit

class FadingLayout: UICollectionViewFlowLayout,UICollectionViewDelegateFlowLayout {

    //should be 0<fade<1
    private let fadeFactor: CGFloat = 0.5
    private let cellHeight : CGFloat = 60.0

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    init(scrollDirection:UICollectionViewScrollDirection) {
        super.init()
        self.scrollDirection = scrollDirection

    }

    override func prepare() {
        setupLayout()
        super.prepare()
    }

    func setupLayout() {
        self.itemSize = CGSize(width: self.collectionView!.bounds.size.width,height:cellHeight)
        self.minimumLineSpacing = 0
    }

    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        return true
    }

    func scrollDirectionOver() -> UICollectionViewScrollDirection {
        return UICollectionViewScrollDirection.vertical
    }
    //this will fade both top and bottom but can be adjusted
    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        let attributesSuper: [UICollectionViewLayoutAttributes] = super.layoutAttributesForElements(in: rect) as [UICollectionViewLayoutAttributes]!
        if let attributes = NSArray(array: attributesSuper, copyItems: true) as? [UICollectionViewLayoutAttributes]{
            var visibleRect = CGRect()
            visibleRect.origin = collectionView!.contentOffset
            visibleRect.size = collectionView!.bounds.size
            for attrs in attributes {
                if attrs.frame.intersects(rect) {
                    let distance = visibleRect.midY - attrs.center.y
                    let normalizedDistance = abs(distance) / (visibleRect.height * fadeFactor)
                    let fade = 1 - normalizedDistance
                    attrs.alpha = fade
                }
            }
            return attributes
        }else{
            return nil
        }
    }
    //appear and disappear at 0
    override func initialLayoutAttributesForAppearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        let attributes = super.layoutAttributesForItem(at: itemIndexPath)! as UICollectionViewLayoutAttributes
        attributes.alpha = 0
        return attributes
    }

    override func finalLayoutAttributesForDisappearingItem(at itemIndexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
        let attributes = super.layoutAttributesForItem(at: itemIndexPath)! as UICollectionViewLayoutAttributes
        attributes.alpha = 0
        return attributes
    }
}

And in your setup in your controller with the collection view it would look like this.

let layout = FadingLayout(scrollDirection: .vertical)
collectionView.delegate = self
collectionView.dataSource = self
self.collectionView.setCollectionViewLayout(layout, animated: false)

I can tell you how to modify it if I knew the use case a bit better.

agibson007
  • 4,173
  • 2
  • 19
  • 24
  • Is it possible to fade only when user start scrolling? Also, when the user gets to the end of the scrollView the bottom cell to be without fade? – Adrian Macarenco Oct 09 '20 at 10:37
4

This is quite simple if you subclass UICollectionViewFlowLayout. First thing you'll need to do is make sure the visible attributes are recalculated when bounds change/scroll happens by returning true in

shouldInvalidateLayout(forBoundsChange newBounds: CGRect)

Then in layoutAttributesForElements(in rect: CGRect) delegate call, get the attributes calculated by the super class and modify the alpha value based on the offset of the item in the visible bounds, thats it. Distinguishing between left/right side items can be handled in the controller with whatever logic you have and communicated to the layout class to avoid applying this effect on left side items. (I used ´CustomLayoutDelegate´ for that which is implemented in the controller that simply identifies items with odd indexPath.row as left side cells)

Here is a demo that applies this effect on items with with even indexPath.row skipping odd rows

import UIKit

class ViewController: UIViewController {

    /// Custom flow layout
    lazy var layout: CustomFlowLayout = {
        let l: CustomFlowLayout = CustomFlowLayout()
        l.itemSize = CGSize(width: self.view.bounds.width / 1.5, height: 100)
        l.delegate = self

        return l
    }()

    /// The collectionView if you're not using UICollectionViewController
    lazy var collectionView: UICollectionView = {
        let cv: UICollectionView = UICollectionView(frame: self.view.bounds, collectionViewLayout: self.layout)
        cv.backgroundColor = UIColor.lightGray

        cv.register(UICollectionViewCell.self, forCellWithReuseIdentifier: "Cell")
        cv.dataSource = self

        return cv
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(collectionView)
    }

}

extension ViewController: UICollectionViewDataSource, CustomLayoutDelegate {

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 30
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath)
        cell.backgroundColor = UIColor.black

        return cell
    }

    // MARK: CustomLayoutDelegate

    func cellSide(forIndexPath indexPath: IndexPath) -> CellSide {

        // TODO: Your implementation to distinguish left/right indexPath

        // Even rows are .right and Odds .left
        if indexPath.row % 2 == 0 {
            return .right
        } else {
            return .left
        }
    }
}

public enum CellSide {
    case right
    case left
}

protocol CustomLayoutDelegate: class {

    func cellSide(forIndexPath indexPath: IndexPath) -> CellSide
}

class CustomFlowLayout: UICollectionViewFlowLayout {

    /// Delegates distinguishing between left and right items
    weak var delegate: CustomLayoutDelegate!

    /// Maximum alpha value
    let kMaxAlpha: CGFloat = 1

    /// Minimum alpha value. The alpha value you want the first visible item to have
    let kMinAlpha: CGFloat = 0.3

    override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
        guard let cv = collectionView, let rectAtts = super.layoutAttributesForElements(in: rect) else { return nil }

        for atts in rectAtts {

            // Skip left sides
            if delegate.cellSide(forIndexPath: atts.indexPath) == .left {
                continue
            }

            // Offset Y on visible bounds. you can use
            //      ´cv.bounds.height - (atts.frame.origin.y - cv.contentOffset.y)´
            // To reverse the effect
            let offset_y = (atts.frame.origin.y - cv.contentOffset.y)

            let alpha = offset_y * kMaxAlpha / cv.bounds.height

            atts.alpha = alpha + kMinAlpha
        }

        return rectAtts
    }

    // Invalidate layout when scroll happens. Otherwise atts won't be recalculated
    override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
        return true
    }

}
Lukas
  • 3,423
  • 2
  • 14
  • 26
1

Sure! Note that UICollectionView is a subclass of UIScrollView, and that your UICollectionViewController is already the delegate of the collection view. This means that it also conforms to the UIScrollViewDelegate protocol, which includes a bunch of methods to inform you about scroll position changes.

Most notable to me is scrollViewDidScroll(_:), which will be called when the contentOffset in the collection view changes. You might implement that method to iterate over the collection view's visibleCells, either adjusting the cell's alpha yourself or sending some message to the cell to notify it to adjust its own alpha based on its frame and offset.

The simplest possible implementation I could come up with that does this – respecting your right-side-only requirement – is as follows. Note that this might exhibit some glitches near the top or the bottom of the view, since the cell's alpha is only adjusted on scroll, not on initial dequeue or reuse.

class FadingCollectionViewController: UICollectionViewController {

    override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 500
    }

    override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath)
        return cell
    }

    override func scrollViewDidScroll(_ scrollView: UIScrollView) {
        guard let collectionView = collectionView else {
            return
        }

        let offset = collectionView.contentOffset.y
        let height = collectionView.frame.size.height
        let width = collectionView.frame.size.width
        for cell in collectionView.visibleCells {
            let left = cell.frame.origin.x
            if left >= width / 2 {
                let top = cell.frame.origin.y
                let alpha = (top - offset) / height
                cell.alpha = alpha
            } else {
                cell.alpha = 1
            }
        }
    }

}
Tim
  • 59,527
  • 19
  • 156
  • 165