Skip to content

Commit

Permalink
Implement VisibilityThresholdModifier
Browse files Browse the repository at this point in the history
To control whenever TrackViews will be taking up rendering tasks
  • Loading branch information
ipavlidakis committed Oct 4, 2023
1 parent 932804f commit 4d8c789
Show file tree
Hide file tree
Showing 7 changed files with 154 additions and 65 deletions.
7 changes: 0 additions & 7 deletions Sources/StreamVideoSwiftUI/CallView/CallView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,18 +159,11 @@ public struct CallView<Factory: ViewFactory>: View {
viewFactory.makeVideoParticipantsView(
viewModel: viewModel,
availableSize: size,
onViewRendering: handleViewRendering(_:participant:),
onChangeTrackVisibility: viewModel.changeTrackVisibility(for:isVisible:)
)
}

private var participants: [CallParticipant] {
viewModel.participants
}

private func handleViewRendering(_ view: VideoRenderer, participant: CallParticipant) {
view.handleViewRendering(for: participant) { size, participant in
viewModel.updateTrackSize(size, for: participant)
}
}
}
53 changes: 26 additions & 27 deletions Sources/StreamVideoSwiftUI/CallView/ParticipantsGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
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))
}
.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,
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,39 +53,31 @@ public struct ParticipantsSpotlightLayout<Factory: ViewFactory>: View {
showAllInfo: true
)
)
.onAppear {
log.debug("Participant \(participant.name) is visible")
onChangeTrackVisibility(participant, true)
}
.modifier(ParticipantChangeModifier(
participant: participant,
onChangeTrackVisibility: onChangeTrackVisibility)
)

ScrollView(.horizontal) {
HorizontalContainer {
ForEach(participants) { participant in
viewFactory.makeVideoParticipantView(
participant: participant,
id: participant.id,
availableSize: .init(width: thumbnailSize, height: thumbnailSize),
contentMode: .scaleAspectFill,
customData: [:],
call: call
)
.onAppear {
onChangeTrackVisibility(participant, true)
}
.onDisappear {
onChangeTrackVisibility(participant, false)
GeometryReader { geometry in
HorizontalContainer {
ForEach(participants) { participant in
viewFactory.makeVideoParticipantView(
participant: participant,
id: participant.id,
availableSize: .init(width: thumbnailSize, height: thumbnailSize),
contentMode: .scaleAspectFill,
customData: [:],
call: call
)
.adjustVideoFrame(to: thumbnailSize, ratio: 1)
.cornerRadius(8)
.accessibility(identifier: "spotlightParticipantView")
}
.adjustVideoFrame(to: thumbnailSize, ratio: 1)
.cornerRadius(8)
.accessibility(identifier: "spotlightParticipantView")
}
.frame(height: thumbnailSize)
.cornerRadius(8)
}
.frame(height: thumbnailSize)
.cornerRadius(8)
}
.padding()
.padding(.bottom)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ public struct VideoParticipantsView<Factory: ViewFactory>: View {
var viewFactory: Factory
@ObservedObject var viewModel: CallViewModel
var availableSize: CGSize
var onViewRendering: (VideoRenderer, CallParticipant) -> Void
var onChangeTrackVisibility: @MainActor(CallParticipant, Bool) -> Void

@State private var orientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation ?? .unknown
Expand All @@ -20,13 +19,11 @@ public struct VideoParticipantsView<Factory: ViewFactory>: View {
viewFactory: Factory,
viewModel: CallViewModel,
availableSize: CGSize,
onViewRendering: @escaping (VideoRenderer, CallParticipant) -> Void,
onChangeTrackVisibility: @escaping @MainActor(CallParticipant, Bool) -> Void
) {
self.viewFactory = viewFactory
self.viewModel = viewModel
self.availableSize = availableSize
self.onViewRendering = onViewRendering
self.onChangeTrackVisibility = onChangeTrackVisibility
}

Expand Down
107 changes: 107 additions & 0 deletions Sources/StreamVideoSwiftUI/CallView/VisibilityThresholdModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//
// 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)
/// Calculate the global minY, maxY, minX, and maxX of the content view.
let minY = geometryInGlobal.minY
let maxY = geometryInGlobal.maxY
let minX = geometryInGlobal.minX
let maxX = geometryInGlobal.maxX

/// Calculate required height and width based on visibility threshold.
let requiredHeight = geometry.size.height * threshold
let requiredWidth = geometry.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)

/// 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
}
)
}
}

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
)
)
}
}
5 changes: 1 addition & 4 deletions Sources/StreamVideoSwiftUI/ViewFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ public protocol ViewFactory: AnyObject {
func makeVideoParticipantsView(
viewModel: CallViewModel,
availableSize: CGSize,
onViewRendering: @escaping (VideoRenderer, CallParticipant) -> Void,
onChangeTrackVisibility: @escaping @MainActor(CallParticipant, Bool) -> Void
) -> ParticipantsViewType

Expand Down Expand Up @@ -195,14 +194,12 @@ extension ViewFactory {
public func makeVideoParticipantsView(
viewModel: CallViewModel,
availableSize: CGSize,
onViewRendering: @escaping (VideoRenderer, CallParticipant) -> Void,
onChangeTrackVisibility: @escaping @MainActor(CallParticipant, Bool) -> Void
) -> some View {
VideoParticipantsView(
viewFactory: self,
viewModel: viewModel,
availableSize: availableSize,
onViewRendering: onViewRendering,
onChangeTrackVisibility: onChangeTrackVisibility
)
}
Expand Down Expand Up @@ -333,7 +330,7 @@ extension ViewFactory {
}

public class DefaultViewFactory: ViewFactory {

private init() { /* Private init. */ }

public static let shared = DefaultViewFactory()
Expand Down
4 changes: 4 additions & 0 deletions StreamVideo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
401A64B12A9DF83200534ED1 /* TokenResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A64B02A9DF83200534ED1 /* TokenResponse.swift */; };
401A64B32A9DF86200534ED1 /* URL+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A64B22A9DF86200534ED1 /* URL+Convenience.swift */; };
401A64B52A9DF88C00534ED1 /* String+Unique.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401A64B42A9DF88C00534ED1 /* String+Unique.swift */; };
401EDBAC2ACD646000520215 /* VisibilityThresholdModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401EDBAA2ACD5F1700520215 /* VisibilityThresholdModifier.swift */; };
4029A6222AB068EC0065DAFB /* CallButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40F445DD2A9E22B9004BE3DA /* CallButtonView.swift */; };
4029A6232AB068FC0065DAFB /* DemoControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40F445EC2A9E2A24004BE3DA /* DemoControls.swift */; };
4029A6242AB069100065DAFB /* DemoChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 402EE12F2AA8861B00312632 /* DemoChatViewModel.swift */; };
Expand Down Expand Up @@ -885,6 +886,7 @@
401A64B02A9DF83200534ED1 /* TokenResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenResponse.swift; sourceTree = "<group>"; };
401A64B22A9DF86200534ED1 /* URL+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Convenience.swift"; sourceTree = "<group>"; };
401A64B42A9DF88C00534ED1 /* String+Unique.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Unique.swift"; sourceTree = "<group>"; };
401EDBAA2ACD5F1700520215 /* VisibilityThresholdModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibilityThresholdModifier.swift; sourceTree = "<group>"; };
402EE12F2AA8861B00312632 /* DemoChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoChatViewModel.swift; sourceTree = "<group>"; };
4030E59F2A9DF5BD003E8CBA /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = "<group>"; };
4030E5A12A9DF6B6003E8CBA /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2329,6 +2331,7 @@
846E4AFC29D1DDE8003733AB /* LayoutMenuView.swift */,
846BA2AC2A9F602C001AD0AF /* SampleBufferVideoCallView.swift */,
8430FD212AB1AB4C007AA3E6 /* ParticipantPopoverView.swift */,
401EDBAA2ACD5F1700520215 /* VisibilityThresholdModifier.swift */,
);
path = CallView;
sourceTree = "<group>";
Expand Down Expand Up @@ -4253,6 +4256,7 @@
84366E7C29C9FB6600287D14 /* VideoRendererFactory.swift in Sources */,
848FE1EF2A9DEAD700B45AC2 /* PiPHandler.swift in Sources */,
843B707529C270C300AB0573 /* ReconnectionView.swift in Sources */,
401EDBAC2ACD646000520215 /* VisibilityThresholdModifier.swift in Sources */,
8406269C2A37A653004B8748 /* CallEventsHandler.swift in Sources */,
8434C529289AA2FA0001490A /* Colors.swift in Sources */,
401480302A5317640029166A /* AudioValuePercentageNormaliser.swift in Sources */,
Expand Down

0 comments on commit 4d8c789

Please sign in to comment.