I've seen a dozen or so tutorials on how to use Combine and receive a Notification of a task being completed. It seems they all show linear code - the publisher and receiver all in the same place, one row after another.
Publishing a notification is as easy as the code below:
// background download task complete - notify the appropriate views
DispatchQueue.main.async {
NotificationCenter.default.post(name: .dataDownloadComplete, object: self, userInfo: self.dataCounts)
}
extension Notification.Name {
static let dataDownloadComplete = Notification.Name("dataDownloadComplete")
}
SwiftUI has the onReceive() modifier, but I can't find any way to connect the above to a "listener" of the posted notification.
How does a View receive this Notification
FYI, after several days of reading and putting together the confusing-to-me Combine tutorials, I discovered these two methods of receiving the notification. For ease, they are included in the same View. (Some not-related details have been omitted.)
In my case, a fetch (performed on a background thread) is batch loading info into Core Data for several entities. The View was not being updated after the fetch completed.
// in the background fetch download class
...
var dataCounts: [DataSources.Source : Int] = [:]
...
// as each source of data has been imported
self.dataCounts[source] = numberArray.count
...
// in a view
import Combine
struct FilteredPurchasesView: View {
private var downloadCompletePublisher: AnyPublisher<Notification, Never> {
NotificationCenter.default
.publisher(for: .dataDownloadComplete)
.eraseToAnyPublisher()
}
private var publisher = NotificationCenter.default
.publisher(for: .dataDownloadComplete)
.map { notification in
return notification.userInfo as! [DataSources.Source : Int]
}
.receive(on: RunLoop.main)
var body: some View {
List {
ForEach(numbers.indices, id: \.self) { i in
NavigationLink(destination: NumberDetailView(number: numbers[i])) {
NumberRowView(number: numbers[i])
}
.id(i)
}
}
.add(SearchBar(searchText: $numberState.searchText))
.onReceive(downloadCompletePublisher) { notification in
print("dataDownload complete (FilteredPurchasesView 1)")
if let info = notification.userInfo as? [DataSources.Source:Int], let purchaseCount = info[DataSources.Source.purchased] {
if purchaseCount > 0 {
// now the view can be updated/redrawn
} else {
print("purchase update count = 0")
}
}
}
.onReceive(publisher) { notification in
print("dataDownload complete (FilteredPurchasesView 2)")
}
}
}
Some notes about this:
During the first couple of attempts, the FilteredPurchasesView had not yet been initialized. This meant there was no Subscriber to listen for the posted notification.
Both of the Publisher vars are available. As of this writing, I cannot explain why or how they work.
Both onReceive() modifiers contain the notification.
Comments, ideas and feedback welcome.
Related
I would like to use Contacts in an app. The user should select them and I will store the name and the id in the Core Data database.
When the Contact gets edited from the iOS Contacts app I would like to update the Core Data entities. When doing this I get the purple warning: "This method should not be called on the main thread as it may lead to UI unresponsiveness." (see comment in code) when fetching the contacts.
How to use the right threads to do it how it should be done? Im not really familiar with the best practices. Do I have to go back to the main thread for UI related things or is this not necessary here because of the #FetchRequest in other parts of the app where the saved contacts from Core Data are used?
class KontakteUpdater{
func update(moc: NSManagedObjectContext){
var contacts: [CNContact] = []
let key = [CNContactGivenNameKey,CNContactFamilyNameKey] as [CNKeyDescriptor]
let Kontakt_request = CNContactFetchRequest(keysToFetch: key)
// get contacts from the store
do{
try CNContactStore().enumerateContacts(with: Kontakt_request, usingBlock: { (contact, stoppingPointer) in // here with warning
contacts.append(contact)
})
}
catch
{
print(error)
}
// fetch currently saved contacts from core data
var kontakteUpdate: [Kontakt] = []
let request = NSFetchRequest<Kontakt>(entityName: "Kontakt")
do{
kontakteUpdate = try moc.fetch(request)
}
catch let error{
print("Error fetching. \(error)")
}
// update saved contacts in core data
for k in kontakteUpdate{
if let c = contacts.first(where: {contact in
contact.identifier == k.id
}){
k.vorname = c.givenName
k.nachname = c.familyName
}
}
do{
try moc.save()
}
catch{
print("\(error) - Saving didn't succeed")
}
}
}
Here is the entry point of the sample app:
import SwiftUI
import CoreData
#main
struct testApp: App {
let pub = NotificationCenter.default
.publisher(for: NSNotification.Name.CNContactStoreDidChange)
let kontakteUpdater = KontakteUpdater()
let persistenceContainer = PersistenceController.shared
var body: some Scene {
WindowGroup {
ContentView()
.onReceive(pub, perform: {output in
kontakteUpdater.update(moc: persistenceContainer.container.viewContext)
})
.environment(\.managedObjectContext, persistenceContainer.container.viewContext)
}
}
}
I have an app, that shows posts on a vertical SwiftUIPager. Each page fills the whole screen.
So, when the app launches, I fetch the 10 most recent posts and display them.
As soon as those posts are fetched, I start listening for new posts. (see code below) Whenever that callback gets triggered, a new post is created. I take it and place it on the top of my list.
The thing is when I scroll to find the new post, its views get mixed up with the views of the next post.
Here's what I mean:
Before the new post, I have the one below
https://imgur.com/a/ZmMzfvb
And then, a new post is added to the top
https://imgur.com/a/PJ0trSF
As you'll notice the image seems to be the same, but it shouldn’t! If I scroll for a while and then go back up, the new post will be fixed and display the proper image. (I'm using SDWebImageSwiftUI for async images, but I don't think it matters... I also used Apple's AsyncImage, with the same results)
Here's my feed view model:
#Published var feedPage: Page = .first()
#Published var feedItems = Array(0..<2)
var posts = [Post]()
...
private func subscribeToNewPosts() {
postsService.subscribeToNewPosts() { [weak self] post in
self?.posts.insert(post, at: 0)
DispatchQueue.main.async {
self?.feedItems = Array(0..<(self?.posts.count ?? 1))
}
}
}
And here's my feed view:
private struct FeedPageView: View {
#EnvironmentObject private var viewModel: FeedView.ViewModel
var body: some View {
ZStack {
VStack {
Pager(page: viewModel.feedPage,
data: viewModel.feedItems,
id: \.self,
content: { index in
if index == 0 {
HomeCameraView()
.background(.black)
} else {
PostView(post: viewModel.posts[index - 1])
}
})
.vertical()
.sensitivity(.custom(0.1))
.onPageWillChange { index in
viewModel.willChangeVerticalPage(index: index)
}
}
}
}
}
Any idea what I'm doing wrong?
I’m building a macOS app based on data from CloudKit. I’m running into an issue where one of the Strings in my UI is not being updated as I would expect it to.
Model
I have an Event struct, which has an ID and a timestamp:
struct Event: Identifiable, Hashable {
let id: UUID
let timestamp: Date
init(id: UUID = UUID(),
timestamp: Date) {
self.id = id
self.timestamp = timestamp
}
}
Periodically, my app fetches updated Event data from CloudKit. This is handled in an ObservedObject called DataStore.
For example, the fetch happens when the DataStore is initialized, and when a push notification comes from the server to indicate there is new information.
The function updateLocalEvents() in DataStore is called to actually update the local in-memory #Published Array, which calls fetchEvents() to actually get the current set of last 10 events data from CloudKit.
class DataStore: ObservableObject {
#Published var recentEvents: [Event] = []
init() {
updateLocalEvents()
}
func updateLocalEvents() {
print("updateLocalEvents()")
fetchEvents()
.receive(on: RunLoop.main)
.assign(to: &$recentEvents)
}
private func fetchEvents() -> AnyPublisher<[Event],Never> {
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: "Event",
predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: "timestamp",
ascending: false)]
let operation = CKQueryOperation(query: query)
operation.resultsLimit = 10
operation.qualityOfService = .userInitiated
var events = [Event]()
operation.recordFetchedBlock = { record in
if let id = UUID(uuidString: record.recordID.recordName),
let timestamp = record.object(forKey: "timestamp") as? Date
{
events.append(Event(id: id,
timestamp: timestamp))
}
}
return Future<[Event],Never> { promise in
operation.completionBlock = {
promise(.success(events))
}
CKConstants.container
.privateCloudDatabase
.add(operation)
}
.eraseToAnyPublisher()
}
}
View
In my view, I show a String to indicate the time since the last event. For example, it may say 1 hour ago or 3 hours ago using a RelativeDateTimeFormatter.
This is stored in a timeAgo #State variable of type String?.
There is a Timer that attempts to update the timeAgo String? every minute, using an .onReceive modifier for the Timer, and another .onReceive modifier that uses the #Published Array of Events to update the timeAgo String?. Here is my view code:
struct EventsView: View {
#EnvironmentObject var store: DataStore
#State private var timer: Publishers.Autoconnect<Timer.TimerPublisher> = Timer
.publish(every: 60,
on: .main,
in: .common)
.autoconnect()
#State private var timeAgo: String?
var body: some View {
VStack {
if let mostRecentEvent = store.recentEvents.first {
Text(timeAgo ?? relativeTimeFormatter.localizedString(for: mostRecentEvent.timestamp, relativeTo: Date()))
.fontWeight(.bold)
.font(.system(.largeTitle,
design: .rounded))
.onReceive(timer) { _ in
timeAgo = relativeTimeFormatter
.localizedString(for: mostRecentEvent.timestamp, relativeTo: Date())
}
.onReceive(store.$recentEvents) { recentEvents in
print(".onReceive(store.$recentEvents)")
if let mostRecentEvent = recentEvents.first {
timeAgo = relativeTimeFormatter
.localizedString(for: mostRecentEvent.timestamp, relativeTo: Date())
}
}
} else {
Text("No Event Data")
}
}
.frame(minWidth: 250,
maxWidth: 250,
minHeight: 200,
maxHeight: 200)
}
}
private let relativeTimeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.dateTimeStyle = .numeric
return formatter
}()
The problem is that, sometimes, there is a push notification indicating new data, and the updateLocalEvents() function is called, updating the recentEvents variable, which also triggers .onReceive(store.$recentEvents). I can see this is happening with my print statements. However, the timeAgo variable does not always get updated, and the view still shows the old string.
How should I change this to get my desired result of always keeping the timeAgo String? up-to-date based on the current value of #Published var recentEvents?
I’m also open to any other suggestions to simplify or improve any of this code I shared! Thanks!
It turns out I made a dumb mistake and posted this thinking it was a SwiftUI or Combine issue, when really there was a different problem.
From what I can tell now, SwiftUI and everything I had set up was actually working fine.
The real issue was in the data I was fetching. The array of Events based on the query results were not what I expected. I was receiving a push notification from CloudKit that indicated there were, and based on that I would kick off the query to CloudKit to fetch the new data. This data that was fetched was outdated, and did not include the new events (or still included deleted events).
I found that if I included a delay of just a couple seconds between the notification arriving and sending the query, the expected data would come back from CloudKit and everything would update accordingly.
I should have actually been using the CKFetchDatabaseChangesOperation and related features of the CloudKit API to fetch and process the actual changes that triggered the notification instead of kicking off a fresh query each time.
I've made a widget which fetches Codable data and it's working just fine in the simulator ONLY. The widget updates within 30 seconds or less after the data has changed. I've set a 5 minute update limit (I understand it's called far less frequently). It's working actually really great in the simulator without any kind of background data fetches and updates in less time than I set in getTimeline. Then I ran into an issue on a a real test device.
The data won't update anywhere between 2-10+ mins while testing a real device, in the snapshot it's updated and can see the new data changes but not in the widget on springboard. I don't understand why the simulator works just fine but not a real device. The Widget is definitely being updated when the data changes but only in the Simulator so am I suppose to fetch data in the background?
I've come across this Keeping a Widget Up To Date | Apple Developer Documentation. I'm still very new to Swift and SwiftUI so this is a little bit harder for me to grasp. I'm trying to understand the section Update After Background Network Requests Complete to update my Codeable data. My guess is the simulator is different from a real device and I need to fetch data in the background for the must up to date data?
The end goal is to have the widget update as frequently as possible with the most current data. I'm not sure I even need the background data fetch?
My data model for my widget as an example (which is working fine)
class DataModel {
var data: DataClass = DataClass(results: []))
func sessions(_ completion: #escaping (DataClass -> Void) {
guard let url = URL(string: "URL HERE") else { return }
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: "Accept")
URLSession.shared.dataTask(with: request) { data, response, error in
if let data = data {
if let response = try? JSONDecoder().decode(DataClass.self, from: data) {
self.data = response
completion(self.data)
WidgetCenter.shared.reloadTimelines(ofKind: "Widget")
}
}
}
.resume()
}
}
My getTimeline calling the data model
func getTimeline(in context: Context, completion: #escaping (Timeline<Entry>) -> ()) {
let model = DataModel()
var entries: [SimpleEntry] = []
let currentDate = Date()
let entryDate = Calendar.current.date(byAdding: .minute, value: 5, to: currentDate)!
let entry = SimpleEntry(date: entryDate, model: model)
entries.append(entry)
model.sessions {_ in
let timeline = Timeline(entries: entries, policy: .atEnd)
completion(timeline)
}
}
I have this for my background network request
import Foundation
import WidgetKit
class BackgroundManager : NSObject, URLSessionDelegate, URLSessionDownloadDelegate {
var completionHandler: (() -> Void)? = nil
private lazy var urlSession: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: "widget-bundleID")
config.sessionSendsLaunchEvents = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
func update() {
let task = urlSession.downloadTask(with: URL(string: "SAME URL FROM DATA MODEL HERE")!)
task.resume()
}
func urlSession(_ session: URLSession ,downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
print (location)
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
self.completionHandler!()
WidgetCenter.shared.reloadTimelines(ofKind: "Widget")
print("Background update")
}
}
Then in my Widget I set .onBackgroundURLSessionEvents(). I never see any background updates or errors in the console. This seems very wrong, the Codable data will never be updated? How do I properly update my data in the background?
struct Some_Widget: Widget {
let kind: String = "Widget"
let backgroundData = BackgroundManager()
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in
SomeWidget_WidgetEntryView(entry: entry)
}
.configurationDisplayName("Widget")
.description("Example widget.")
.onBackgroundURLSessionEvents { (sessionIdentifier, completion) in
if sessionIdentifier == self.kind {
self.backgroundData.update()
self.backgroundData.completionHandler = completion
print("background update")
}
}
}
}
When creating a class conforming to ReferenceFileDocument, how do you indicate the document needs saving. i.e. the equivalent of the NSDocument's updateChangeCount method?
I've met the same problem that the SwiftUI ReferenceFileDocument cannot trigger the update. Recently, I've received feedback via the bug report and been suggested to register an undo.
Turns out the update of ReferenceFileDocument can be triggered, just like UIDocument, by registering an undo action. The difference is that the DocumentGroup explicitly implicitly setup the UndoManager via the environment.
For example,
#main
struct RefDocApp: App {
var body: some Scene {
DocumentGroup(newDocument: {
RefDocDocument()
}) { file in
ContentView(document: file.document)
}
}
}
struct ContentView: View {
#Environment(\.undoManager) var undoManager
#ObservedObject var document: RefDocDocument
var body: some View {
TextEditor(text: Binding(get: {
document.text
}, set: {
document.text = $0
undoManager?.registerUndo(withTarget: document, handler: {
print($0, "undo")
})
}))
}
}
I assume at this stage, the FileDocument is actually, on iOS side, a wrapper on top of the UIDocument, the DocumentGroup scene explicitly implicitly assign the undoManager to the environment. Therefore, the update mechanism is the same.
The ReferenceFileDocument is ObservableObject, so you can add any trackable or published property for that purpose. Here is a demo of possible approach.
import UniformTypeIdentifiers
class MyTextDocument: ReferenceFileDocument {
static var readableContentTypes: [UTType] { [UTType.plainText] }
func snapshot(contentType: UTType) throws -> String {
defer {
self.modified = false
}
return self.storage
}
#Published var modified = false
#Published var storage: String = "" {
didSet {
self.modified = true
}
}
}
ReferenceFileDocument exists for fine grained controll over the document. In comparison, a FileDocument has to obey value semantics which makes it very easy for SwiftUI to implement the undo / redo functionality as it only needs to make a copy before each mutation of the document.
As per the documentation of the related DocumentGroup initializers, the undo functionality is not provided automatically. The DocumentGroup will inject an instance of an UndoManger into the environment which we can make use of.
However an undo manager is not the only way to update the state of the document. Per this documentation AppKit and UIKit both have the updateChangeCount method on their native implementation of the UI/NSDocument object. We can reach this method by grabbing the shared document controller on macOS from within the view and finding our document. Unfortunately I don't have a simple solution for the iOS side. There is a private SwiftUI.DocumentHostingController type which holds a reference to our document, but that would require mirroring into the private type to obtain the reference to the native document, which isn't safe.
Here is a full example:
import SwiftUI
import UniformTypeIdentifiers
// DOCUMENT EXAMPLE
extension UTType {
static var exampleText: UTType {
UTType(importedAs: "com.example.plain-text")
}
}
final class MyDocument: ReferenceFileDocument {
// We add `Published` for automatic SwiftUI updates as
// `ReferenceFileDocument` refines `ObservableObject`.
#Published
var number: Int
static var readableContentTypes: [UTType] { [.exampleText] }
init(number: Int = 42) {
self.number = number
}
init(configuration: ReadConfiguration) throws {
guard
let data = configuration.file.regularFileContents,
let string = String(data: data, encoding: .utf8),
let number = Int(string)
else {
throw CocoaError(.fileReadCorruptFile)
}
self.number = number
}
func snapshot(contentType: UTType) throws -> String {
"\(number)"
}
func fileWrapper(
snapshot: String,
configuration: WriteConfiguration
) throws -> FileWrapper {
// For the sake of the example this force unwrapping is considered as safe.
let data = snapshot.data(using: .utf8)!
return FileWrapper(regularFileWithContents: data)
}
}
// APP EXAMPLE FOR MACOS
#main
struct MyApp: App {
var body: some Scene {
DocumentGroup.init(
newDocument: {
MyDocument()
},
editor: { file in
ContentView(document: file.document)
.frame(width: 400, height: 400)
}
)
}
}
struct ContentView: View {
#Environment(\.undoManager)
var _undoManager: UndoManager?
#ObservedObject
var document: MyDocument
var body: some View {
VStack {
Text(String("\(document.number)"))
Button("randomize") {
if let undoManager = _undoManager {
let currentNumber = document.number
undoManager.registerUndo(withTarget: document) { document in
document.number = currentNumber
}
}
document.number = Int.random(in: 0 ... 100)
}
Button("randomize without undo") {
document.number = Int.random(in: 0 ... 100)
// Let the system know that we edited the document, which will
// eventually trigger the auto saving process.
//
// There is no simple way to mimic this on `iOS` or `iPadOS`.
let controller = NSDocumentController.shared
if let document = controller.currentDocument {
// On `iOS / iPadOS` change the argument to `.done`.
document.updateChangeCount(.changeDone)
}
}
}
}
}
Unfortunatelly SwiftUI (v2 at this moment) does not provide a native way to mimic the same functionality, but this workaround is still doable and fairly consice.
Here is a gist where I extended the example with a custom DocumentReader view and a DocumentProxy which can be extended for common document related operations for more convenience: https://gist.github.com/DevAndArtist/eb7e8aa5e7134610c20b1a7aca358604