Search code examples
iosswiftanalyticsnstimercollectionview

How to track a CollectionView cell by time in Swift


I've been working on a feature to detect when a user sees a post and when he doesn't. When the user does see the post I turn the cell's background into green, when it doesn't then it stays red. Now after doing that I notice that I turn on all the cells into green even tho the user only scroll-down the page, so I added a timer but I couldn't understand how to use it right so I thought myself maybe you guys have a suggestion to me cause I'm kinda stuck with it for like two days :(

  • Edit: Forgot to mention that a cell marks as seen if it passes the minimum length which is 2 seconds.

Here's my Code: My VC(CollectionView):

import UIKit

class ViewController: UIViewController,UIScrollViewDelegate {
    
    var impressionEventStalker: ImpressionStalker?
    var impressionTracker: ImpressionTracker?
    
    var indexPathsOfCellsTurnedGreen = [IndexPath]() // All the read "posts"
    
    
    @IBOutlet weak var collectionView: UICollectionView!{
        didSet{
            collectionView.contentInset = UIEdgeInsets(top: 20, left: 0, bottom: 0, right: 0)
            impressionEventStalker = ImpressionStalker(minimumPercentageOfCell: 0.70, collectionView: collectionView, delegate: self)

        }
    }
    
    
    
    func registerCollectionViewCells(){
        let cellNib = UINib(nibName: CustomCollectionViewCell.nibName, bundle: nil)
        collectionView.register(cellNib, forCellWithReuseIdentifier: CustomCollectionViewCell.reuseIdentifier)
    }
    
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        collectionView.delegate = self
        collectionView.dataSource = self
        
        registerCollectionViewCells()
        
    }
    
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
        impressionEventStalker?.stalkCells()
        
    }
    
    
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        impressionEventStalker?.stalkCells()
    }
    
}

// MARK: CollectionView Delegate + DataSource Methods
extension ViewController: UICollectionViewDelegateFlowLayout, UICollectionViewDataSource{
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return 100
    }
    
    
    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        
        guard let customCell = collectionView.dequeueReusableCell(withReuseIdentifier: CustomCollectionViewCell.reuseIdentifier, for: indexPath) as? CustomCollectionViewCell else {
            fatalError()
        }
        
        customCell.textLabel.text = "\(indexPath.row)"
        
        if indexPathsOfCellsTurnedGreen.contains(indexPath){
            customCell.cellBackground.backgroundColor = .green
        }else{
            customCell.cellBackground.backgroundColor = .red
        }
        
        return customCell
    }
    
    
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return CGSize(width: 150, height: 225)
    }
    
    
    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
        return UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) // Setting up the padding
    }
    
    
    func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        //Start The Clock:
        if let trackableCell = cell as? TrackableView {
            trackableCell.tracker = ImpressionTracker(delegate: trackableCell)
            trackableCell.tracker?.start()
            
        }
    }
    
    
    func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        //Stop The Clock:
        (cell as? TrackableView)?.tracker?.stop()
    }
    
    
    
}


// MARK: - Delegate Method:
extension ViewController:ImpressionStalkerDelegate{
    func sendEventForCell(atIndexPath indexPath: IndexPath) {
        
        guard let customCell = collectionView.cellForItem(at: indexPath) as? CustomCollectionViewCell else {
            return
        }
        

        customCell.cellBackground.backgroundColor = .green
        indexPathsOfCellsTurnedGreen.append(indexPath) // We append all the visable Cells into an array
    }
}

my ImpressionStalker:

import Foundation
import UIKit

protocol ImpressionStalkerDelegate:NSObjectProtocol {
    func sendEventForCell(atIndexPath indexPath:IndexPath)
}

protocol ImpressionItem {
    func getUniqueId()->String
}


class ImpressionStalker: NSObject {
    
    //MARK: Variables & Constants
    let minimumPercentageOfCell: CGFloat
    weak var collectionView: UICollectionView?
    
    static var alreadySentIdentifiers = [String]()
    weak var delegate: ImpressionStalkerDelegate?
    
    
    //MARK: Initializer
    init(minimumPercentageOfCell: CGFloat, collectionView: UICollectionView, delegate:ImpressionStalkerDelegate ) {
            self.minimumPercentageOfCell = minimumPercentageOfCell
            self.collectionView = collectionView
            self.delegate = delegate
        }
    
    
    // Checks which cell is visible:
    func stalkCells() {
        for cell in collectionView!.visibleCells {
            if let visibleCell = cell as? UICollectionViewCell & ImpressionItem {
                let visiblePercentOfCell = percentOfVisiblePart(ofCell: visibleCell, inCollectionView: collectionView!)
                
                if visiblePercentOfCell >= minimumPercentageOfCell,!ImpressionStalker.alreadySentIdentifiers.contains(visibleCell.getUniqueId()){ // >0.70 and not seen yet then...
                    guard let indexPath = collectionView!.indexPath(for: visibleCell), let delegate = delegate else {
                        continue
                    }
                    
                   
                    delegate.sendEventForCell(atIndexPath: indexPath) // send the cell's index since its visible.
                    
                    ImpressionStalker.alreadySentIdentifiers.append(visibleCell.getUniqueId()) // to avoid double events to show up.
                }
            }
        }
    }
    
    
    // Func Which Calculate the % Of Visible of each Cell:
    private func percentOfVisiblePart(ofCell cell:UICollectionViewCell, inCollectionView collectionView:UICollectionView) -> CGFloat{
           
           guard let indexPathForCell = collectionView.indexPath(for: cell),
               let layoutAttributes = collectionView.layoutAttributesForItem(at: indexPathForCell) else {
                   return CGFloat.leastNonzeroMagnitude
           }
           
           let cellFrameInSuper = collectionView.convert(layoutAttributes.frame, to: collectionView.superview)
           
           let interSectionRect = cellFrameInSuper.intersection(collectionView.frame)
           let percentOfIntersection: CGFloat = interSectionRect.height/cellFrameInSuper.height
           
           return percentOfIntersection
       }
}

ImpressionTracker:

import Foundation
import UIKit

protocol ViewTracker {

    init(delegate: TrackableView)
    func start()
    func pause()
    func stop()
    
}


final class ImpressionTracker: ViewTracker {
    private weak var viewToTrack: TrackableView?
        
    private var timer: CADisplayLink?
    private var startedTimeStamp: CFTimeInterval = 0
    private var endTimeStamp: CFTimeInterval = 0
    
     init(delegate: TrackableView) {
        viewToTrack = delegate
        setupTimer()
    }
    
    
    func setupTimer() {
        timer = (viewToTrack as? UIView)?.window?.screen.displayLink(withTarget: self, selector: #selector(update))
        timer?.add(to: RunLoop.main, forMode: .default)
        timer?.isPaused = true
    }
    
    
    func start() {
        guard viewToTrack != nil else { return }
        timer?.isPaused = false
        startedTimeStamp = CACurrentMediaTime() // Current Time in seconds.
    }
    
    func pause() {
        guard viewToTrack != nil else { return }
        timer?.isPaused = true
        endTimeStamp = CACurrentMediaTime()
        print("Im paused!")
    }
    
    func stop() {
        timer?.isPaused = true
        timer?.invalidate()
    }
    
    @objc func update() {
        guard let viewToTrack = viewToTrack else {
            stop()
            return
        }

        guard viewToTrack.precondition() else {
            startedTimeStamp = 0
            endTimeStamp = 0
            return
        }

        endTimeStamp = CACurrentMediaTime()
        trackIfThresholdCrossed()
    }
    
    
    private func trackIfThresholdCrossed() {
       
        guard let viewToTrack = viewToTrack else { return }
        let elapsedTime = endTimeStamp - startedTimeStamp
        if  elapsedTime >= viewToTrack.thresholdTimeInSeconds() {
            viewToTrack.viewDidStayOnViewPortForARound()
            

            startedTimeStamp = endTimeStamp
        }
    }
}

my customCell:

import UIKit


protocol TrackableView: NSObject {
    var tracker: ViewTracker? { get set }
    func thresholdTimeInSeconds() -> Double //Takes care of the screen's time, how much "second" counts.
    func viewDidStayOnViewPortForARound() // Counter for how long the "Post" stays on screen.
    func precondition() -> Bool // Checks if the View is full displayed so the counter can go on fire.
}


class CustomCollectionViewCell: UICollectionViewCell {
    var tracker: ViewTracker?
    
    static let nibName = "CustomCollectionViewCell"
    static let reuseIdentifier = "customCell"
    
    @IBOutlet weak var cellBackground: UIView!
    @IBOutlet weak var textLabel: UILabel!
    
    var numberOfTimesTracked : Int = 0 {
        didSet {
            self.textLabel.text = "\(numberOfTimesTracked)"
        }
    }

    override func awakeFromNib() {
        super.awakeFromNib()
        cellBackground.backgroundColor = .red
        layer.borderWidth = 0.5
        layer.borderColor = UIColor.lightGray.cgColor
    }
    
    
    
    override func prepareForReuse() {
        super.prepareForReuse()
        print("Hello")
        tracker?.stop()
        tracker = nil

    }
}

extension CustomCollectionViewCell: ImpressionItem{
    func getUniqueId() -> String {
        return self.textLabel.text!
    }
}


extension CustomCollectionViewCell: TrackableView {
    func thresholdTimeInSeconds() -> Double { // every 2 seconds counts as a view.
        return 2
    }
    
    
    func viewDidStayOnViewPortForARound() {
        numberOfTimesTracked += 1 // counts for how long the view stays on screen.
    }
    
    
    
    func precondition() -> Bool {
        let screenRect = UIScreen.main.bounds
        let viewRect = convert(bounds, to: nil)
        let intersection = screenRect.intersection(viewRect)
        return intersection.height == bounds.height && intersection.width == bounds.width
    }
}

Solution

  • The approach you probably want to use...

    In you posted code, you've created an array of "read posts":

    var indexPathsOfCellsTurnedGreen = [IndexPath]() // All the read "posts"
    

    Assuming your real data will have multiple properties, such as:

    struct TrackPost {
        var message: String = ""
        var postAuthor: String = ""
        var postDate: Date = Date()
        // ... other stuff
    }
    

    add another property to track whether or not it has been "seen":

    struct TrackPost {
        var message: String = ""
        var postAuthor: String = ""
        var postDate: Date = Date()
        // ... other stuff
    
        var hasBeenSeen: Bool = false
    }
    

    Move all of your "tracking" code out of the controller, and instead add a Timer to your cell class.

    When the cell appears:

    • if hasBeenSeen for that cell's Data is false
      • start a 2-second timer
      • if the timer elapses, the cell has been visible for 2 seconds, so set hasBeenSeen to true (use a closure or protocol / delegate pattern to tell the controller to update the data source) and change the backgroundColor
      • if the cell is scrolled off-screen before the timer elapses, stop the timer and don't tell the controller anything
    • if hasBeenSeen is true to begin with, don't start the 2-second timer

    Now, your cellForItemAt code will look something like this:

        let p: TrackPost = myData[indexPath.row]
        
        customCell.authorLabel.text = p.postAuthor
        customCell.dateLabel.text = myDateFormat(p.postDate) // formatted as a string
        customCell.textLabel.text = p.message
        
        // setting hasBeenSeen in your cell should also set the backgroundColor
        //  and will be used so the cell knows whether or not to start the timer
        customCell.hasBeenSeen = p.hasBeenSeen
        
        // this will be called by the cell if the timer elapsed
        customCell.wasSeenCallback = { [weak self] in
            guard let self = self else { return }
            self.myData[indexPath.item].hasBeenSeen = true
        }