Skip to content
4 changes: 4 additions & 0 deletions firefox-ios/Client.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,7 @@
96EB6C3827D821B800A9D159 /* HistoryPanelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96EB6C3727D821B800A9D159 /* HistoryPanelViewModel.swift */; };
96EB6C3E27D9266500A9D159 /* HistoryActionables.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96EB6C3D27D9266500A9D159 /* HistoryActionables.swift */; };
96F8DA49280452CA00E53239 /* GleanPlumbContextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F8DA48280452CA00E53239 /* GleanPlumbContextProvider.swift */; };
A2BC4C042E8EF51A00459792 /* ObservableCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2BC4C032E8EF50A00459792 /* ObservableCollectionViewCell.swift */; };
A83E5B1A1C1DA8BF0026D912 /* image.gif in Resources */ = {isa = PBXBuildFile; fileRef = A83E5B181C1DA8BF0026D912 /* image.gif */; };
A83E5B1B1C1DA8BF0026D912 /* image.png in Resources */ = {isa = PBXBuildFile; fileRef = A83E5B191C1DA8BF0026D912 /* image.png */; };
A83E5B1D1C1DA8D80026D912 /* UIPasteboardExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A83E5B1C1C1DA8D80026D912 /* UIPasteboardExtensionsTests.swift */; };
Expand Down Expand Up @@ -9364,6 +9365,7 @@
A26C435BB698CC43D73D3ACA /* bo */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bo; path = bo.lproj/ClearHistoryConfirm.strings; sourceTree = "<group>"; };
A27945ADA7E27D8296CEB772 /* dsb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = dsb; path = dsb.lproj/3DTouchActions.strings; sourceTree = "<group>"; };
A2884783AB373E4A0320E33E /* si */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = si; path = si.lproj/AuthenticationManager.strings; sourceTree = "<group>"; };
A2BC4C032E8EF50A00459792 /* ObservableCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableCollectionViewCell.swift; sourceTree = "<group>"; };
A2C9430F8D3A454B81045BA2 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/ErrorPages.strings; sourceTree = "<group>"; };
A2D6479F8C533A108065AEDC /* si */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = si; path = si.lproj/Storage.strings; sourceTree = "<group>"; };
A2E7448D9546B86303F12903 /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/ClearPrivateData.strings; sourceTree = "<group>"; };
Expand Down Expand Up @@ -12803,6 +12805,7 @@
8A032951288F27CA00AD9B89 /* Cell */ = {
isa = PBXGroup;
children = (
A2BC4C032E8EF50A00459792 /* ObservableCollectionViewCell.swift */,
8AB8571E27D931B40075C173 /* EmptyTopSiteCell.swift */,
3BB50E101D6274CD004B33DF /* TopSiteItemCell.swift */,
);
Expand Down Expand Up @@ -18641,6 +18644,7 @@
6669B5E2211418A200CA117B /* WebsiteDataSearchResultsViewController.swift in Sources */,
D51EA5CF26406D8300334331 /* ExperimentsViewController.swift in Sources */,
210972702DCBA50F001162A2 /* GenericItemCellView.swift in Sources */,
A2BC4C042E8EF51A00459792 /* ObservableCollectionViewCell.swift in Sources */,
ED7A08DB2CF674730035EC8F /* ShareMessage.swift in Sources */,
8CA4E80D2D22C066007207C1 /* GleanUsageReporting.swift in Sources */,
1D05C9832D9CCF720081540C /* RemoteSettingsServiceSyncCoordinator.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Storage
import UIKit

/// The TopSite cell that appears for the homepage rebuild project.
class TopSiteCell: UICollectionViewCell, ReusableCell {
class TopSiteCell: ObservableCollectionViewCell, ReusableCell {
// MARK: - Variables

private var homeTopSite: TopSiteConfiguration?
Expand Down Expand Up @@ -149,6 +149,7 @@ class TopSiteCell: UICollectionViewCell, ReusableCell {
self.theme = theme
homeTopSite = topSite
titleLabel.text = topSite.title
visibilityDebugLabel = topSite.title
accessibilityLabel = topSite.accessibilityLabel
accessibilityTraits = .link

Expand Down Expand Up @@ -249,6 +250,17 @@ class TopSiteCell: UICollectionViewCell, ReusableCell {
guard topSite.isSponsored else { return }

sponsoredLabel.text = topSite.sponsoredText
inViewFractionThreshold = 0.5
visibleTimeThresholdSeconds = 1.0
isVisibilityMonitoringEnabled = true

onAboveInViewThreshold = { [weak self] cell in
print("isVisible", self?.visibilityDebugLabel ?? "Unknown", self?.isInView ?? false)
} // This is an optional callback for whether it is in-view at all (but not above view time threshold)

onAboveVisibleTimeThreshold = { [weak self] cell in
print("Viewed for 1 Second!", self?.visibilityDebugLabel ?? "Unknown", self?.isInView ?? false)
} // This fires when the cell is in-view for a continous time period. It only fires once for the lifetime.
}

// Add insets to favicons with transparent backgrounds
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import UIKit

/**
A cell that can detect and react to changes in “in-view” and “visible” states using area and time thresholds.

We distinguish between "In-view" and "visible" as follows:
- **In-view:**
More than a given fraction (`inViewFractionThreshold`) of the cell’s area is unobscured in the viewport.
- **Visible:**
The cell remains in-view continuously for at least `visibleTimeThresholdSeconds`.
Callbacks and configuration:
- `onAboveInViewThreshold`:
Callback that may fire multiple times over the cell’s lifetime. Called when the cell goes from !inView to inView.
- `onAboveVisibleTimeThreshold`:
Callback that can fire only once over the cell’s lifetime. Called when the cell becomes visible as defined above.
- `inViewFractionThreshold`:
The fraction of the cell’s total area that must be unobscured in the viewport to count as in-view.
- `visibleTimeThresholdSeconds`:
The continuous time (in seconds) the cell must remain in-view to count as visible.
- `isVisibilityMonitoringEnabled`:
When `false`, the cell does not track in-view/visible state and behaves like a normal `UICollectionViewCell`.
*/
class ObservableCollectionViewCell: UICollectionViewCell {
// MARK: Public config
var visibilityDebugLabel = ""
var isVisibilityMonitoringEnabled = false {
didSet {
if isVisibilityMonitoringEnabled {
startObservingIfNeeded()
checkIfCellIsInView()
} else {
stopObserving()
stopVisibilityTimer()
wasPreviouslyInView = false
}
}
}
var inViewFractionThreshold: CGFloat = 0.5
var onAboveInViewThreshold: ((ObservableCollectionViewCell) -> Void)?
var visibleTimeThresholdSeconds: TimeInterval = 1.0
var onAboveVisibleTimeThreshold: ((ObservableCollectionViewCell) -> Void)?

// MARK: In-view State
var isInView: Bool { inViewAreaFraction >= inViewFractionThreshold }
private var wasPreviouslyInView = false

// MARK: Visibility State
private var visibilityTimer: Timer?
private var wasVisibleForThisLifetime = false

// MARK: In-view Fraction Logic
private var observedScrollViews: Set<UIScrollView> = []

private var inViewAreaFraction: CGFloat {
guard let window = window, !isHidden, alpha > 0.01, !bounds.isEmpty else { return 0 }
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I got this logic right... but I would love a sanity check here as I think I've stared at this code for too long

var rect = convert(bounds, to: window).intersection(window.bounds)
if rect.isNull { return 0 }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably also be a guard !rect.isNull else { return 0 } (usually guard is used for this type of early-returning)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah. Nice call!

var a = superview
while let s = a, s !== window {
if s.clipsToBounds {
rect = rect.intersection(s.convert(s.bounds, to: window))
if rect.isNull { return 0 }
}
a = s.superview
}
let total = bounds.width * bounds.height
guard total > 0 else { return 0 }
return max(0, min(1, (rect.width * rect.height) / total))
}

private var scrollViews: [UIScrollView] {
let chain = sequence(first: superview, next: { $0?.superview })
return Array(chain).compactMap { $0 as? UIScrollView }
}

// MARK: Lifecycle
override func prepareForReuse() {
stopObserving()
stopVisibilityTimer()
wasPreviouslyInView = false
wasVisibleForThisLifetime = false
super.prepareForReuse()
}

override func layoutSubviews() {
if isVisibilityMonitoringEnabled {
startObservingIfNeeded()
checkIfCellIsInView()
}
super.layoutSubviews()
}

override func willMove(toWindow newWindow: UIWindow?) {
// If the cell leaves the screen without prepareForReuse() being called we want to make sure we stop the timer
if newWindow == nil {
stopVisibilityTimer()
}
super.willMove(toWindow: newWindow)
}

// MARK: Observing Logic
private func startObservingIfNeeded() {
for sv in scrollViews where !observedScrollViews.contains(sv) && isVisibilityMonitoringEnabled {
sv.addObserver(self, forKeyPath: "contentOffset", context: nil)
observedScrollViews.insert(sv)
}
}

private func stopObserving() {
for sv in observedScrollViews {
sv.removeObserver(self, forKeyPath: "contentOffset")
}
observedScrollViews.removeAll()
}

override func observeValue(forKeyPath keyPath: String?, of object: Any?,
change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
guard isVisibilityMonitoringEnabled, keyPath == "contentOffset" else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
return
}
checkIfCellIsInView()
}

private func checkIfCellIsInView() {
let nowInView = isInView

// Cell coming into view (not visible -> visible)
if !wasPreviouslyInView && nowInView {
onAboveInViewThreshold?(self)
startVisibilityTimerIfNeeded()
}

// Cell leaving view (visible -> not visible)
if wasPreviouslyInView && !nowInView {
stopVisibilityTimer()
}

wasPreviouslyInView = nowInView
}

// MARK: Visibility timer
private func startVisibilityTimerIfNeeded() {
guard visibilityTimer == nil, !wasVisibleForThisLifetime else { return }
let t = Timer(timeInterval: visibleTimeThresholdSeconds, repeats: false) { [weak self] _ in
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe you can use Timer's built-in initializer instead of instantiating it with its constructor directly so you don't need to manually add it to the run loop like:

visibilityTimer = Timer.scheduledTimer(withTimeInterval: visibleTimeThresholdSeconds, repeats: false) { [weak self] _ in
  // do stuff...
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay so this was throwing me for a loop (pardon the pun).

https://developer.apple.com/documentation/foundation/timer

The docs seem to indicate that using scheduledTimer spawns the timer on the current run loop in default mode. But then these docs seem to indicate that we want to be in .common mode so that the timer can still run and fire even when the user is interacting with the UI. (If I'm understanding it correctly)

https://developer.apple.com/documentation/corefoundation/common-mode-flag

I wasn't able to obviously find a way to force the scheduledTimer into the .common mode but it does seem like there should be a way. I'll look into this.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh maybe this line actually will work either way.

RunLoop.main.add(t, forMode: .common). Trying to see if there is an easy way to test this but I think it should work.

guard let self = self else { return }
// Ensure still visible above threshold
if self.isInView && !self.wasVisibleForThisLifetime {
self.wasVisibleForThisLifetime = true
self.onAboveVisibleTimeThreshold?(self)
}
self.stopVisibilityTimer()
}
t.tolerance = visibleTimeThresholdSeconds * 0.1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should add this tolerance. In practice, this will just ensure that it almost always fires later than you intend.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I'm curious of your thoughts here. I was reading this:

https://developer.apple.com/library/archive/documentation/Performance/Conceptual/EnergyGuide-iOS/MinimizeTimerUse.html

And there is a section on recommending that we set tolerances to allow timers to be batched. Maybe this is an over optimization though... I don't really know.

RunLoop.main.add(t, forMode: .common)
visibilityTimer = t
}

private func stopVisibilityTimer() {
visibilityTimer?.invalidate()
visibilityTimer = nil
}
}
Loading