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 }