Pausing a Notification publisher in SwiftUI - ios

When I call a backend service (login, value check…) I use a Notification publisher on the concerned Views to manage the update asynchronously.
I want to unsubscribe to the notifications when the view disappear, or « pause » the publisher.
I went first with the simple « assign » option from the WWDC19 Combine and related SwiftUI talks, then I looked at this great post and the onReceive modifier. However the view keeps updating with the published value even when the view is not visible.
My questions are:
Can I « pause » this publisher when the view is not visible ?
Should I really be concerned by this, does it affect resources (the backend update could trigger a big refresh on list and images display) or should I just let SwiftUI manage under the hood ?
The sample code:
Option 1: onReceive
struct ContentView: View {
#State var info:String = "???"
let provider = DataProvider() // Local for demo purpose, use another pattern
let publisher = NotificationCenter.default.publisher(for: DataProvider.updated)
.map { notification in
return notification.userInfo?["data"] as! String
}
.receive(on: RunLoop.main)
var body: some View {
TabView {
VStack {
Text("Info: \(info)")
Button(action: {
self.provider.startNotifications()
}) {
Text("Start notifications")
}
}
.onReceive(publisher) { (payload) in
self.info = payload
}
.tabItem {
Image(systemName: "1.circle")
Text("Notifications")
}
VStack {
Text("AnotherView")
}
.tabItem {
Image(systemName: "2.circle")
Text("Nothing")
}
}
}
}
Option 2: onAppear / onDisappear
struct ContentView: View {
#State var info:String = "???"
let provider = DataProvider() // Local for demo purpose, use another pattern
#State var cancel: AnyCancellable? = nil
var body: some View {
TabView {
VStack {
Text("Info: \(info)")
Button(action: {
self.provider.startNotifications()
}) {
Text("Start notifications")
}
}
.onAppear(perform: subscribeToNotifications)
.onDisappear(perform: unsubscribeToNotifications)
.tabItem {
Image(systemName: "1.circle")
Text("Notifications")
}
VStack {
Text("AnotherView")
}
.tabItem {
Image(systemName: "2.circle")
Text("Nothing")
}
}
}
private func subscribeToNotifications() {
// publisher to emit events when the default NotificationCenter broadcasts the notification
let publisher = NotificationCenter.default.publisher(for: DataProvider.updated)
.map { notification in
return notification.userInfo?["data"] as! String
}
.receive(on: RunLoop.main)
// keep reference to Cancellable, and assign String value to property
cancel = publisher.assign(to: \.info, on: self)
}
private func unsubscribeToNotifications() {
guard cancel != nil else {
return
}
cancel?.cancel()
}
}
For this test, I use a dummy service:
class DataProvider {
static let updated = Notification.Name("Updated")
var payload = "nothing"
private var running = true
func fetchSomeData() {
payload = Date().description
print("DEBUG new payload : \(payload)")
let dictionary = ["data":payload] // key 'data' provides payload
NotificationCenter.default.post(name: DataProvider.updated, object: self, userInfo: dictionary)
}
func startNotifications() {
running = true
runNotification()
}
private func runNotification() {
if self.running {
self.fetchSomeData()
let soon = DispatchTime.now().advanced(by: DispatchTimeInterval.seconds(3))
DispatchQueue.main.asyncAfter(deadline: soon) {
self.runNotification()
}
} else {
print("DEBUG runNotification will no longer run")
}
}
func stopNotifications() {
running = false
}
}

It seems that there are two publishers name let publisher in your program. Please remove one set of them. Also self.info = payload and publisher.assign(to: \.info, on: self)} are duplicating.
}
.onAppear(perform: subscribeToNotifications)
.onDisappear(perform: unsubscribeToNotifications)
.onReceive(publisher) { (payload) in
// self.info = payload
print(payload)
}
.tabItem {
In the following:
#State var cancel: AnyCancellable? = nil
private func subscribeToNotifications() {
// publisher to emit events when the default NotificationCenter broadcasts the notification
// let publisher = NotificationCenter.default.publisher(for: DataProvider.updated)
// .map { notification in
// return notification.userInfo?["data"] as! String
// }
// .receive(on: RunLoop.main)
// keep reference to Cancellable, and assign String value to property
if cancel == nil{
cancel = publisher.assign(to: \.info, on: self)}
}
private func unsubscribeToNotifications() {
guard cancel != nil else {
return
}
cancel?.cancel()
}
Now you can see, cancel?.cancel() does work and the info label no longer update after you come back from tab2. ~~~Publisher pause Here because subscription has been cancelled.~~~
Publisher is not paused as there is another subscriber in the view , so the print(payload) still works.

Related

UICloudSharingController Does not Display/Work with CloudKit App

I have been struggling to get an app to work with CloudKit and record sharing. I have
created several apps that sync Core Data records among devices for one user. I have not
been able to get the UICloudSharingController to work for sharing records. I can display
the Sharing View Controller, but tapping on Mail or Message displays a keyboard but no
address field and no way to dismiss the view. I have been so frustrated by it that I
decided to try the Apple "Sharing" sample app to start from the basics. However, the
sample app does not work for me either.
Here's the link to the sample app:
https://github.com/apple/cloudkit-sample-sharing/tree/swift-concurrency
The code below is pretty much straight from the sample app.
This is the ContentView file:
import SwiftUI
import CloudKit
struct ContentView: View {
#EnvironmentObject private var vm: ViewModel
#State private var isAddingContact = false
#State private var isSharing = false
#State private var isProcessingShare = false
#State private var showShareView = false
#State private var showIntermediateView = false
#State private var activeShare: CKShare?
#State private var activeContainer: CKContainer?
var body: some View {
NavigationView {
contentView
.navigationTitle("Contacts")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button { Task.init { try await vm.refresh() } } label: { Image(systemName: "arrow.clockwise") }
}
ToolbarItem(placement: .navigationBarLeading) {
progressView
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { isAddingContact = true }) { Image(systemName: "plus") }
}
}
}
.onAppear {
Task.init {
try await vm.initialize()
try await vm.refresh()
}
}
.sheet(isPresented: $isAddingContact, content: {
AddContactView(onAdd: addContact, onCancel: { isAddingContact = false })
})
}
/// This progress view will display when either the ViewModel is loading, or a share is processing.
var progressView: some View {
let showProgress: Bool = {
if case .loading = vm.state {
return true
} else if isProcessingShare {
return true
}
return false
}()
return Group {
if showProgress {
ProgressView()
}
}
}
/// Dynamic view built from ViewModel state.
private var contentView: some View {
Group {
switch vm.state {
case let .loaded(privateContacts, sharedContacts):
List {
Section(header: Text("Private")) {
ForEach(privateContacts) { contactRowView(for: $0) }
}
Section(header: Text("Shared")) {
ForEach(sharedContacts) { contactRowView(for: $0, shareable: false) }
}
}.listStyle(GroupedListStyle())
case .error(let error):
VStack {
Text("An error occurred: \(error.localizedDescription)").padding()
Spacer()
}
case .loading:
VStack { EmptyView() }
}
}
}
/// Builds a `CloudSharingView` with state after processing a share.
private func shareView() -> CloudSharingView? {
guard let share = activeShare, let container = activeContainer else {
return nil
}
return CloudSharingView(container: container, share: share)
}
/// Builds a Contact row view for display contact information in a List.
private func contactRowView(for contact: Contact, shareable: Bool = true) -> some View {
HStack {
VStack(alignment: .leading) {
Text(contact.name)
Text(contact.phoneNumber)
.textContentType(.telephoneNumber)
.font(.footnote)
}
if shareable {
Spacer()
Button(action: { Task.init { try? await shareContact(contact) }
}, label: { Image(systemName: "square.and.arrow.up") }).buttonStyle(BorderlessButtonStyle())
.sheet(isPresented: $isSharing, content: { shareView() })
}//if sharable
}//h
}//contact row view
// MARK: - Actions
private func addContact(name: String, phoneNumber: String) async throws {
try await vm.addContact(name: name, phoneNumber: phoneNumber)
try await vm.refresh()
isAddingContact = false
}
private func shareContact(_ contact: Contact) async throws {
isProcessingShare = true
do {
let (share, container) = try await vm.createShare(contact: contact)
isProcessingShare = false
activeShare = share
activeContainer = container
isSharing = true
} catch {
debugPrint("Error sharing contact record: \(error)")
}
}
}
And the UIViewControllerRepresentable file for the sharing view:
import Foundation
import SwiftUI
import UIKit
import CloudKit
/// This struct wraps a `UIImagePickerController` for use in SwiftUI.
struct CloudSharingView: UIViewControllerRepresentable {
#Environment(\.presentationMode) var presentationMode
let container: CKContainer
let share: CKShare
func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
func makeUIViewController(context: Context) -> some UIViewController {
let sharingController = UICloudSharingController(share: share, container: container)
sharingController.availablePermissions = [.allowReadWrite, .allowPrivate]
sharingController.delegate = context.coordinator
return sharingController
}
func makeCoordinator() -> CloudSharingView.Coordinator {
Coordinator()
}
final class Coordinator: NSObject, UICloudSharingControllerDelegate {
func cloudSharingController(_ csc: UICloudSharingController, failedToSaveShareWithError error: Error) {
debugPrint("Error saving share: \(error)")
}
func itemTitle(for csc: UICloudSharingController) -> String? {
"Sharing Example"
}
}
}
This is the presented screen when tapping to share a record. This all looks as expected:
This is the screen after tapping Messages (same result for Mail). I don't see any way to influence the second screen presentation - it seems that the view controller representable is not working with this version of Xcode:
Any guidance would be appreciated. Xcode 13.1, iOS 15 and I am on macOS Monterrey.
I had the same issue and fixed it by changing makeUIViewController() in CloudSharingView.swift:
func makeUIViewController(context: Context) -> some UIViewController {
share[CKShare.SystemFieldKey.title] = "Boom"
let sharingController = UICloudSharingController(share: share, container: container)
sharingController.availablePermissions = [.allowReadWrite, .allowPrivate]
sharingController.delegate = context.coordinator
-->>> sharingController.modalPresentationStyle = .none
return sharingController
}
It seems like some value work, some don't. Not sure why.

Proper way of Navigation in SwiftUI after async task

I have screen with List of Views one of them is NavigationLink that navigates to separate screen DocumentPreviewView where PDF document is presented.
This PDF document need to be downloaded asynchronously on the first screen after button is tapped and need to be passed to the DocumentPreviewView screen.
I came to working solution but I' m looking for more elegant way and more SwiftUI way.
Maybe document should be some kind of observable object.
This is my current solution. As you can see I use hidden NavigationLink that is being triggered by callback from button action where I also download document which I need to present.
#State private var document = PDFDocument()
#State private var docState: DocState? = .setup
enum DocState: Int {
case setup = 0
case ready = 1
}
var body: some View {
List {
/// some other views
Button(action: {
someAsyncFunction { doc, error in
self.document = doc
self.docState = .ready
}
}) {
Text("Show Document")
}
/// some other views
}
NavigationLink(
destination: DocumentPreviewView(pdfDocument: document),
tag: .ready,
selection: $docState) {
EmptyView()
}
}
Possible solution
struct ContentView: View {
#State private var docState: DocState? = .setup
enum DocState: Int {
case setup = 0
case ready = 1
}
var body: some View {
NavigationView {
ZStack {
NavigationLink(
destination: Text("Preview"),
tag: .ready,
selection: $docState) {
Button(action: {
someTask() { isTaskDone in
docState = .ready
}
}) {
Text("Download")
}
}
}
}
}
func someTask(_ completion: #escaping ((Bool)->())) {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
completion(true)
}
}
}

How can I present successive views in a SwiftUI-based iOS app?

In attempting to learn SwiftUI, I am working on an iOS app that displays a list view of "observation sessions" and allows users to create new sessions from a "New" button. It requires an intermediate step of selecting a configuration that the new session will be based on.
I am able to show reasonable session list and configuration list screens, but my attempts to handle the selected configuration are failing.
The closure sent to the configurations list screen is called successfully as evidenced by a print statement that correctly displays the configuration name. But the remainder of the handler that is supposed to present a third view type fails to work (i.e. it doesn't present the view). In addition, I am getting a warning where I attempt to present the new view that "Result of call to 'sheet(isPresented:onDismiss:content:)' is unused". I'm hoping somebody can explain to me what I'm doing wrong. This is in Xcode 12.3, targeting iOS 14 in the simulator. Here is the SessionListView code where the problem is exhibited:
import SwiftUI
struct SessionsListView: View {
#ObservedObject var dataManager: DataManager
#State private var isPresented = false
#State private var isObserving = false
var body: some View {
VStack {
List {
ForEach(dataManager.allSavedSessions) {session in
NavigationLink(
// Navigate to a detail view
destination: SessionDetailView(session: session),
label: {
Text("\(session.name)")
})
}
}
Spacer()
Button("New Session") {
isPresented = true
}
.padding()
.font(.headline)
.sheet(isPresented: $isPresented) {
// Present a configuration list view where user must select configuration to use for new session
// Requires a closure that's called upon selection in the configuration list view, to handle the selection
NavigationView {
ConfigurationsListView(dataManager: dataManager, selectionHandler: { config in
isPresented = false
isObserving = true
handleConfigSelection(config)
})
.navigationTitle("Configurations")
.navigationBarItems(trailing: Button("Cancel") {
isPresented = false
})
}
}
}
}
private func handleConfigSelection(_ config: SessionConfiguration) {
// Use the selected configuration to start an observations session
print("Selected \(config.name). Will attempt to show sheet from \(self)")
isPresented = false
isObserving = true
self.sheet(isPresented: $isObserving) { // displaying warning: "Result of call to 'sheet(isPresented:onDismiss:content:)' is unused"
NavigationView {
ObservationsView(configuration: config)
.navigationBarItems(trailing: Button(action: {}) {
Text("Done")
})
}
}
}
}
Here's the code I'm using in this simplified demo for the model types.
ObservationSession:
struct ObservationSession: Identifiable {
let id: UUID = UUID()
let name: String
}
SessionConfiguration:
import Foundation
struct ObservationSession: Identifiable {
let id: UUID = UUID()
let name: String
}
DataManager:
import Foundation
class DataManager: ObservableObject {
var allSavedSessions: [ObservationSession] {
return [ObservationSession(name: "Field mouse droppings"), ObservationSession(name: "Squirrels running up trees"), ObservationSession(name: "Squirrel behavior in urban landscapes")]
}
var allSavedConfigurations: [SessionConfiguration] {
return [SessionConfiguration(name: "Squirrel Behavior"), SessionConfiguration(name: "Squirrel/Tree Interaction"), SessionConfiguration(name: "Mouse Behavior")]
}
}
After a night's sleep I figured out an approach that seems to work.
I added a "currentConfiguration" property to my DataManager class of type SessionConfiguration, and set that property in the ConfigurationsListView when a user selects a configuration from the list. Then the SessionsListView can either present the ConfigurationsListView or an ObservationsView depending on a variable that tracks the flow:
import SwiftUI
enum SessionListPresentationFlow {
case configuration
case observation
}
struct SessionsListView: View {
#ObservedObject var dataManager: DataManager
#State private var isPresented = false
#State var flow: SessionListPresentationFlow = .configuration
var body: some View {
VStack {
List {
ForEach(dataManager.allSavedSessions) {session in
NavigationLink(
// Navigate to a detail view
destination: SessionDetailView(session: session),
label: {
Text("\(session.name)")
})
}
}
Spacer()
Button("New Session") {
isPresented = true
}
.padding()
.font(.headline)
.sheet(isPresented: $isPresented, onDismiss: {
if flow == .observation {
flow = .configuration
} else {
flow = .configuration
}
dataManager.currentConfiguration = nil
isPresented = false
}) {
// Present a view for the appropriate flow
viewForCurrentFlow()
}
}
}
#ViewBuilder private func viewForCurrentFlow() -> some View {
if flow == .configuration {
NavigationView {
ConfigurationsListView(dataManager: dataManager, selectionHandler: { config in
isPresented = false
handleConfigSelection(config)
})
.navigationTitle("Configurations")
.navigationBarItems(trailing: Button("Cancel") {
isPresented = false
flow = .observation
})
}
} else if flow == .observation, let config = dataManager.currentConfiguration {
NavigationView {
ObservationsView(configuration: config)
.navigationBarItems(leading: Button(action: {isPresented = false}) {
Text("Done")
})
}
} else {
EmptyView()
}
}
private func handleConfigSelection(_ config: SessionConfiguration) {
flow = .observation
isPresented = true
}
}

How can I get UNNotificationRequest array using for loop to populate SwiftUI List view?

I want to simply get the list of local notifications that are scheduled and populate a SwiftUI List using forEach. I believe it should work like I have done below, but the array is always empty as it seems to be used before the for loop is finished. I tried the getNotifications() function with a completion handler, and also as a return function, but both ways still didn't work. How can I wait until the for loop is done to populate my list? Or if there is another way to do this please let me know, thank you.
var notificationArray = [UNNotificationRequest]()
func getNotifications() {
print("getNotifications")
center.getPendingNotificationRequests(completionHandler: { requests in
for request in requests {
print(request.content.title)
notificationArray.append(request)
}
})
}
struct ListView: View {
var body: some View {
NavigationView {
List {
ForEach(notificationArray, id: \.content) { notification in
HStack {
VStack(alignment: .leading, spacing: 10) {
let notif = notification.content
Text(notif.title)
Text(notif.subtitle)
.opacity(0.5)
}
}
}
}
.onAppear() {
getNotifications()
}
}
}
Update:
Here is how I am adding a new notification and calling getNotifications again. I want the list to dynamically update as the new array is made. Printing to console shows that the getNotifications is working correctly and the new array contains the added notiication.
Section {
Button(action: {
print("Adding Notification: ", title, bodyText, timeIntValue[previewIndex])
addNotification(title: title, bodyText: bodyText, timeInt: timeIntValue[previewIndex])
showDetail = false
self.vm.getNotifications()
}) {
Text("Save Notification")
}
}.disabled(title.isEmpty || bodyText.isEmpty)
Your global notificationArray is not observed by view. It should be dynamic property... possible solution is to wrap it into ObservableObject view model.
Here is a demo of solution:
class ViewModel: ObservableObject {
#Published var notificationArray = [UNNotificationRequest]()
func getNotifications() {
print("getNotifications")
center.getPendingNotificationRequests(completionHandler: { requests in
var newArray = [UNNotificationRequest]()
for request in requests {
print(request.content.title)
newArray.append(request)
}
DispatchQueue.main.async {
self.notificationArray = newArray
}
})
}
}
struct ListView: View {
#ObservedObject var vm = ViewModel()
//#StateObject var vm = ViewModel() // << for SwiftUI 2.0
var body: some View {
NavigationView {
List {
ForEach(vm.notificationArray, id: \.content) { notification in
HStack {
VStack(alignment: .leading, spacing: 10) {
let notif = notification.content
Text(notif.title)
Text(notif.subtitle)
.opacity(0.5)
}
}
}
}
.onAppear() {
self.vm.getNotifications()
}
}
}

State not updating correctly in SwiftUI

Am trying to implement like and unlike button, to allow a user to like a card and unlike. When a user likes a button the id is saved and the icon changes from line heart to filled heart. I can save the id correctly but the issue is that at times the icon does not switch to filled one to show the user the changes especially after selecting the first one. The subsequent card won't change state but remain the same, while it will add save the id correctly. To be able to see the other card I have to, unlike the first card it cand display both like at the same time. I have tried both Observable and Environmental.
My Class to handle like and unlike
import Foundation
import Disk
class FavouriteRest: ObservableObject {
#Published private var fav = [Favourite]()
init() {
getFav()
}
func getFav(){
if let retrievedFav = try? Disk.retrieve("MyApp/favourite.json", from: .documents, as: [Favourite].self) {
fav = retrievedFav
} else {
print("")
}
}
//Get single data
func singleFave(id: String) -> Bool {
for x in fav {
if id == x.id {
return true
}
return false
}
return false
}
func addFav(favourite: Favourite){
if singleFave(id: favourite.id) == false {
self.fav.append(favourite)
self.saveFave()
}
}
//Remove Fav
func removeFav(_ favourite: Favourite) {
if let index = fav.firstIndex(where: { $0.id == favourite.id }) {
fav.remove(at: index)
saveFave()
}
}
//Save Fav
func saveFave(){
do {
try Disk.save(self.fav, to: .documents, as: "SmartParking/favourite.json")
}
catch let error as NSError {
fatalError("""
Domain: \(error.domain)
Code: \(error.code)
Description: \(error.localizedDescription)
Failure Reason: \(error.localizedFailureReason ?? "")
Suggestions: \(error.localizedRecoverySuggestion ?? "")
""")
}
}
}
Single Card
#EnvironmentObject var favourite:FavouriteRest
HStack(alignment: .top){
VStack(alignment: .leading, spacing: 4){
Text(self.myViewModel.myModel.title)
.font(.title)
.fontWeight(.bold)
Text("Some text")
.foregroundColor(Color("Gray"))
.font(.subheadline)
.fontWeight(.bold)
}
Spacer()
VStack{
self.favourite.singleFave(id: self.myViewModel.myModel.id) ? Heart(image: "suit.heart.fill").foregroundColor(Color.red) : Heart(image: "suit.heart").foregroundColor(Color("Gray"))
}
.onTapGesture {
if self.favourite.singleFave(id: self.myViewModel.myModel.id) {
self.favourite.removeFav(Favourite(id: self.myViewModel.myModel.id))
} else {
self.favourite.addFav(favourite: Favourite(id: self.myViewModel.myModel.id))
}
}
}
I was able to solve the question by moving the code inside the card view and using #States as shown below.
#State private var fav = [Favourite]()
#State var liked = false
VStack{
// Heading
HStack(alignment: .top){
VStack{
self.liked ? Heart(image:"suit.heart.fill").foregroundColor(Color.red) : Heart(image: "suit.heart").foregroundColor(Color("Gray"))
}
.onTapGesture {
if self.liked {
self.removeFav(self.singleFav!)
} else {
let faveID = self.myViewModel.myModel.id
let myFav = Favourite(id:faveID)
self.fav.append(myFav)
self.saveFave()
}
}
}
In the method for fetch and remove, I updated the #State var liked. Everything working as expected now.

Resources