Struggling with NotificationCenter/Combine in SwiftUI/AVPlayer - swift

I'm trying to pause my AVPlayer when the item finishes playing. What is the best way to do this with SwiftUI? I don't know much about notifications, where to declare them, etc. Is there a way to use Combine for this? Sample code would be awesome! Thank you in advance.
UPDATE:
With help from the answer below, I managed to make a class which takes an AVPlayer and publishes a notification when the item ends. You can subscribe to the notification with the following:
Class:
import Combine
import AVFoundation
class PlayerFinishedObserver {
let publisher = PassthroughSubject<Void, Never>()
init(player: AVPlayer) {
let item = player.currentItem
var cancellable: AnyCancellable?
cancellable = NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: item).sink { [weak self] change in
self?.publisher.send()
print("Gotcha")
cancellable?.cancel()
}
}
}
Add to your struct:
let finishedObserver: PlayerFinishedObserver
Subscribe on some View:
.onReceive(finishedObserver.publisher) {
print("Gotcha!")
}

I found one solution for similar issue:
I created the new subclass of AVPlayer;
Added observer to currentItem;
Override func observeValue, where add observer for current item when player reach end time;
Here is simplified example:
import AVKit // for player
import Combine // for observing and adding as environmentObject
final class AudioPlayer: AVPlayer, ObservableObject {
var songDidEnd = PassthroughSubject<Void, Never>() // you can use it in some View with .onReceive function
override init() {
super.init()
registerObserves()
}
private func registerObserves() {
self.addObserver(self, forKeyPath: "currentItem", options: [.new], context: nil)
// example of using
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
// currentItem could be nil in the player. I add observer to exist item
if keyPath == "currentItem", let item = currentItem {
NotificationCenter.default.addObserver(self, selector: #selector(playerDidFinishPlaying(_:)), name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: item)
// another way, using Combine
var cancellable: AnyCancellable?
cancellable = NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime, object: item).sink { [weak self] _ in
self?.songDidEnd.send()
cancellable?.cancel()
}
}
// other observers
}
#objc private func playerDidFinishPlaying(_ notification: Notification) {
playNextSong() // my implementation, here you can just write: "self.pause()"
}
}
UPDATE: simple example of using .onReceive (be careful, I wrote it without playground/Xcode, so it can have errors):
struct ContentView: View {
#EnvironmentObject var audioPlayer: AudioPlayer
#State private var someText: String = "song is playing"
var body: some View {
Text(someText)
.onReceive(self.audioPlayer.songDidEnd) { // maybe you need "_ in" here
self.handleSongDidEnd()
}
}
private func handleSongDidEnd() {
print("song did end")
withAnimation {
someText = "song paused"
}
}
}
About Combine with AVPlayer: you can look at my question, there you'll see some ways to observe playing time and functionality to rewind time with slider in SwiftUI.
I'm using one instance of AudioPlayer, controlling play/pause functions or changing currentItem (which means setting another song) like this:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// other staff
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let homeView = ContentView()
.environmentObject(AudioPlayer())
// other staff of SceneDelegate
}
}

Related

Setup UserDefaults property as Published property in View Model [duplicate]

I have an #ObservedObject in my View:
struct HomeView: View {
#ObservedObject var station = Station()
var body: some View {
Text(self.station.status)
}
which updates text based on a String from Station.status:
class Station: ObservableObject {
#Published var status: String = UserDefaults.standard.string(forKey: "status") ?? "OFFLINE" {
didSet {
UserDefaults.standard.set(status, forKey: "status")
}
}
However, I need to change the value of status in my AppDelegate, because that is where I receive my Firebase Cloud Messages:
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: #escaping (UIBackgroundFetchResult) -> Void) {
// If you are receiving a notification message while your app is in the background,
// this callback will not be fired till the user taps on the notification launching the application.
// Print full message.
let rawType = userInfo["type"]
// CHANGE VALUE OF status HERE
}
But if I change the status UserDefaults value in AppDelegate - it won't update in my view.
How can my #ObservedObjectin my view be notified when status changes?
EDIT: Forgot to mention that the 2.0 beta version of SwiftUI is used in the said example.
Here is possible solution
import Combine
// define key for observing
extension UserDefaults {
#objc dynamic var status: String {
get { string(forKey: "status") ?? "OFFLINE" }
set { setValue(newValue, forKey: "status") }
}
}
class Station: ObservableObject {
#Published var status: String = UserDefaults.standard.status {
didSet {
UserDefaults.standard.status = status
}
}
private var cancelable: AnyCancellable?
init() {
cancelable = UserDefaults.standard.publisher(for: \.status)
.sink(receiveValue: { [weak self] newValue in
guard let self = self else { return }
if newValue != self.status { // avoid cycling !!
self.status = newValue
}
})
}
}
Note: SwiftUI 2.0 allows you to use/observe UserDefaults in view directly via AppStorage, so if you need that status only in view, you can just use
struct SomeView: View {
#AppStorage("status") var status: String = "OFFLINE"
...
I would suggest you to use environment object instead or a combination of both of them if required. Environment objects are basically a global state objects. Thus if you change a published property of your environment object it will reflect your view. To set it up you need to pass the object to your initial view through SceneDelegate and you can work with the state in your whole view hierarchy. This is also the way to pass data across very distant sibling views (or if you have more complex scenario).
Simple Example
In your SceneDelegate.swift:
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let contentView = ContentView().environmentObject(GlobalState())
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible()
}
}
The global state should conform ObservableObject. You should put your global variables in there as #Published.
class GlobalState: ObservableObject {
#Published var isLoggedIn: Bool
init(isLoggedIn : Bool) {
self.isLoggedIn = isLoggedIn
}
}
Example of how you publish a variable, not relevant to the already shown example in SceneDelegate
This is then how you can work with your global state inside your view. You need to inject it with the #EnvironmentObject wrapper like this:
struct ContentView: View {
#EnvironmentObject var globalState: GlobalState
var body: some View {
Text("Hello World")
}
}
Now in your case you want to also work with the state in AppDelegate. In order to do this I would suggest you safe the global state variable in your AppDelegate and access it from there in your SceneDelegate before passing to the initial view. To achieve this you should add the following in your AppDelegate:
var globalState : GlobalState!
static func shared() -> AppDelegate {
return UIApplication.shared.delegate as! AppDelegate
}
Now you can go back to your SceneDelegate and do the following instead of initialising GlobalState directly:
let contentView = ContentView().environmentObject(AppDelegate.shared().globalState)

Using a class inside a struct is giving an error: "partial application of 'mutating' method is not allowed"

I am creating a class inside a struct to create a timer that sends information between an Apple Watch and the paired phone. When trying to run the timer with a button the error:
Partial application of the 'mutating' method is not allowed
The way I'm creating the class is the following:
import SwiftUI
struct ContentView: View {
//Timer to send information to phone
var timerLogic: TimerLogic!
var body: some View {
Button(action: startTimer, //"partial application of 'mutating' method is not allowed"
label: {
Image(systemName: "location")
})
}
// Class with the timer logic
class TimerLogic {
var structRef: ContentView!
var timer: Timer!
init(_ structRef: ContentView) {
self.structRef = structRef
self.timer = Timer.scheduledTimer(
timeInterval: 3.0,
target: self,
selector: #selector(timerTicked),
userInfo: nil,
repeats: true)
}
func stopTimer() {
self.timer?.invalidate()
self.structRef = nil
}
// Function to run with each timer tick
#objc private func timerTicked() {
self.structRef.timerTicked()
}
}
mutating func startTimer() {
self.timerLogic = TimerLogic(self)
}
// Function to run with each timer tick, this can be any action
func timerTicked() {
let data = ["latitude": "\(location.coordinate.latitude)", "longitud": "\(location.coordinate.longitude)"]
connectivity.sendMessage(data)
}
}
The closest solution that might solve the error is this one or is there another one?
SwiftUI Views should not have mutating properties/functions. Instead, they should use property wrappers like #State and #StateObject for state.
Besides the mutating function, you're fighting the principals of SwiftUI a bit. For example, you should never try to keep a reference to a View and call a function on it. Views are transient in SwiftUI and should not be expected to exist again if you need to call back to them. Also, SwiftUI tends to go hand-in-hand with Combine, which would be a good fit for implementing in your Timer code.
This might be a reasonable refactor:
import SwiftUI
import Combine
// Class with the timer logic
class TimerLogic : ObservableObject {
#Published var timerEvent : Timer.TimerPublisher.Output?
private var cancellable: AnyCancellable?
func startTimer() {
cancellable = Timer.publish(every: 3.0, on: RunLoop.main, in: .default)
.autoconnect()
.sink { event in
self.timerEvent = event
}
}
func stopTimer() {
cancellable?.cancel()
}
}
struct ContentView: View {
//Timer to send information to phone
#StateObject var timerLogic = TimerLogic()
var body: some View {
Button(action: timerLogic.startTimer) {
Image(systemName: "location")
}
.onChange(of: timerLogic.timerEvent) { _ in
timerTicked()
}
}
// Function to run with each timer tick, this can be any action
func timerTicked() {
print("Timer ticked...")
//...
}
}

Using UIApplicationDelegateAdaptor to get callbacks from userDidAcceptCloudKitShareWith not working

I'm trying to get notified when userDidAcceptCloudKitShareWith gets called. Traditionally this was called in the App Delegate but since I am building an iOS 14+ using App as my root object. I couldn't find any documentation out yet as far as how to add userDidAcceptCloudKitShareWith to my App class, so I am using UIApplicationDelegateAdaptor to use an App Delegate class, however it doesn't seem like userDidAcceptCloudKitShareWith is ever getting called?
import SwiftUI
import CloudKit
// Our observable object class
class ShareDataStore: ObservableObject {
static let shared = ShareDataStore()
#Published var didRecieveShare = false
#Published var shareInfo = ""
}
#main
struct SocialTestAppApp: App {
#StateObject var shareDataStore = ShareDataStore.shared
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView().environmentObject(shareDataStore)
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
let container = CKContainer(identifier: "iCloud.com.TestApp")
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
print("did finish launching called")
return true
}
func application(_ application: UIApplication, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
print("delegate callback called!! ")
acceptShare(metadata: cloudKitShareMetadata) { result in
switch result {
case .success(let recordID):
print("successful share!")
ShareDataStore.shared.didRecieveShare = true
ShareDataStore.shared.shareInfo = recordID.recordName
case .failure(let error):
print("failure in share = \(error)")
}
} }
func acceptShare(metadata: CKShare.Metadata,
completion: #escaping (Result<CKRecord.ID, Error>) -> Void) {
// Create a reference to the share's container so the operation
// executes in the correct context.
let container = CKContainer(identifier: metadata.containerIdentifier)
// Create the operation using the metadata the caller provides.
let operation = CKAcceptSharesOperation(shareMetadatas: [metadata])
var rootRecordID: CKRecord.ID!
// If CloudKit accepts the share, cache the root record's ID.
// The completion closure handles any errors.
operation.perShareCompletionBlock = { metadata, share, error in
if let _ = share, error == nil {
rootRecordID = metadata.rootRecordID
}
}
// If the operation fails, return the error to the caller.
// Otherwise, return the record ID of the share's root record.
operation.acceptSharesCompletionBlock = { error in
if let error = error {
completion(.failure(error))
} else {
completion(.success(rootRecordID))
}
}
// Set an appropriate QoS and add the operation to the
// container's queue to execute it.
operation.qualityOfService = .utility
container.add(operation)
}
}
Updated based on Asperi's Answer:
import SwiftUI
import CloudKit
class ShareDataStore: ObservableObject {
static let shared = ShareDataStore()
#Published var didRecieveShare = false
#Published var shareInfo = ""
}
#main
struct athlyticSocialTestAppApp: App {
#StateObject var shareDataStore = ShareDataStore.shared
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
let sceneDelegate = MySceneDelegate()
var body: some Scene {
WindowGroup {
ContentView().environmentObject(shareDataStore)
.withHostingWindow { window in
sceneDelegate.originalDelegate = window.windowScene.delegate
window.windowScene.delegate = sceneDelegate
}
}
}
}
class MySceneDelegate: NSObject, UIWindowSceneDelegate {
let container = CKContainer(identifier: "iCloud.com...")
var originalDelegate: UIWindowSceneDelegate?
var window: UIWindow?
func sceneWillEnterForeground(_ scene: UIScene) {
print("scene is active")
}
func sceneWillResignActive(_ scene: UIScene) {
print("scene will resign active")
}
// forward all other UIWindowSceneDelegate/UISceneDelegate callbacks to original, like
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
originalDelegate?.scene!(scene, willConnectTo: session, options: connectionOptions)
}
func windowScene(_ windowScene: UIWindowScene, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShare.Metadata) {
print("delegate callback called!! ")
acceptShare(metadata: cloudKitShareMetadata) { result in
switch result {
case .success(let recordID):
print("successful share!")
ShareDataStore.shared.didRecieveShare = true
ShareDataStore.shared.shareInfo = recordID.recordName
case .failure(let error):
print("failure in share = \(error)")
}
}
}
}
extension View {
func withHostingWindow(_ callback: #escaping (UIWindow?) -> Void) -> some View {
self.background(HostingWindowFinder(callback: callback))
}
}
struct HostingWindowFinder: UIViewRepresentable {
var callback: (UIWindow?) -> ()
func makeUIView(context: Context) -> UIView {
let view = UIView()
DispatchQueue.main.async { [weak view] in
self.callback(view?.window)
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
}
}
In Scene-based application the userDidAcceptCloudKitShareWith callback is posted to Scene delegate, but in SwiftUI 2.0 App-based application the scene delegate is used by SwiftUI itself to provide scenePhase events, but does not provide native way to handle topic callback.
The possible approach to solve this is to find a window and inject own scene delegate wrapper, which will handle userDidAcceptCloudKitShareWith and forward others to original SwiftUI delegate (to keep standard SwiftUI events working).
Here is a couple of demo snapshots based on https://stackoverflow.com/a/63276688/12299030 window access (however you can use any other preferable way to get window)
#main
struct athlyticSocialTestAppApp: App {
#StateObject var shareDataStore = ShareDataStore.shared
#UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
let sceneDelegate = MySceneDelegate()
var body: some Scene {
WindowGroup {
ContentView().environmentObject(shareDataStore)
.withHostingWindow { window in
sceneDelegate.originalDelegate = window?.windowScene.delegate
window?.windowScene.delegate = sceneDelegate
}
}
}
}
class MySceneDelegate : NSObject, UIWindowSceneDelegate {
var originalDelegate: UISceneDelegate?
func windowScene(_ windowScene: UIWindowScene, userDidAcceptCloudKitShareWith cloudKitShareMetadata: CKShareMetadata) {
// your code here
}
// forward all other UIWindowSceneDelegate/UISceneDelegate callbacks to original, like
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
originalDelegate?.scene(scene, willConnectTo: session, options: connectionOptions)
}
}
Check out this question that has a lot of useful things to check across several possible answers:
CloudKit CKShare userDidAcceptCloudKitShareWith Never Fires on Mac App
Be sure to add the CKSharingSupported key to your info.plist, and then try putting the userDidAcceptCloudKitShareWith in different places using the answers in the above link (where you put it will depend on what kind of app you're building).

How can I replace addObserver with publisher in receiving notifications from NotificationCenter

Currently I am using addObserver method to receive my wished notification in my ObservableObject, from the other hand we can use publisher method to receive wished notification in a View, I like to use publisher method inside my ObservableObject instead of addObserver method, how could I do that? How can I receive/notified the published value from publisher in my class?
import SwiftUI
struct ContentView: View {
#StateObject var model: Model = Model()
var body: some View {
Text("Hello, world!")
.padding()
.onReceive(orientationDidChangedPublisher) { _ in
print("orientationDidChanged! from View")
}
}
}
var orientationDidChangedPublisher = NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)
class Model: ObservableObject {
init() { orientationDidChangeNotification() }
private func orientationDidChangeNotification() { NotificationCenter.default.addObserver(self, selector: #selector(readScreenInfo), name: UIDevice.orientationDidChangeNotification, object: nil) }
#objc func readScreenInfo() { print("orientationDidChanged! from ObservableObject") }
}
You can use the same publisher(for:) inside your class:
import Combine
class Model: ObservableObject {
var cancellable : AnyCancellable?
init() {
cancellable = NotificationCenter.default
.publisher(for: UIDevice.orientationDidChangeNotification)
.sink(receiveValue: { (notification) in
//do something with that notification
print("orientationDidChanged! from ObservableObject")
})
}
}
There are two ways. First using the Combine framework (don't forget to import Combine), and the other using the usual way.
// Without Combine:
class MyClass1 {
let notification = NotificationCenter.default
.addObserver(forName: UIDevice.orientationDidChangeNotification,
object: nil, queue: .main) { notification in
// Do what you want
let orientationName = UIDevice.current.orientation.isPortrait ? "Portrait" : "Landscape"
print("DID change rotation to " + orientationName)
}
deinit {
NotificationCenter.default.removeObserver(notification)
}
}
// Using Combine
class MyClass2 {
let notification = NotificationCenter.default
.publisher(for: UIDevice.orientationDidChangeNotification)
.sink { notification in
// Do What you want
let orientationName = UIDevice.current.orientation.isPortrait ? "Portrait" : "Landscape"
print("DID change rotation to " + orientationName)
}
deinit {
notification.cancel()
}
}
In deinit you should remove all the observers, like i've shown above.
EDIT:
more about deinit:
deinit is the opposite side of init. It is called whenever your instance of the class is about to be removed from the memory.
If you are using the Combine way, it is fine to don't use deinit because as soon as the instance of the class is removed form the memory, the notification which is of type AnyCancelleable is removed from the memory as well, and that results in the notification being cancelled automatically.
But that automatic cancellation doesnt happen when you are using the normal way, and if you dont remove the observer you added, you'll have multiple observers listening to the notification. For example if you delete the deinit in the MyClass1, you'll see that the "DID change rotation to " is typed more than once (3 times for me) when you are using the class in a SwiftUI view, because the class was initialized more thatn once before the SwiftUI view is stable.

SwiftUI Combine for .onReceive notifications with AVPlayer

I'm working in SwiftUI and have an AudioPlayer type that is a subclass of AVPlayer; it publishes AVPlayer's timeControllerStatus(?) (.playing, .paused, and others?). Rather than subclassing AVPlayer, I would like to pass in an AVPlayer and have it notify me using .onReceive in some View. Here is the current, functional type I have:
import AVKit
import Combine
class AudioPlayer: AVPlayer, ObservableObject {
#Published var buffering: Bool = false
override init() {
super.init()
registerObservers()
}
private func registerObservers() {
self.addObserver(self, forKeyPath: "timeControlStatus", options: [.old, .new], context: nil)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "timeControlStatus", let change = change, let newValue = change[NSKeyValueChangeKey.newKey] as? Int, let oldValue = change[NSKeyValueChangeKey.oldKey] as? Int {
let oldStatus = AVPlayer.TimeControlStatus(rawValue: oldValue)
let newStatus = AVPlayer.TimeControlStatus(rawValue: newValue)
if newStatus != oldStatus {
DispatchQueue.main.async {[weak self] in
if newStatus == .playing || newStatus == .paused {
self?.buffering = false
} else {
self?.buffering = true
}
}
}
}
}
}
And here is an example of a class like the one I would like (taken from Chris Mash's tutorial on SwiftUI & AVPlayer):
import Combine
import AVFoundation
class PlayerItemObserver {
let publisher = PassthroughSubject<Bool, Never>()
private var itemObservation: NSKeyValueObservation?
init(player: AVPlayer) {
// Observe the current item changing
itemObservation = player.observe(\.currentItem) { [weak self] player, change in
guard let self = self else { return }
// Publish whether the player has an item or not
self.publisher.send(player.currentItem != nil)
}
}
deinit {
if let observer = itemObservation {
observer.invalidate()
}
}
}
Your help is much appreciated.
As I understand, you need to observe timeControlStatus just like in article example. For this you can just replace observer:
import Combine
import AVFoundation
class PlayerItemObserver {
let controlStatusChanged = PassthroughSubject<AVPlayer.TimeControlStatus, Never>()
private var itemObservation: NSKeyValueObservation?
init(player: AVPlayer) {
itemObservation = player.observe(\.timeControlStatus) { [weak self] player, change in
guard let self = self else { return }
self.controlStatusChanged.send(player.timeControlStatus)
}
}
deinit {
if let observer = itemObservation {
observer.invalidate()
}
}
}
// MARK: init view
let player = AudioPlayer()
let playerObserver = PlayerItemObserver(player: player)
let contentView = SongListView(playerObserver: playerObserver)
// MARK: react on changing in view:
struct ContentView: View {
let playerObserver: PlayerItemObserver
var body: some View {
Text("Any view")
.onReceive(playerObserver.controlStatusChanged) { newStatus in
switch newStatus {
case .waitingToPlayAtSpecifiedRate:
print("waiting")
case .paused:
print("paused")
case .playing:
print("playing")
}
}
}
}
UPDATE you can achieve the same without "old school" observe, using #Published and AnyCancellable. The last one even don't need extra code in deinit. Here is this solution:
import Combine
import AVFoundation
class PlayerItemObserver {
#Published var currentStatus: AVPlayer.TimeControlStatus?
private var itemObservation: AnyCancellable?
init(player: AVPlayer) {
itemObservation = player.publisher(for: \.timeControlStatus).sink { newStatus in
self.currentStatus = newStatus
}
}
}
// MARK: you need to change view with new observation, but in general it will be the same
struct ContentView: View {
let playerObserver: PlayerItemObserver
var body: some View {
Text("Any view")
.onReceive(playerObserver.$currentStatus) { newStatus in
switch newStatus {
case nil:
print("nothing is here")
case .waitingToPlayAtSpecifiedRate:
print("waiting")
case .paused:
print("paused")
case .playing:
print("playing")
}
}
}
}
NSObject has a method that gives you a Publisher for any KVO-compliant property. It's not documented, but it was discussed in WWDC 2019 sessions. For example, Raleigh Ledet described it starting at 25m36s in Session 231: Introducing SwiftUI, and Michael LeHew used it at 11m47s in Session 721: Combine in Practice.
The method is declared like this:
public func publisher<Value>(
for keyPath: KeyPath<Self, Value>,
options:NSKeyValueObservingOptions = [.initial, .new]
) -> NSObject.KeyValueObservingPublisher<Self, Value>
So, for example, you can use it like this:
player.publisher(for: \.timeControlStatus, options: [.initial])
.sink { print("player status: \($0)") }
.store(in: &tickets)