damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

VisibilityTracker.swift (7298B)


      1 //
      2 //  VisibilityTracker.swift
      3 //  damus
      4 //
      5 //  Created by Daniel D’Aquino on 2024-03-18.
      6 // 
      7 //  Based on code examples shown in this article: https://medium.com/@jackvanderpump/how-to-detect-is-an-element-is-visible-in-swiftui-9ff58ca72339
      8 
      9 import Foundation
     10 import SwiftUI
     11 
     12 extension View {
     13     /// Watches for visibility changes. Does not detect occlusion
     14     /// 
     15     /// ## Usage notes
     16     /// 
     17     /// 1. Detection mechanisms are not perfect, parameters may need fine tuning. Please refer to `VisibilityTracker` documentation for more details.
     18     /// 2. This does **not** detect if the view has been occluded. There are currently no known mechanisms to do that.
     19     ///   If occlusion tracking is needed for your usage, consider using layout knowledge/introspection of the different layers that make up the view, and using that information for your logic.
     20     ///   For example, when dealing with items on a normal view, and a full screen cover, write your logic based on explicit information about which views are in the full screen layer.
     21     ///   Read about `present(full_screen_item: FullScreenItem)`, `damus_full_screen_cover`, and the `.view_layer_context` environment variable.
     22     ///
     23     /// - Parameters:
     24     ///   - visibility_change_notifier: Function to call once visibility changes
     25     ///   - edge: Edge for the visibility overlay sensor
     26     ///   - method: The method to use for visibility tracking. Refer to `VisibilityTracker` documentation for more details.
     27     /// - Returns: A modified view.
     28     func on_visibility_change(perform visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment = .center, method: VisibilityTracker.Method = .standard) -> some View {
     29         self.modifier(VisibilityTracker(visibility_change_notifier: visibility_change_notifier, edge: edge, method: method))
     30     }
     31 }
     32 
     33 
     34 /// Tracks visibility of a SwiftUI view.
     35 /// Built mostly to track visibility states of video players around the app and help the video coordinator pick a video to focus on, but can be used for basically any other view
     36 /// **Caution:** This is not a perfect tracker, please read and fine-tune parameters for your use case, especially `method`
     37 struct VisibilityTracker: ViewModifier {
     38     let visibility_window: CGFloat = 0.8
     39     let visibility_change_notifier: (Bool) -> Void
     40     let edge: Alignment
     41     let method: Method
     42     
     43     init(visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment, method: Method) {
     44         self.visibility_change_notifier = visibility_change_notifier
     45         self.edge = edge
     46         self.method = method
     47     }
     48     
     49     @EnvironmentObject private var orientationTracker: OrientationTracker
     50     /// Holds information about whether the view is "generically" visible, meaning whether it would have been loaded on a lazy stack.
     51     @State private var generic_visible: Bool = false {
     52         didSet {
     53             if oldValue == generic_visible { return }  // Save up computing resources if there were no changes
     54             self.visibility_change_notifier(self.is_visible)
     55         }
     56     }
     57     /// Whether the view is visible by checking if its Y position is within a range of the user's screen
     58     @State private var y_scroll_visible: Bool = false {
     59         didSet {
     60             switch self.method {
     61                 case .standard:
     62                     if oldValue == y_scroll_visible { return }  // Save up computing resources if there were no changes
     63                     self.visibility_change_notifier(self.is_visible)
     64                 case .no_y_scroll_detection:
     65                     return  // Don't cause re-renders if the visibility method does not use this
     66             }
     67         }
     68     }
     69     /// Whether view is "visible"
     70     var is_visible: Bool {
     71         switch method {
     72             case .standard:
     73                 return generic_visible && y_scroll_visible
     74             case .no_y_scroll_detection:
     75                 return generic_visible
     76         }
     77     }
     78 
     79     func body(content: Content) -> some View {
     80         content
     81           .overlay(
     82             GeometryReader { geo in
     83                 let localFrame = geo.frame(in: .local)
     84                 let centerY = globalCoordinate(localX: 0, localY: localFrame.midY, localGeometry: geo).y
     85                 LazyVStack {
     86                     Color.clear
     87                         // MARK: Detection triggers
     88                         .onAppear {
     89                             self.generic_visible = true
     90                             self.y_scroll_visible = self.compute_y_scroll_visible(centerY: centerY)
     91                         }
     92                         .onDisappear {
     93                             self.generic_visible = false
     94                         }
     95                         .onChange(of: centerY) { new_center_y in
     96                             if generic_visible == false { return }  // Don't bother calculating anything if this is not visible generically, to save up computing resources
     97                             self.y_scroll_visible = self.compute_y_scroll_visible(
     98                                 centerY: new_center_y   // Compute the new Y scroll visibility using the newest value to avoid transient issues on device orientation changes
     99                             )
    100                         }
    101                 }
    102             },
    103             alignment: edge)
    104     }
    105     
    106     /// Computes whether the view is "visible" in a range of the screen given its Y position
    107     private func compute_y_scroll_visible(centerY: CGFloat) -> Bool {
    108         let screen_center_y = orientationTracker.deviceMajorAxis / 2
    109         let screen_visibility_window_margin = orientationTracker.deviceMajorAxis * visibility_window / 2
    110         let isBelowTop = centerY > screen_center_y - screen_visibility_window_margin,
    111             isAboveBottom = centerY < screen_center_y + screen_visibility_window_margin
    112         return (isBelowTop && isAboveBottom)
    113     }
    114     
    115     /// The methods available for visibility detection.
    116     /// Unfortunately, there is currently no perfect visibility detection mechanism, so callers of `VisibilityTracker` should select a method that best suits the context of the view.
    117     enum Method: Equatable {
    118         /// Includes both a generic and Y coordinate based visibility detection.
    119         /// When this option is selected, the view is only deemed visible if both lazy view evaluators load it (when close enough to viewport), and the center Y coordinate is sufficiently in the center
    120         /// This is best for most view presentations, specially for scroll views.
    121         case standard
    122         /// Includes only a generic visibility detection based on a lazy view loader
    123         /// When this option is selected, the view is only deemed visible if the lazy view evaluators load it (which SwiftUI does when it is close enough to viewport), regardless of Y coordinate
    124         /// This is not suitable for scroll views or most presentations because it may trigger too early, leading to false positives. This is more suitable when the standard detection mechanism is triggering too many false negatives, and this is a more "static" view
    125         /// For example: when displaying an item in full screen mode where it is visible in a more stable, static form, and device orientation changes may cause transient visibility triggers
    126         case no_y_scroll_detection
    127     }
    128 }