Widget update data properly - swiftui

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")
}
}
}
}

Related

SwiftUI - Publishing changes from background threads is not allowed

I am trying to create an app that creates random pictures when a button is clicked. The app is working fine but I see this message which I have never seen before."Publish changes from background threads is not allowed; make sure to publish values from the main thread.".
I am new to SwiftUI, help is appreciated.
import Foundation
import SwiftUI
class ImageviewModel{
var image: UIImage? = nil
//let url = URL(string: "https://source.unsplash.com/random/600x600")!
let url = URL(string: "https://picsum.photos/600/600")!
func responseHandler(data: Data?, response: URLResponse?) ->
UIImage?{
guard let data = data,
let image = UIImage(data: data),
let response = response else {return nil}
return image
}
func loadImageWithAsync() async throws -> UIImage?{
do{
let (data, response) = try await URLSession.shared.data(from: url,delegate: nil)
return responseHandler(data: data, response: response)
} catch{
throw error
}
}
}
class ViewModel: ObservableObject{
#Published var image: UIImage? = nil
var loader = ImageviewModel()
func fetchImage() async {
let image = try? await loader.loadImageWithAsync()
self.image = image
}
}
You need to add the MainActor wrapper to the class to guarantee that updates are done on Main
#MainActor
class ViewModel: ObservableObject{

How can I reliably update a String in SwiftUI based on a #Published Array from an ObservableObject?

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.

SwiftUI combine nil data

I have created a class to perform a network request and parse the data using Combine. I'm not entirely certain the code is correct, but it's working as of now (still learning the basics of Swift and basic networking tasks). My Widget has the correct data and is works until the data becomes nil. Unsure how to check if the data from my first publisher in my SwiftUI View is nil, the data seems to be valid even when there's no games showing.
My SwiftUI View
struct SimpleEntry: TimelineEntry {
let date: Date
public var model: CombineData?
let configuration: ConfigurationIntent
}
struct Some_WidgetEntryView : View {
var entry: Provider.Entry
#Environment(\.widgetFamily) var widgetFamily
var body: some View {
VStack (spacing: 0){
if entry.model?.schedule?.dates.first?.games == nil {
Text("No games Scheduled")
} else {
Text("Game is scheduled")
}
}
}
}
Combine
import Foundation
import WidgetKit
import Combine
// MARK: - Combine Attempt
class CombineData {
var schedule: Schedule?
var live: Live?
private var cancellables = Set<AnyCancellable>()
func fetchSchedule(_ teamID: Int, _ completion: #escaping (Live) -> Void) {
let url = URL(string: "https://statsapi.web.nhl.com/api/v1/schedule?teamId=\(teamID)")!
let publisher = URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: Schedule.self, decoder: JSONDecoder())
//.catch { _ in Empty<Schedule, Error>() }
//.replaceError(with: Schedule(dates: []))
let publisher2 = publisher
.flatMap {
return self.fetchLiveFeed($0.dates.first?.games.first?.link ?? "")
}
Publishers.Zip(publisher, publisher2)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: {_ in
}, receiveValue: { schedule, live in
self.schedule = schedule
self.live = live
completion(self.live!)
WidgetCenter.shared.reloadTimelines(ofKind: "NHL_Widget")
}).store(in: &cancellables)
}
func fetchLiveFeed(_ link: String) -> AnyPublisher<Live, Error /*Never if .catch error */> {
let url = URL(string: "https://statsapi.web.nhl.com\(link)")!
return URLSession.shared.dataTaskPublisher(for: url)
.map(\.data)
.decode(type: Live.self, decoder: JSONDecoder())
//.catch { _ in Empty<Live, Never>() }
.eraseToAnyPublisher()
}
}
Like I said in the comments, it's likely that the decode(type: Live.self, decoder: JSONDecoder()) returns an error because the URL that you're fetching from when link is nil doesn't return anything that can be decoded as Live.self.
So you need to handle that case somehow. For example, you can handle this by making the Live variable an optional, and returning nil when link is empty (or nil).
This is just to set you in the right direction - you'll need to work out the exact code yourself.
let publisher2 = publisher1
.flatMap {
self.fetchLiveFeed($0.dates.first?.games.first?.link ?? "")
.map { $0 as Live? } // convert to an optional
.replaceError(with: nil)
}
Then in the sink, handle the nil:
.sink(receiveCompletion: {_ in }, receiveValue:
{ schedule, live in
if let live = live {
// normal treatment
self.schedule = schedule
self.live = live
//.. etc
} else {
// set a placeholder
}
})
SwiftUI and WidgetKit work differently. I needed to fetch data in getTimeline for my IntentTimelineProvider then add a completion handler for my TimelineEntry. Heavily modified my Combine data model. All credit goes to #EmilioPelaez for pointing me in the right direction, answer here.

In SwiftUI, what's the best way to dynamically update rows based on underlying data when underlying data has not changed?

I have an app that lists Events from Core Data. Each event has a date.
When I list the Events, I want show the date, unless the date was today or yesterday, and in that case I want to show Today or Yesterday instead of the date.
As of now, I have a function that handles generating the String to show in the row. However, I've noticed that if a day passes and I re-open the app, it shows outdated information. For example, if there is an event from the previous day that said Today when I had the app open the previous day, it will still say Today instead of Yesterday when I re-open the app. Obviously this function is not being called every time I open the app, but I am wondering what the best approach is for making this more dynamic.
These are the avenues I am considering, but not sure what would be best, so I wanted to post here to get recommendations and see if I'm overlooking anything important:
Somehow do something with .onAppear on the row to re-calculate it every time the app is opened (I'm not sure how expensive this date calculation stuff is for each event, but even if it's not expensive I'm not sure how I would tell the rows to re-run the function when the app comes to the foreground)
Switch to a computed property (I don't know if this would be any different than putting a function in there, like I have now. This could be bad to have it called every time if it's an expensive call, but assuming it's not how would I get this to refresh every time the app comes to the foreground?)
Come up with a solution to only re-calculate each row if the day has changed (this is probably what I'd try to do if I knew the row calculation was very expensive, but seems like it might be overkill here, and I'm also not sure how I would go about telling each row to re-run the function)
Here is my code (I left out my date formatter code, but it's pretty standard and shouldn't matter for this):
struct ContentView: View {
#FetchRequest(fetchRequest: Event.eventsNewestFirst)
private var events: FetchedResults<Event>
var body: some View {
NavigationView {
ForEach(events){ event in
EventRow(event: event)
}
}
}
}
struct EventRow: View {
#ObservedObject var event: Event
var body: some View {
Text(event.dateAndTimeString())
}
}
extension Event {
func dateAndTimeString() -> String {
guard let date = self.date else { return "Error" }
let timeString = DateAndNumberFormatters.simpleTimeDisplay.string(from: date)
let dateString: String
if let todayOrYesterday = date.asTodayOrYesterday() {
dateString = todayOrYesterday
} else {
dateString = DateAndNumberFormatters.simpleShortDateDisplay.string(from: date)
}
return "\(dateString) at \(timeString)"
}
}
extension Date {
func asTodayOrYesterday() -> String? {
let calendar = Calendar.current
let dayComponents = calendar.dateComponents([.year, .month, .day], from: self)
let todayDateComponents = calendar.dateComponents([.year, .month, .day], from: Date())
var yesterdayDateComponents = calendar.dateComponents([.year, .month, .day], from: Date())
yesterdayDateComponents.day = yesterdayDateComponents.day! - 1
let dayDate: Date! = calendar.date(from: dayComponents)
let todayDayDate: Date! = calendar.date(from: todayDateComponents)
let yesterdayDayDate: Date! = calendar.date(from: yesterdayDateComponents)
switch dayDate {
case todayDayDate:
return "Today"
case yesterdayDayDate:
return "Yesterday"
default:
return nil
}
}
}
The possible approach is to observe scene phase and force refresh observed core data object as needed, like
struct EventRow: View {
#ObservedObject var event: Event
#Environment(\.scenePhase) var scenePhase
var body: some View {
Text(event.dateAndTimeString())
.onChange(of: scenePhase) {
if $0 == .active {
event.objectWillChange.send()
}
}
}
}
The scenePhase approach in another answer did not work.
The solution I ended up using relies on a publisher of UIApplication.didBecomeActiveNotification instead:
struct EventRow: View {
#ObservedObject var event: Event
#State private var dateAndTime: String = "Error"
var body: some View {
NavigationLink(destination: EventDetailView(event: event)) {
Text(dateAndTime)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
dateAndTime = event.dateAndTimeString()
}
}
}
}

Swift 3 submit form - UITextField changes only after focusing field again

I am working on a login view and trying to change the border color of a UITextField in Xcode/swift3 when validation of the textfield fails. The UITextField should get a red border color.
The problem is that if enter an email, then a password and then press the submit button, i have to focus email text field again before it gets a red border.
This is my LoginViewController.swift so far:
import Foundation
import UIKit
class LoginViewController : UIViewController, UITextFieldDelegate {
#IBOutlet weak var userEmailTextField: UITextField!
#IBOutlet weak var userPasswordTextField: UITextField!
override func viewDidLoad() {
super.viewDidLoad()
}
// login button action
#IBAction func loginButtonTabbed(_ sender: Any) {
// getting values from text fields
let userEmail = userEmailTextField.text;
let userPassword = userPasswordTextField.text;
// set enpoind data
let requestURL = NSURL(string: Constants.apiUrl)
//creating a task to send the post request
var request = URLRequest(url: requestURL as! URL)
request.httpMethod = "POST"
let postString = "cmd=addUser&email="+userEmail!+"&password="+userPassword!
request.httpBody = postString.data(using: .utf8)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
guard let data = data, error == nil else { // check for fundamental networking error
print("error=\(error)")
return
}
if let httpStatus = response as? HTTPURLResponse, httpStatus.statusCode != 200 { // check for http errors
print("statusCode should be 200, but is \(httpStatus.statusCode)")
print("response = \(response)")
}
do {
let json = try? JSONSerialization.jsonObject(with: data, options: [])
// store json response to dictionary
if let dictionary = json as? [String: Any] {
// check if we got validation errors
if let nestedDictionary = dictionary["validation"] as? [String: Any] {
// display validation messages on device
if let emailMsg = nestedDictionary["Email"] as? String { // change color of textfield
self.userEmailTextField.errorField()
}
}
}
} catch let error as NSError {
print(error)
}
}
//executing the task
task.resume()
}
}
and the UITextField extension UITextField.swift:
import Foundation
import UIKit
extension UITextField {
func errorField(){
self.layer.borderColor = UIColor(red: 255/255.0, green: 59/255.0, blue: 48/255.0, alpha: 1.0).cgColor
self.layer.borderWidth = 1.0;
}
}
When you're doing a network call, it always happens in the background...so in order to do any kind of UI updates you need to be on the main queue. Just put the self.userEmailTextField.errorField() inside DispatchQueue.main.async {...} so it would be done immediately.
Also haven't really tested your code very well. Why?
Even in your current code the border would still turn red, but it turns red after almost like 6-7 seconds (it could take less or more for you)...because it's being ran from background thread.
What I don't understand is why clicking on the textField again brings the red border right away!? Here's what I'm guessing happens:
From the background thread you update the model ie change the textField color which queues the UI/view to be updated...but since we're on a background queue, that UI updated could take a few seconds to happen
But then you tapped on the textField right away and forced a super quick read of the textField and all its properties which includes the border—from main thread (actual user touches are always handled through main thread)...which even though are not yet red on the screen, but since it's red on the model it will read from it and change color to red immediately.