SwiftUI - Audio keeps playing when changing views - swift

I have a problem that's cropped up since I've updated my Xcode... Unless the user hits pause in the audio view. My audio player continues to play when the user changes views however I would like the audio to stop when the user exits the player view (ItemDetail) (for example when the user goes to Content View)
Previously I was using the below at the start of the Content View and that had worked but now it doesn't:
init() { sounds.pauseSounds() }
I've also tried this (which hasn't worked either):
}// end of navigation view .onAppear{sounds.pauseSounds()}
This is my sounds class:
class Sounds:ObservableObject {
var player:AVAudioPlayer?
// let shared = Sounds()
func playSounds(soundfile: String) {
if let path = Bundle.main.path(forResource: soundfile, ofType: nil){
do{
player = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
player?.prepareToPlay()
player?.play()
}catch {
print("Error can't find sound file or something's not working with the sounds model")
}
}
}
// This method used to work at pausing the sound (pre-update)
func pauseSounds() {
player?.pause()
}
// over rides the sound defaulting to bluetooth speaker only and sends it to phone speaker if bluetooth is not available (headset/speaker) however if it is available it'll play through that.
func overRideAudio() {
let audioSession = AVAudioSession.sharedInstance()
do {
try audioSession.setCategory(AVAudioSession.Category.playback, mode: .spokenAudio, options: .defaultToSpeaker)
try audioSession.setActive(true, options: .notifyOthersOnDeactivation)
} catch {
print("error.")
}
}
}
And this is the player view:
struct ItemDetail: View {
#State var isPlaying = false
var item : SessionsItem
#ObservedObject var soundsPlayer = Sounds()
var body: some View {
HStack {
Button(action: {
if self.isPlaying{
// Sounds.player?.pause()
self.isPlaying = false
soundsPlayer.pauseSounds()
}
else{
self.isPlaying = true
soundsPlayer.playSounds(soundfile: "\(self.item.name).mp3")
soundsPlayer.overRideAudio()
}

You are creating another Sound instance so you do not have access to the open audiio instance. You can make Sounds class as single instance or pass the Sounds instance to the detail. Dont create a new one.

onAppear is not a reliable way to do work like this SwiftUI does a lot of preloading (It is very apparent with a List). It depends on the device and its capabilities. Sometimes screens "appear" when they are preloaded not necessarily when the user is looking at it.
With the List example below you can see it in simulator.
On iPhone 8 I get 12 onAppear print statements in the console when I initially load it and on iPhone 12 Pro Max I get 17 print statements.
If the cells are more/less complex you get more/less statements accordingly. You should find another way to do this.
import SwiftUI
struct ListCellAppear: View {
var idx: Int
var body: some View {
HStack{
Text(idx.description)
Image(systemName: "checkmark")
}.onAppear(){
print(idx.description)
}
}
}
struct ListAppear: View {
var body: some View {
List(0..<1000){ idx in
ListCellAppear(idx: idx).frame(height: 300)
}
}
}
struct ListAppear_Previews: PreviewProvider {
static var previews: some View {
ListAppear()
}
}

Related

View don't update in real time when running a cycle

I'm making a card game in SwiftUI and having the following problem: when running a cycle, the view updates only on cycle stop, but don't show any changes when running. UI part of code is:
//on the table
ScrollView(.horizontal) {
HStack(alignment: .center, spacing: 0) {
if game.gameStarted {
ForEach ((0..<game.onTheTable.count), id: \.self) {number in
VStack {
Image(game.onTheTable[number].pic)
.resizable()
.modifier(CardStyle())
Text("\(ai.getPower(card: game.onTheTable[number]))")
}
}
}
}
}
It actually shows card images "on the table" when I move an item to the game.onTheTable array. But when I run a while loop like "while true" it behaves as I mentioned above. So I've created a simple code with a delay to be able to se how card images one by one appears on the table but it just doesn't work as expected. There's the code for the cycle:
func test() {
gameStarted = true
while deck.cardsInDeck.count > 0 {
onTheTable.append(deck.cardsInDeck[0])
deck.cardsInDeck.remove(at: 0)
usleep(100000)
}
}
Yes, it appends cards, but visually I see the result just when the whole cycle has finished. Any ideas how to fix that to see the cards being added in real time one by one?
SwiftUI is declarative, so it doesn't mesh well with imperative control flow like while loops or system timers. You don't have control over when layout happens. Instead, you need to modify the underlying state which is driving the view, and those updates must happen on the main thread.
Here's one approach, which starts the timer when the view appears. You could also trigger the timer based on user interaction.
Note that you can attach transitions to views, and those transitions can take advantage of .matchedGeometryEffect... So you could have cards animate from their position on the deck to their place on the table, and that could happen automatically as you move items from one array to another—as long as the deck and table views use the same namespace and a consistent ID for each unique card.
struct GameView: View {
#State var deckCards: [Card] = Card.standardDeck
#State var tableCards: [Card] = []
#State var timer: Timer? = nil
var body: some View {
VStack {
DeckView(cards: deckCards)
TableView(cards: tableCards)
}.onAppear {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
moveCard()
}
}
}
func moveCard() {
DispatchQueue.main.async {
guard deckCards.count > 0 else {
self.timer?.invalidate()
return
}
tableCards.append(deckCards.removeFirst())
}
}
}

SwiftUI - How to disable sidebar from collapsing?

Gif to understand easier
Is there any way to disable collapsibility of SidebarListStyle NavigationViews?
EDIT: This method still works as of late 2022, and has never stopped working on any version of macOS (up to latest Ventura 13.1). Not sure why there are answers here suggesting otherwise. If the Introspection library changes their API you may need to update your calls accordingly, but the gist of the solution is the same.
Using this SwiftUI Introspection library:
https://github.com/siteline/SwiftUI-Introspect
We can introspect the underlying NSSplitView by extending their functionality:
public func introspectSplitView(customize: #escaping (NSSplitView) -> ()) -> some View {
return introspect(selector: TargetViewSelector.ancestorOrSibling, customize: customize)
}
And then create a generic extension on View:
public extension View {
func preventSidebarCollapse() -> some View {
return introspectSplitView { splitView in
(splitView.delegate as? NSSplitViewController)?.splitViewItems.first?.canCollapse = false
}
}
}
Which can be used on our sidebar:
var body: some View {
(...)
MySidebar()
.preventSidebarCollapse()
}
The introspection library mentioned by Oskar is not working for MacOS.
Inspired by that, I figured out a solution for MacOS.
The rationality behind the solution is to use a subtle way to find out the parent view of a NavigationView which is a NSSplitViewController in the current window.
Below codes was tested on XCode 13.2 and macOS 12.1.
var body: some View {
Text("Replace with your sidebar view")
.onAppear {
guard let nsSplitView = findNSSplitVIew(view: NSApp.windows.first?.contentView), let controller = nsSplitView.delegate as? NSSplitViewController else {
return
}
controller.splitViewItems.first?.canCollapse = false
// set the width of your side bar here.
controller.splitViewItems.first?.minimumThickness = 150
controller.splitViewItems.first?.maximumThickness = 150
}
}
private func findNSSplitVIew(view: NSView?) -> NSSplitView? {
var queue = [NSView]()
if let root = view {
queue.append(root)
}
while !queue.isEmpty {
let current = queue.removeFirst()
if current is NSSplitView {
return current as? NSSplitView
}
for subview in current.subviews {
queue.append(subview)
}
}
return nil
}
While the method that Oskar used with the Introspect library no longer works, I did find another way of preventing the sidebar from collapsing using Introspect. First, you need to make an extension on View:
extension View {
public func introspectSplitView(customize: #escaping (NSSplitView) -> ()) -> some View {
return inject(AppKitIntrospectionView(
selector: { introspectionView in
guard let viewHost = Introspect.findViewHost(from: introspectionView) else {
return nil
}
return Introspect.findAncestorOrAncestorChild(ofType: NSSplitView.self, from: viewHost)
},
customize: customize
))
}
}
Then do the following:
NavigationView {
SidebarView()
.introspectSplitView { controller in
(controller.delegate as? NSSplitViewController)?.splitViewItems.first?.canCollapse = false
}
Text("Main View")
}
This being said, we don't know how long this will actually work for. Apple could change how NavigationView works and this method may stop working in the future.

Sharing Screenshot of SwiftUI view causes crash

I am grabbing a screenshot of a sub-view in my SwiftUI View to immediately pass to a share sheet in order to share the image.
The view is of a set of questions from a text array rendered as a stack of cards. I am trying to get a screenshot of the question and make it share-able along with a link to the app (testing with a link to angry birds).
I have been able to capture the screenshot using basically Asperi's answer to the below question:
How do I render a SwiftUI View that is not at the root hierarchy as a UIImage?
My share sheet launches, and I've been able to use the "Copy" feature to copy the image, so I know it's actually getting a screenshot, but whenever I click "Message" to send it to someone, or if I just leave the share sheet open, the app crashes.
The message says it's a memory issue, but doesn't give much description of the problem. Is there a good way to troubleshoot this sort of thing? I assume it must be something with how the screenshot is being saved in this case.
Here are my extensions of View and UIView to render the image:
extension UIView {
func asImage() -> UIImage {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
return renderer.image { rendererContext in
layer.render(in: rendererContext.cgContext)
}
}
}
extension View {
func asImage() -> UIImage {
let controller = UIHostingController(rootView: self)
// locate far out of screen
controller.view.frame = CGRect(x: 0, y: CGFloat(Int.max), width: 1, height: 1)
UIApplication.shared.windows.first!.rootViewController?.view.addSubview(controller.view)
let size = controller.sizeThatFits(in: UIScreen.main.bounds.size)
controller.view.bounds = CGRect(origin: .zero, size: size)
controller.view.sizeToFit()
controller.view.backgroundColor = .clear
let image = controller.view.asImage()
controller.view.removeFromSuperview()
return image
}
}
Here's an abbreviated version of my view - the button is about halfway down, and should call the private function at the bottom that renders the image from the View/UIView extensions, and sets the "questionScreenShot" variable to the rendered image, which is then presented in the share sheet.
struct TopicPage: View {
var currentTopic: Topic
#State private var currentQuestions: [String]
#State private var showShareSheet = false
#State var questionScreenShot: UIImage? = nil
var body: some View {
GeometryReader { geometry in
Button(action: {
self.questionScreenShot = render()
if self.questionScreenShot != nil {
self.showShareSheet = true
} else {
print("Did not set screenshot")
}
}) {
Text("Share Question").bold()
}
.sheet(isPresented: $showShareSheet) {
ShareSheet(activityItems: [questionScreenShot!])
}
}
}
private func render() -> UIImage {
QuestionBox(currentQuestion: self.currentQuestions[0]).asImage()
}
}
I've found a solution that seems to be working here. I start the variable where the questionScreenShot gets stored as nil to start:
#State var questionScreenShot: UIImage? = nil
Then I just make sure to set it to 'render' when the view appears, which means it loads the UIImage so if the user clicks "Share Question" it will be ready to be loaded (I think there was an issue earlier where the UIImage wasn't getting loaded in time once the sharing was done).
It also sets that variable back to nil on disappear.
.onAppear {
self.currentQuestions = currentTopic.questions.shuffled()
self.featuredQuestion = currentQuestions.last!
self.questionScreenShot = render()
}
.onDisappear {
self.questionScreenShot = nil
self.featuredQuestion = nil
}

Agora SwiftUI OpenLive Integration issues

I'm trying to implement Agora to my existing SwiftUI app, I used this tutorial to implement it https://github.com/AgoraIO/Basic-Video-Call/tree/master/One-to-One-Video/Agora-iOS-Tutorial-SwiftUI-1to1
My Intention is to have a OpenLive video calls where I have only one Broadcaster and many audience to view it (similar to Instagram Live). unfortunately, I only found documentation on how to make a 1-1 video call and I was not able to let Audience join the channel and preview the live video stream.
This is my code:
import SwiftUI
import AgoraRtcKit
var isCurrentStreamer = true
struct AgoraView : View {
#EnvironmentObject var userDefaultData: UserDefaultDetails
#State var isLocalInSession = true
#State var isLocalAudioMuted = false
#State var isRemoteInSession = true
#State var isRemoteVideoMuted = false
let localCanvas = VideoCanvas()
let remoteCanvas = VideoCanvas()
private let videoEngine = VideoEngine()
private var rtcEngine: AgoraRtcEngineKit {
get {
return videoEngine.agoraEngine
}
}
var body: some View {
ZStack() {
VideoSessionView(
backColor: Color("c2"),
backImage: Image("big_logo"),
hideCanvas: false,
canvas: isCurrentStreamer ? localCanvas:remoteCanvas
).edgesIgnoringSafeArea(.all)
}.onAppear {
self.agoraLive(role: self.userDefaultData.AgoraRole, channelID: self.userDefaultData.currentAuctionId)
}
}
}
fileprivate extension AgoraView {
func agoraLive(role: AgoraClientRole, channelID: String){
// init AgoraRtcEngineKit
videoEngine.AgoraView = self
rtcEngine.enableDualStreamMode(false)
rtcEngine.setVideoEncoderConfiguration(
AgoraVideoEncoderConfiguration(
size: AgoraVideoDimension640x360,
frameRate: .fps15,
bitrate: AgoraVideoBitrateStandard,
orientationMode: .adaptative
)
)
if role == .broadcaster {
rtcEngine.enableVideo()
addLocalSession()
rtcEngine.startPreview()
}else{
addLocalSession()
rtcEngine.startPreview()
}
rtcEngine.joinChannel(byToken: Token, channelId: channelID, info: nil, uid: 0, joinSuccess: nil)
// Step 6, set speaker audio route
rtcEngine.setEnableSpeakerphone(true)
}
func addLocalSession() {
let videoCanvas = AgoraRtcVideoCanvas()
if self.userDefaultData.AgoraRole == .broadcaster {
videoCanvas.view = localCanvas.rendererView
}else {
videoCanvas.view = remoteCanvas.rendererView
}
videoCanvas.renderMode = .hidden
if isCurrentStreamer {
rtcEngine.setupLocalVideo(videoCanvas)
}else{
rtcEngine.setupRemoteVideo(videoCanvas)
}
}
}
Thanks.
It looks like you aren't hooking up the remote video view correctly. In order to see a remote user's video, you need to create a AgoraRtcVideoCanvas with that remote user's UID, and then pass it into setupRemoteVideo. What you're doing is trying to set up a remote view before you've even joined the call, and have no idea who is in it.
You need to implement the remoteVideoStateChangedOfUid callback on the AgoraRtcEngineDelegate in order to get the remote user's UID and hook it up to your canvas correctly.
Something like this:
extension AgoraView: AgoraRtcEngineDelegate {
func rtcEngine(_ engine: AgoraRtcEngineKit, remoteVideoStateChangedOfUid uid: UInt, state: AgoraVideoRemoteState, reason: AgoraVideoRemoteStateReason, elapsed: Int) {
if state == .starting {
let videoCanvas = AgoraRtcVideoCanvas()
videoCanvas.view = remoteCanvas.rendererView
videoCanvas.uid = uid
videoCanvas.renderMode = .hidden
rtcEngine.setupRemoteVideo(videoCanvas)
}
}
}
If you did the above as written, you'd have to set the AgoraView as the rtcEngine's delegate when you initialize it, as well.
If you're using the demo app provided, VideoEngine is currently set up as the delegate, and is doing this in firstRemoteVideoDecodedOfUid (which is now deprecated). It's likely that this code is not correctly hooked up to your SwiftUI views.

generic parameter 'FalseContent' could not be inferred

I'm trying to create a 4x4 grid of images, and I'd like it to scale from 1 image up to 4.
This code works when the images provided come from a regular array
var images = ["imageOne", "imageTwo", "imageThree", "imageFour"]
However it does not work if the array comes from an object we are bound to:
#ObjectBinding var images = ImageLoader() //Where our array is in images.images
My initialiser looks like this:
init(imageUrls urls: [URL]){
self.images = ImageLoader(urls)
}
And my ImageLoader class looks like this:
class ImageLoader: BindableObject {
var didChange = PassthroughSubject<ImageLoader, Never>()
var images = [UIImage]() {
didSet{
DispatchQueue.main.async {
self.didChange.send(self)
}
}
}
init(){
}
init(_ urls: [URL]){
for image in urls{
//Download image and append to images array
}
}
}
The problem arises in my View
var body: some View {
return VStack {
if images.images.count == 1{
Image(images.images[0])
.resizable()
} else {
Text("More than one image")
}
}
}
Upon compiling, I get the error generic parameter 'FalseContent' could not be inferred, where FalseContent is part of the SwiftUI buildEither(first:) function.
Again, if images, instead of being a binding to ImageLoader, is a regular array of Strings, it works fine.
I'm not sure what is causing the issue, it seems to be caused by the binding, but I'm not sure how else to do this.
The problem is your Image initialiser, your passing a UIImage, so you should call it like this:
Image(uiImage: images.images[0])
Note that when dealing with views, flow control is a little complicated and error messages can be misleading. By commenting the "else" part of the IF statement of your view, the compiler would have shown you the real reason why it was failing.