-
Notifications
You must be signed in to change notification settings - Fork 21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement VisibilityThresholdModifier #173
Changes from 6 commits
5f06d72
2aeb12d
bb622d1
3bd5ea8
f221811
a423ee4
ab38e14
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,37 +8,40 @@ import WebRTC | |
|
||
@MainActor | ||
struct ParticipantsGridView<Factory: ViewFactory>: View { | ||
|
||
var viewFactory: Factory | ||
var call: Call? | ||
var participants: [CallParticipant] | ||
var availableSize: CGSize | ||
var isPortrait: Bool | ||
var participantVisibilityChanged: (CallParticipant, Bool) -> Void | ||
|
||
var body: some View { | ||
ScrollView { | ||
if #available(iOS 14.0, *) { | ||
LazyVGrid( | ||
columns: [ | ||
.init(.adaptive(minimum: size.width, maximum: size.width), spacing: 0) | ||
], | ||
spacing: 0 | ||
) { | ||
participantsContent | ||
} | ||
.frame(width: availableSize.width) | ||
} else { | ||
VStack { | ||
participantsContent | ||
GeometryReader { geometryProxy in | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems like we might have an extra geometry reader? The There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
We can potentially use only one GeometryReader but that will require a change on the API that will allow us to pass There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would say it's better to break the API at this point, since we are still in beta. Geometry readers can be expensive, best to use them only when needed. Wdyt? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Changing this one I think will incur many changes. How do you feel about having it in another PR? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe another PR towards this PR, to see how much it is? Since if we do breaking changes, we should do them very soon. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A PR to this branch sounds good. Here it is #176 |
||
ScrollView { | ||
if #available(iOS 14.0, *) { | ||
LazyVGrid( | ||
columns: [ | ||
.init(.adaptive(minimum: size.width, maximum: size.width), spacing: 0) | ||
], | ||
spacing: 0 | ||
) { | ||
participantsContent(geometryProxy.frame(in: .global)) | ||
ipavlidakis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
.frame(width: availableSize.width) | ||
} else { | ||
VStack { | ||
participantsContent(geometryProxy.frame(in: .global)) | ||
} | ||
} | ||
} | ||
} | ||
.edgesIgnoringSafeArea(.all) | ||
.accessibility(identifier: "gridScrollView") | ||
} | ||
|
||
private var participantsContent: some View { | ||
|
||
@ViewBuilder | ||
private func participantsContent(_ bounds: CGRect) -> some View { | ||
ForEach(participants) { participant in | ||
viewFactory.makeVideoParticipantView( | ||
participant: participant, | ||
|
@@ -57,17 +60,13 @@ struct ParticipantsGridView<Factory: ViewFactory>: View { | |
showAllInfo: true | ||
) | ||
) | ||
.onAppear { | ||
log.debug("Participant \(participant.name) is visible") | ||
participantVisibilityChanged(participant, true) | ||
} | ||
.onDisappear { | ||
log.debug("Participant \(participant.name) is not visible") | ||
participantVisibilityChanged(participant, false) | ||
.visibilityObservation(in: bounds) { | ||
log.debug("Participant \(participant.name) is \($0 ? "visible" : "not visible.")") | ||
participantVisibilityChanged(participant, $0) | ||
} | ||
} | ||
} | ||
|
||
var ratio: CGFloat { | ||
if isPortrait { | ||
let width = availableSize.width / 2 | ||
|
@@ -79,7 +78,7 @@ struct ParticipantsGridView<Factory: ViewFactory>: View { | |
return width / height | ||
} | ||
} | ||
|
||
private var size: CGSize { | ||
if #available(iOS 14.0, *) { | ||
let dividerWidth: CGFloat = isPortrait ? 2 : 3 | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
// | ||
// Copyright © 2023 Stream.io Inc. All rights reserved. | ||
// | ||
|
||
import StreamVideo | ||
import SwiftUI | ||
|
||
/// The modifier designed to dynamically track and respond to the visibility status of a view within its parent | ||
/// bounds or viewport. It utilises a user-defined visibility threshold, represented as a percentage, to | ||
/// determine how much of the view should be visible (both vertically and horizontally) before it's considered | ||
/// "on screen". | ||
/// | ||
/// When the visibility state of the view changes (i.e., it transitions between being "on screen" and "off screen"), | ||
/// a callback is triggered to notify the user of this change. This can be particularly useful in scenarios where | ||
/// resource management is crucial, such as video playback or dynamic content loading, where actions might | ||
/// be triggered based on whether a view is currently visible to the user. | ||
/// | ||
/// By default, the threshold is set to 30%, meaning 30% of the view's dimensions must be within the parent's | ||
/// bounds for it to be considered visible. | ||
struct VisibilityThresholdModifier: ViewModifier { | ||
/// State to track if the content view is on screen. | ||
@State private var isOnScreen = false { | ||
didSet { | ||
// Check if the visibility state has changed. | ||
guard isOnScreen != oldValue else { return } | ||
// Notify the caller about the visibility state change. | ||
changeHandler(isOnScreen) | ||
} | ||
} | ||
|
||
/// The bounds of the parent view or viewport. | ||
var bounds: CGRect | ||
/// The threshold percentage of the view that must be visible. | ||
var threshold: CGFloat | ||
/// Closure to handle visibility changes. | ||
var changeHandler: (Bool) -> Void | ||
|
||
init(in bounds: CGRect, | ||
threshold: CGFloat, | ||
changeHandler: @escaping (Bool) -> Void | ||
) { | ||
self.bounds = bounds | ||
self.threshold = threshold | ||
self.changeHandler = changeHandler | ||
} | ||
|
||
func body(content: Content) -> some View { | ||
content | ||
.background( | ||
GeometryReader { geometry -> Color in | ||
/// Convert the local frame of the content to a global frame. | ||
let geometryInGlobal = geometry.frame(in: .global) | ||
|
||
let (verticalVisible, horizontalVisible) = calculateVisibilityInBothAxis(in: geometryInGlobal) | ||
|
||
/// Update the isOnScreen state based on visibility calculations. | ||
DispatchQueue.main.async { | ||
self.isOnScreen = verticalVisible && horizontalVisible | ||
} | ||
|
||
/// Use a clear color for the background to not affect the appearance. | ||
return Color.clear | ||
} | ||
) | ||
} | ||
|
||
func calculateVisibilityInBothAxis(in rect: CGRect) -> (verticalVisible: Bool, horizontalVisible: Bool) { | ||
/// Calculate the global minY, maxY, minX, and maxX of the content view. | ||
let minY = rect.minY | ||
let maxY = rect.maxY | ||
let minX = rect.minX | ||
let maxX = rect.maxX | ||
|
||
/// Calculate required height and width based on visibility threshold. | ||
let requiredHeight = rect.size.height * threshold | ||
let requiredWidth = rect.size.width * threshold | ||
|
||
/// Check if the content view is vertically within the parent's bounds. | ||
let verticalVisible = (minY + requiredHeight < bounds.maxY && minY > bounds.minY) || | ||
(maxY - requiredHeight > bounds.minY && maxY < bounds.maxY) | ||
/// Check if the content view is horizontally within the parent's bounds. | ||
let horizontalVisible = (minX + requiredWidth < bounds.maxX && minX > bounds.minX) || | ||
(maxX - requiredWidth > bounds.minX && maxX < bounds.maxX) | ||
|
||
return (verticalVisible, horizontalVisible) | ||
} | ||
} | ||
|
||
extension View { | ||
/// Attaches a visibility observation modifier to the view. | ||
/// | ||
/// - Parameters: | ||
/// - bounds: The bounds of the parent view or viewport within which the visibility of the view will | ||
/// be tracked. | ||
/// - threshold: A percentage value (defaulted to 0.3 or 30%) representing how much of the view | ||
/// should be visible within the `bounds` before it's considered "on screen". | ||
/// - changeHandler: A closure that gets triggered with a Boolean value indicating the visibility | ||
/// state of the view whenever it changes. | ||
/// | ||
/// - Returns: A modified view that observes its visibility status within the specified bounds. | ||
func visibilityObservation( | ||
in bounds: CGRect, | ||
threshold: CGFloat = 0.3, | ||
changeHandler: @escaping (Bool) -> Void | ||
) -> some View { | ||
modifier( | ||
VisibilityThresholdModifier( | ||
in: bounds, | ||
threshold: threshold, | ||
changeHandler: changeHandler | ||
) | ||
) | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it possible to do this without breaking changes to the public API?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I remove this one as it's not being used anywhere. We can definitely go ahead without this change but makes our API confusing as we are asking for something what we won't be using.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Other things to keep in mind when looking on the screenshots (as you may missed the note below them):
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ok, you should sell it better then 😄