diff --git a/Sources/StreamVideoSwiftUI/CallView/CallView.swift b/Sources/StreamVideoSwiftUI/CallView/CallView.swift index 0235e6cdd..3711b111f 100644 --- a/Sources/StreamVideoSwiftUI/CallView/CallView.swift +++ b/Sources/StreamVideoSwiftUI/CallView/CallView.swift @@ -159,7 +159,6 @@ public struct CallView: View { viewFactory.makeVideoParticipantsView( viewModel: viewModel, availableSize: size, - onViewRendering: handleViewRendering(_:participant:), onChangeTrackVisibility: viewModel.changeTrackVisibility(for:isVisible:) ) } @@ -167,10 +166,4 @@ public struct CallView: View { 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) - } - } } diff --git a/Sources/StreamVideoSwiftUI/CallView/ParticipantsGridView.swift b/Sources/StreamVideoSwiftUI/CallView/ParticipantsGridView.swift index e588c376c..e84f71c64 100644 --- a/Sources/StreamVideoSwiftUI/CallView/ParticipantsGridView.swift +++ b/Sources/StreamVideoSwiftUI/CallView/ParticipantsGridView.swift @@ -8,37 +8,40 @@ import WebRTC @MainActor struct ParticipantsGridView: 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, @@ -57,17 +60,13 @@ struct ParticipantsGridView: 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: View { return width / height } } - + private var size: CGSize { if #available(iOS 14.0, *) { let dividerWidth: CGFloat = isPortrait ? 2 : 3 diff --git a/Sources/StreamVideoSwiftUI/CallView/ParticipantsSpotlightLayout.swift b/Sources/StreamVideoSwiftUI/CallView/ParticipantsSpotlightLayout.swift index 79a5e1d7d..f5c5a76fe 100644 --- a/Sources/StreamVideoSwiftUI/CallView/ParticipantsSpotlightLayout.swift +++ b/Sources/StreamVideoSwiftUI/CallView/ParticipantsSpotlightLayout.swift @@ -53,39 +53,31 @@ public struct ParticipantsSpotlightLayout: 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) diff --git a/Sources/StreamVideoSwiftUI/CallView/VideoParticipantsView.swift b/Sources/StreamVideoSwiftUI/CallView/VideoParticipantsView.swift index 9b4f52900..48714a4b8 100644 --- a/Sources/StreamVideoSwiftUI/CallView/VideoParticipantsView.swift +++ b/Sources/StreamVideoSwiftUI/CallView/VideoParticipantsView.swift @@ -11,7 +11,6 @@ public struct VideoParticipantsView: 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 @@ -20,13 +19,11 @@ public struct VideoParticipantsView: 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 } diff --git a/Sources/StreamVideoSwiftUI/CallView/VisibilityThresholdModifier.swift b/Sources/StreamVideoSwiftUI/CallView/VisibilityThresholdModifier.swift new file mode 100644 index 000000000..7cf76e4ec --- /dev/null +++ b/Sources/StreamVideoSwiftUI/CallView/VisibilityThresholdModifier.swift @@ -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 + ) + ) + } +} diff --git a/Sources/StreamVideoSwiftUI/ViewFactory.swift b/Sources/StreamVideoSwiftUI/ViewFactory.swift index 99b178079..47e4aa38c 100644 --- a/Sources/StreamVideoSwiftUI/ViewFactory.swift +++ b/Sources/StreamVideoSwiftUI/ViewFactory.swift @@ -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 @@ -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 ) } @@ -333,7 +330,7 @@ extension ViewFactory { } public class DefaultViewFactory: ViewFactory { - + private init() { /* Private init. */ } public static let shared = DefaultViewFactory() diff --git a/StreamVideo.xcodeproj/project.pbxproj b/StreamVideo.xcodeproj/project.pbxproj index 0eaa0156c..ed909466b 100644 --- a/StreamVideo.xcodeproj/project.pbxproj +++ b/StreamVideo.xcodeproj/project.pbxproj @@ -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 */; }; @@ -885,6 +886,7 @@ 401A64B02A9DF83200534ED1 /* TokenResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenResponse.swift; sourceTree = ""; }; 401A64B22A9DF86200534ED1 /* URL+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Convenience.swift"; sourceTree = ""; }; 401A64B42A9DF88C00534ED1 /* String+Unique.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Unique.swift"; sourceTree = ""; }; + 401EDBAA2ACD5F1700520215 /* VisibilityThresholdModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisibilityThresholdModifier.swift; sourceTree = ""; }; 402EE12F2AA8861B00312632 /* DemoChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoChatViewModel.swift; sourceTree = ""; }; 4030E59F2A9DF5BD003E8CBA /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = ""; }; 4030E5A12A9DF6B6003E8CBA /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; @@ -2329,6 +2331,7 @@ 846E4AFC29D1DDE8003733AB /* LayoutMenuView.swift */, 846BA2AC2A9F602C001AD0AF /* SampleBufferVideoCallView.swift */, 8430FD212AB1AB4C007AA3E6 /* ParticipantPopoverView.swift */, + 401EDBAA2ACD5F1700520215 /* VisibilityThresholdModifier.swift */, ); path = CallView; sourceTree = ""; @@ -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 */,