iosswifttrackingswiftui-listimpressions

Impression Tracking in iOS


I want to achieve this:

Impression Tracking in iOS using SwiftUI.

How do I do it?

I am using List to show the feeds. If the user spent 3 seconds on the card, I need to update the viewed count. I want the feeds the user spent 3 secs. If he scrolls fast, I don't want those feeds. I tried to achieve this way :

    struct TestUIList: View {
    @ObservedObject var presenter: Presenter
   
    var body: some View {
        List{
            if #available(iOS 14.0, *) {
                LazyVStack {
                    ForEach(presenter.feeds.indices,id: \.self) { feedIndex in
                        let feed = presenter.feeds[feedIndex]
                        CardView(delegate: presenter, feed: feed, index: feedIndex)
                    }
                }
            } else {
                // Fallback on earlier versions
            }
        }
    }
}
struct CardView: View {
    weak var delegate: CardViewToPresenterProtocol?
    let feed: Feed
    let index: Int
    
    var body: some View{
        ZStack{
            GeometryReader{ reader in
                RoundedRectangle(cornerRadius: 8)
                    .fill(Color.green)
                    .valueChanged(value: reader.frame(in: CoordinateSpace.global).maxY, onChange: { _ in
                        print("onChange")
                        DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3, execute: {
                            let maxY = reader.frame(in: CoordinateSpace.global).maxY
                            let screenMaxY = UIScreen.main.bounds.maxY
                            let screenMinY = UIScreen.main.bounds.minY
                            if !feed.isVisible  {
                                print("\(index) After 3 sec", maxY, screenMaxY)
                                if (maxY > screenMinY) && (maxY <= screenMaxY) {
                                    print("\(index) cell became visible ")
                                    delegate?.visibilityChanged(visibilityStatus: !feed.isVisible, id: feed.id)
                                }
                            }
                        })
                    })
                    .onAppear(perform: {
                        print("onAppear")
                      
                            DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3, execute: {
                                let maxY = reader.frame(in: CoordinateSpace.global).maxY
                                let screenMaxY = UIScreen.main.bounds.maxY
                                let screenMinY = UIScreen.main.bounds.minY
                                if !feed.isVisible {
                                    print("\(index) After 3 sec", maxY, screenMaxY)
                                    if maxY > screenMinY && maxY <= screenMaxY  {
                                        print("\(index) cell became visible")
                                        delegate?.visibilityChanged(visibilityStatus: !feed.isVisible, id: feed.id)
                                        
                                    }
                                
                            })
                        }
                    })
                
                VStack {
                    Text("\(feed.viewedCount) viewed")
                        .font(.system(size: 12))
                    Text("reader MaxY = \(reader.frame(in: CoordinateSpace.global).maxY)")
                    Text("screen maxy = \(UIScreen.main.bounds.maxY)")
                    Text("screen miny = \(UIScreen.main.bounds.minY)")
                }
                
            }
        }.frame( height: 200)
            .onTapGesture {
                print("Card Tapped")
            }
        
    }
}

    extension View {
    /// A backwards compatible wrapper for iOS 14 `onChange`
    @ViewBuilder func valueChanged<T: Equatable>(value: T, onChange: @escaping (T) -> Void) -> some View {
        if #available(iOS 14.0, *) {
            self.onChange(of: value, perform: onChange)
        } else {
            self.onReceive(Just(value)) { (value) in
                onChange(value)
            }
        }
    }
}


Solution

  • For this problem:

    1. We need the visible in the UI; by using Geometry reader, we can achieve visible cells
    2. After getting the visible cells, we need to add a timer, as we want the user to have spent some time on visible cells; in my case added a five second timer
    3. Timer starts when the user stops scrolling; if they are scrolling, the timer will be invalidated (stops)
    4. Check this link when the scrollview has finished scrolling SwiftUI - Detect when ScrollView has finished scrolling?
    5. Once the timer completes five seconds, use a delegate and protocol to update the feed viewed count

    Check the code below

    Model

    struct Feed: Identifiable {
        var id = UUID()
        var viewedCount: Int = 0
        var viewed = false 
         func dummyArray() -> [Feed] {
             var array = [Feed]()
             for _ in 1...100 {
                 array.append(Feed())
             }
             return array
        }
    }
    

    Feeds List View

    struct TestScrollView: View {
        @ObservedObject var presenter: Presenter
        @State private var scrolling: Bool = false
        let detector: CurrentValueSubject<CGFloat, Never>
        let publisher: AnyPublisher<CGFloat, Never>
       
         init(presenter: Presenter) {
            self.presenter = presenter
            let detector = CurrentValueSubject<CGFloat, Never>(0)
            self.publisher = detector
                .debounce(for: .seconds(0.2), scheduler: DispatchQueue.main)
                .dropFirst()
                .eraseToAnyPublisher()
            self.detector = detector
        }
        var body: some View {
        ScrollView{
            GeometryReader { reader in
                Rectangle()
                    .frame(width: 0, height: 0)
                    .valueChanged(value: reader.frame(in: .global).origin.y) { offset in
                        if !scrolling {
                            scrolling = true
                            print("scrolling")
                            presenter.isScrolling = true
                        }
                        detector.send(offset)
                    }
                    .onReceive(publisher) { _ in
                        scrolling = false
                        print("not scrolling")
                        presenter.isScrolling = false
                    }
            }
            ZStack {
                LazyVStack{
                    ForEach(presenter.feeds.indices, id: \.self) { 
                        feedIndex in
                            let feed = presenter.feeds[feedIndex]
                            ScrollCardView(feed: feed, index: feedIndex,delegate: presenter,isScrolling: $isScrolling)
                        } 
    
                        Spacer()
                    }.padding([.leading, .trailing,.top], 10)
                }
            }
        }
    }
    

    CardView

    struct ScrollCardView: View {
        let feed: Feed
        let index: Int
        weak var delegate: CardViewToPresenterProtocol?
        @State private var timer: Timer?
        @Binding var isScrolling: Bool
        @State var maxY: CGFloat = 0
        
        var body: some View {
            ZStack{
                RoundedRectangle(cornerRadius: 8)
                    .fill(Color.green)
                    
                VStack(alignment: .leading) {
                    Text("feed \(index)")
                    Text("reader maxY: \(maxY)")
                    Text("screen maxY: \(UIScreen.main.bounds.maxY)")
                }
            }.frame(height: 200)
                .onDisappear(perform: {
                    self.timer?.invalidate()
                })
                .background(GeometryReader{ reader in
                    Color.clear
                        .onAppear(perform: {
                            print("onAppear")
                            self.maxY = reader.frame(in: CoordinateSpace.global).maxY
                            let screenMaxY = UIScreen.main.bounds.maxY
                            if isScrolling == false {
                                executeSomeTask(screenMaxY: screenMaxY)
                            }else {
                        timer?.invalidate()
                    }
                        })
                        .valueChanged(value: isScrolling, onChange: { value in
                            let screenMaxY = UIScreen.main.bounds.maxY
                            if value == false {
                                executeSomeTask(screenMaxY: screenMaxY)
                            }else {
                                timer?.invalidate()
                            }
                        })
                        .onReceive(Just(reader.frame(in: CoordinateSpace.global).maxY)) { value in
                            self.maxY = value
                        }
                })
        }
        
    
        private func executeSomeTask(screenMaxY: CGFloat) {
            if (maxY > 200) && (maxY <= screenMaxY) && !(feed.viewed) {
                timer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false, block: { _ in
                    print(maxY, screenMaxY)
                    if (maxY > 200) && (maxY <= screenMaxY) {
                        print(maxY, screenMaxY)
                        if !(feed.viewed) {
                            self.delegate?.visibilityChanged(visibilityStatus: true, index: index)
                        }
                    }else {
                        timer?.invalidate()
                    }
                })
            }else{
                timer?.invalidate()
            }
        }
    }
    

    Presenter Class

    class Presenter: ObservableObject,CardViewToPresenterProtocol {
        
        @Published var feeds = Feed().dummyArray()
        @Published var isScrolling: Bool = false
        @Published  var visibleRows = [Int: Bool]()
       
         func visibilityChanged(visibilityStatus: Bool,index: Int) {
            self.feeds[index].viewed = visibilityStatus
            if visibleRows[index] == nil {
                self.visibleRows[index] = visibilityStatus
            }
            print("visible rows",visibleRows)
            Task {
                let finishedApiCall = await self.apiCall(feeds: Array(visibleRows.keys))
                print("After api call", finishedApiCall)
            }
        }
        //performing some network operation
        func apiCall(feeds: [Int]) async -> [Int] {
            //Do required stuff here
        }
    }
    
    /// A backwards compatible wrapper for iOS 14 `onChange`
       
    
         @ViewBuilder func valueChanged<T: Equatable>(value: T, onChange: @escaping (T) -> Void) -> some View {
            if #available(iOS 14.0, *) {
                self.onChange(of: value, perform: onChange)
            } else {
                self.onReceive(Just(value)) { (value) in
                    onChange(value)
                }
            }
        }