diff --git a/firefox-ios/Client.xcodeproj/project.pbxproj b/firefox-ios/Client.xcodeproj/project.pbxproj index 64a977cdc0f24..bff9198c24ff0 100644 --- a/firefox-ios/Client.xcodeproj/project.pbxproj +++ b/firefox-ios/Client.xcodeproj/project.pbxproj @@ -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 */; }; @@ -9364,6 +9365,7 @@ A26C435BB698CC43D73D3ACA /* bo */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bo; path = bo.lproj/ClearHistoryConfirm.strings; sourceTree = ""; }; A27945ADA7E27D8296CEB772 /* dsb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = dsb; path = dsb.lproj/3DTouchActions.strings; sourceTree = ""; }; A2884783AB373E4A0320E33E /* si */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = si; path = si.lproj/AuthenticationManager.strings; sourceTree = ""; }; + A2BC4C032E8EF50A00459792 /* ObservableCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableCollectionViewCell.swift; sourceTree = ""; }; A2C9430F8D3A454B81045BA2 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/ErrorPages.strings; sourceTree = ""; }; A2D6479F8C533A108065AEDC /* si */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = si; path = si.lproj/Storage.strings; sourceTree = ""; }; A2E7448D9546B86303F12903 /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/ClearPrivateData.strings; sourceTree = ""; }; @@ -12803,6 +12805,7 @@ 8A032951288F27CA00AD9B89 /* Cell */ = { isa = PBXGroup; children = ( + A2BC4C032E8EF50A00459792 /* ObservableCollectionViewCell.swift */, 8AB8571E27D931B40075C173 /* EmptyTopSiteCell.swift */, 3BB50E101D6274CD004B33DF /* TopSiteItemCell.swift */, ); @@ -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 */, diff --git a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSiteCell.swift b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSiteCell.swift index 639e605ecd251..b5aa11791b04b 100644 --- a/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSiteCell.swift +++ b/firefox-ios/Client/Frontend/Home/Homepage Rebuild/TopSites/TopSiteCell.swift @@ -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? @@ -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 @@ -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 diff --git a/firefox-ios/Client/Frontend/Home/TopSites/Cell/ObservableCollectionViewCell.swift b/firefox-ios/Client/Frontend/Home/TopSites/Cell/ObservableCollectionViewCell.swift new file mode 100644 index 0000000000000..63edf6756a646 --- /dev/null +++ b/firefox-ios/Client/Frontend/Home/TopSites/Cell/ObservableCollectionViewCell.swift @@ -0,0 +1,177 @@ +// 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 = [] + + private var inViewAreaFraction: CGFloat { + guard let window = window, !isHidden, alpha > 0.01, !bounds.isEmpty else { return 0 } + + // The cell's location with respect to the window's coordinate system + var visibleRectInWindow = convert(bounds, to: window).intersection(window.bounds) + guard !visibleRectInWindow.isNull else { return 0 } + + // We need to verify that there aren't views clipping and obscuring this view. + // If they are, we need to subtract that intersection away for our final visible area calc. + // Everything is done with respect to the window's coordnates. + var currentAncestor: UIView? = superview + while let ancestorView = currentAncestor, ancestorView !== window { + if ancestorView.clipsToBounds { + let ancestorClipRect = ancestorView.convert(ancestorView.bounds, to: window) + visibleRectInWindow = visibleRectInWindow.intersection(ancestorClipRect) + if visibleRectInWindow.isNull { return 0 } + } + currentAncestor = ancestorView.superview + } + + let totalArea = bounds.width * bounds.height + guard totalArea > 0 else { return 0 } + let visibleArea = visibleRectInWindow.width * visibleRectInWindow.height + + return max(0, min(1, visibleArea / totalArea)) + } + + 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 + 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 + RunLoop.main.add(t, forMode: .common) + visibilityTimer = t + } + + private func stopVisibilityTimer() { + visibilityTimer?.invalidate() + visibilityTimer = nil + } +}