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)
}
}
}
}
For this problem:
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)
}
}
}