commit 409be7fc58de0636ea70104aff83290d59bb79c7
parent 1bc660c9cd1e7beb5110d97d22341b34abbac905
Author: Daniel D’Aquino <daniel@daquino.me>
Date: Fri, 1 Nov 2024 18:39:44 -0700
Refactor visibility tracker
This commit moves all logic related to visibility tracking into a single
view modifier for better code reusability.
Furthermore, the modified VisibilityTracker component was more
extensively documented, for better awareness of its limitations and
usage.
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Diffstat:
2 files changed, 130 insertions(+), 65 deletions(-)
diff --git a/damus/Views/Extensions/VisibilityTracker.swift b/damus/Views/Extensions/VisibilityTracker.swift
@@ -10,27 +10,119 @@ import Foundation
import SwiftUI
extension View {
- func on_visibility_change(perform visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment = .center) -> some View {
- self.modifier(VisibilityTracker(visibility_change_notifier: visibility_change_notifier, edge: edge))
+ /// Watches for visibility changes. Does not detect occlusion
+ ///
+ /// ## Usage notes
+ ///
+ /// 1. Detection mechanisms are not perfect, parameters may need fine tuning. Please refer to `VisibilityTracker` documentation for more details.
+ /// 2. This does **not** detect if the view has been occluded. There are currently no known mechanisms to do that.
+ /// 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.
+ /// 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.
+ /// Read about `present(full_screen_item: FullScreenItem)`, `damus_full_screen_cover`, and the `.view_layer_context` environment variable.
+ ///
+ /// - Parameters:
+ /// - visibility_change_notifier: Function to call once visibility changes
+ /// - edge: Edge for the visibility overlay sensor
+ /// - method: The method to use for visibility tracking. Refer to `VisibilityTracker` documentation for more details.
+ /// - Returns: A modified view.
+ func on_visibility_change(perform visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment = .center, method: VisibilityTracker.Method = .standard) -> some View {
+ self.modifier(VisibilityTracker(visibility_change_notifier: visibility_change_notifier, edge: edge, method: method))
}
}
+
+/// Tracks visibility of a SwiftUI view.
+/// 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
+/// **Caution:** This is not a perfect tracker, please read and fine-tune parameters for your use case, especially `method`
struct VisibilityTracker: ViewModifier {
+ let visibility_window: CGFloat = 0.8
let visibility_change_notifier: (Bool) -> Void
let edge: Alignment
+ let method: Method
+
+ init(visibility_change_notifier: @escaping (Bool) -> Void, edge: Alignment, method: Method) {
+ self.visibility_change_notifier = visibility_change_notifier
+ self.edge = edge
+ self.method = method
+ }
+
+ @EnvironmentObject private var orientationTracker: OrientationTracker
+ /// Holds information about whether the view is "generically" visible, meaning whether it would have been loaded on a lazy stack.
+ @State private var generic_visible: Bool = false {
+ didSet {
+ if oldValue == generic_visible { return } // Save up computing resources if there were no changes
+ self.visibility_change_notifier(self.is_visible)
+ }
+ }
+ /// Whether the view is visible by checking if its Y position is within a range of the user's screen
+ @State private var y_scroll_visible: Bool = false {
+ didSet {
+ switch self.method {
+ case .standard:
+ if oldValue == y_scroll_visible { return } // Save up computing resources if there were no changes
+ self.visibility_change_notifier(self.is_visible)
+ case .no_y_scroll_detection:
+ return // Don't cause re-renders if the visibility method does not use this
+ }
+ }
+ }
+ /// Whether view is "visible"
+ var is_visible: Bool {
+ switch method {
+ case .standard:
+ return generic_visible && y_scroll_visible
+ case .no_y_scroll_detection:
+ return generic_visible
+ }
+ }
func body(content: Content) -> some View {
- content
- .overlay(
- LazyVStack {
- Color.clear
- .onAppear {
- visibility_change_notifier(true)
- }
- .onDisappear {
- visibility_change_notifier(false)
- }
- },
- alignment: edge)
+ content
+ .overlay(
+ GeometryReader { geo in
+ let localFrame = geo.frame(in: .local)
+ let centerY = globalCoordinate(localX: 0, localY: localFrame.midY, localGeometry: geo).y
+ LazyVStack {
+ Color.clear
+ // MARK: Detection triggers
+ .onAppear {
+ self.generic_visible = true
+ self.y_scroll_visible = self.compute_y_scroll_visible(centerY: centerY)
+ }
+ .onDisappear {
+ self.generic_visible = false
+ }
+ .onChange(of: centerY) { new_center_y in
+ if generic_visible == false { return } // Don't bother calculating anything if this is not visible generically, to save up computing resources
+ self.y_scroll_visible = self.compute_y_scroll_visible(
+ centerY: new_center_y // Compute the new Y scroll visibility using the newest value to avoid transient issues on device orientation changes
+ )
+ }
+ }
+ },
+ alignment: edge)
+ }
+
+ /// Computes whether the view is "visible" in a range of the screen given its Y position
+ private func compute_y_scroll_visible(centerY: CGFloat) -> Bool {
+ let screen_center_y = orientationTracker.deviceMajorAxis / 2
+ let screen_visibility_window_margin = orientationTracker.deviceMajorAxis * visibility_window / 2
+ let isBelowTop = centerY > screen_center_y - screen_visibility_window_margin,
+ isAboveBottom = centerY < screen_center_y + screen_visibility_window_margin
+ return (isBelowTop && isAboveBottom)
+ }
+
+ /// The methods available for visibility detection.
+ /// 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.
+ enum Method: Equatable {
+ /// Includes both a generic and Y coordinate based visibility detection.
+ /// 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
+ /// This is best for most view presentations, specially for scroll views.
+ case standard
+ /// Includes only a generic visibility detection based on a lazy view loader
+ /// 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
+ /// 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
+ /// 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
+ case no_y_scroll_detection
}
}
diff --git a/damus/Views/Video/DamusVideoPlayer.swift b/damus/Views/Video/DamusVideoPlayer.swift
@@ -39,63 +39,36 @@ struct DamusVideoPlayer: View {
}
var body: some View {
- GeometryReader { geo in
- let localFrame = geo.frame(in: .local)
- let centerY = globalCoordinate(localX: 0, localY: localFrame.midY, localGeometry: geo).y
- ZStack {
- if case .full = self.style {
- DamusAVPlayerView(player: model.player, controller: model.player_view_controller, show_playback_controls: true)
- }
- if case .preview(let on_tap) = self.style {
- DamusAVPlayerView(player: model.player, controller: model.player_view_controller, show_playback_controls: false)
- .simultaneousGesture(TapGesture().onEnded({
- on_tap?()
- }))
- }
-
- if model.is_loading {
- ProgressView()
- .progressViewStyle(.circular)
- .tint(.white)
- .scaleEffect(CGSize(width: 1.5, height: 1.5))
- }
-
- if case .preview = self.style {
- if model.has_audio {
- mute_button
- }
- }
- if model.is_live {
- live_indicator
- }
+ ZStack {
+ if case .full = self.style {
+ DamusAVPlayerView(player: model.player, controller: model.player_view_controller, show_playback_controls: true)
}
- .onChange(of: centerY) { _ in
- if case .y_scroll = visibility_tracking_method {
- update_is_visible(centerY: centerY)
- }
+ if case .preview(let on_tap) = self.style {
+ DamusAVPlayerView(player: model.player, controller: model.player_view_controller, show_playback_controls: false)
+ .simultaneousGesture(TapGesture().onEnded({
+ on_tap?()
+ }))
}
- .on_visibility_change(perform: { new_visibility in
- if case .generic = visibility_tracking_method {
- model.set_view_is_visible(new_visibility)
- }
- })
- .onAppear {
- if case .y_scroll = visibility_tracking_method {
- update_is_visible(centerY: centerY)
+
+ if model.is_loading {
+ ProgressView()
+ .progressViewStyle(.circular)
+ .tint(.white)
+ .scaleEffect(CGSize(width: 1.5, height: 1.5))
+ }
+
+ if case .preview = self.style {
+ if model.has_audio {
+ mute_button
}
}
- }
- .onDisappear {
- if case .y_scroll = visibility_tracking_method {
- model.view_did_disappear()
+ if model.is_live {
+ live_indicator
}
}
- }
-
- private func update_is_visible(centerY: CGFloat) {
- let isBelowTop = centerY > 100, /// 100 =~ approx. bottom (y) of ContentView's TabView
- isAboveBottom = centerY < orientationTracker.deviceMajorAxis
- model.set_view_is_visible(isBelowTop && isAboveBottom)
+ .on_visibility_change(perform: { new_visibility in
+ model.set_view_is_visible(new_visibility)
+ }, method: self.visibility_tracking_method == .generic ? .no_y_scroll_detection : .standard)
}
private var mute_icon: String {