Learn Courses My Dashboard

Tab Bar item badge doesn't update on date change

Using SwiftUI

I have a tab bar item that shows a list of bills and tasks, with due date. The badge for the list shows the number of items that are past due. This all works fine except when a date change causes a new item to become past due. That item is not reflected in the badge count until I do something, like deleting an item, that causes the view to refresh.

I’m using an onAppear to set the new count and I’ve verified that the onAppear is called when the app starts.

Here’s the code:

import SwiftUI

enum Tab { case list, settings, importExport }

struct ContentView: View
{
  @StateObject private var periodicals = Periodicals()
  @State private var numberPastDue = 0
  
  init()
  {
    UITabBar.appearance().backgroundColor = UIColor(color1)
    UINavigationBar.appearance().backgroundColor = UIColor(color1)
    UITableView.appearance().backgroundColor = UIColor(color2)
  }

  var body: some View
  {
    TabView
    {
      ItemsView()
        .tabItem
        { Label("List", systemImage: "list.bullet.rectangle.fill") }
        .tag(Tab.list)
        .badge(numberPastDue)
        .onAppear() { numberPastDue = periodicals.numberPastDue }
      
      SettingsView()
        .tabItem
        { Label("Settings", systemImage: "gearshape.fill") }
        .tag(Tab.settings)

      ImportExport()
        .tabItem
        { Label("Import-Export", systemImage: "arrow.up.arrow.down.square") }
        .tag(Tab.importExport)
    }
    .environmentObject(periodicals)
    .navigationViewStyle(.stack)
    .accentColor(colorContrast)
    .onChange(of: periodicals.numberPastDue)
    { newValue in numberPastDue = newValue }
  }
}

Any help would be appreciated.

There are a couple if things in your ContentView that are confusing.

You’ve got a declaration at the top of your struct

@StateObject private var periodicals = Periodicals()

and you have a modifier which is also attempting to inject periodicals into the View

.environmentObject(periodicals)

Is Periodicals an ObservableObject?

It is. I guess it shouldn’t be private. Is that causing the problem?

If you are going to inject Periodicals into the View as an environmentObject then the declaration at the top should be:

@EnvironmentObject var periodicals: Periodicals

and then where you are using the modifier .environmentObject is should be:

.environmentObject(Periodicals())

Is this causing the problem?

It could be. Although without knowing all the other elements that make up the App I have no way to be sure.

If Periodicals is being instantiated in the ContentView and passed down into the environment, the @StateObject is the correct property wrapper to use.

@EnvironmentObject is for reading an ObservableObject out of the environment after it is passed in by a parent View.

If, for instance, Periodicals were injected into the environment in the App struct, then it would be declared as an @StateObject there and an @EnvironmentObject in ContentView.

@PeteTheGolfer , what does your Periodicals object look like?

Probably more than you wanted, but better too much than too little.

import Foundation
import SwiftUI
import UserNotifications

struct Periodical: Codable, Identifiable
{
  var id: UUID = UUID()
  let name: String          // name of the bill or periodical
  var dueDate: Date         // due date
  let price: Double?        // amount due
  let notes: String         // any comments
  var timestamp: Date? = Date.now
  var itemOptions: ItemOptions?

  var dateExpired: Bool     // Is the current date at or past due date?
  { dueDate.removeTime() <= Date.now.removeTime() }

  var daysTillDue: Int
  {
    let dateOnlyDueDate = dueDate.removeTime()
    let dateOnlyToday = Date.now.removeTime()
    let myCalendar = Calendar.current
    return myCalendar.dateComponents([.day], from: dateOnlyToday, to: dateOnlyDueDate).day!
  }

  var buttonLabel: String
  { price == nil ? "Done" : "Paid" }
}

final class Periodicals: ObservableObject
{
  // Error handling
  
  struct AppError: Identifiable
  {
    let id = UUID().uuidString
    let errorString: String
  }
  
  var appError: AppError? = nil
  var errorHeading = ""
  
  // End of error handling
  
  @AppStorage("iconBadge") var iconBadge = false
  @AppStorage("notifications") var notifications = false
  
  static let itemKey = "Items"
  static let fileExt = ".txt"
  
  @Published private(set) var items = [Periodical]()
  
  // Computed property which is the number
  // of items that are past due.
  
  var numberPastDue: Int
  { items.filter { $0.dateExpired }.count}
  
  // Computed property which is the total amount that's past due.
  
  var totalPastDue: Double
  {
    items.filter()
    { $0.dateExpired && $0.price != nil }
    .reduce(0){ $0 + $1.price! }
  }
  
  // Computed property which is the total due within the
  // specified number of days, including past due.
  
  var dueInXDays: Double
  {
    items.filter()
    { $0.daysTillDue <= summaryDays && $0.price != nil }
    .reduce(0){ $0 + $1.price! }
  }


  // Items are added and replaced using currentItem.
  // If the id in currentItem matches an existing item,
  // the original item is replaced, otherwise it is
  // appended to the array.  In any case, the array is
  // then sorted by due date, earliest first, and saved.

  var currentItem: Periodical?
  {
    didSet
    {
      let index = itemIndex(id: currentItem!.id)

      if index == -1    // This is a new item being added.
      {
        cancelUndo()
        items.append(currentItem!)
      }
      else              // This is an existing item being edited.
      {
        previousItem = items[index]   // save for undo
        items[index] = currentItem!
      }

      save()            // Save the list.
    }
  }

  // If there is an item in 'previousItem', then it is
  // used for undo by exchanging it with 'currentItem'.

  var previousItem: Periodical?

  func undo()
  { currentItem = previousItem }

  func cancelUndo()
  { previousItem = nil }

  init()
  { load() }

  // Returns 'true' if the provided item name matches the
  // name of another item, otherwise returns 'false'.

  func duplicateItem(name: String, id: UUID) -> Bool
  { items.firstIndex(where: { $0.name == name && $0.id != id }) != nil }

  // Encode and save the data.

  func save()
  {
    let encoder = JSONEncoder()

    sortByDueDate()

    if let encoded = try? encoder.encode(items)
    { saveFile(data: encoded) }
  }

  private func sortByDueDate()
  { items.sort() { $0.dueDate < $1.dueDate };}

  // Load and decode the data file.

  func load()
  {
    if let json = loadFile()
    {
      let decoder = JSONDecoder()

      do
      {
        let decoded = try decoder.decode([Periodical].self, from: json)
        items = decoded
        return
      }
      catch 
      {
        print(error.localizedDescription)
        print(error)
        errorHeading = "Error decoding data"
        appError = AppError(errorString: error.localizedDescription)
      }
    }

    items = []
  }

  // ***************************************
  // *** Import/Export support functions ***
  // ***************************************

  // Returns a String representation of the data. Used by Export.

  func getDataAsString() -> String?
  {
    let data = loadFile() ?? Data()
    return String(data: data, encoding: String.Encoding.utf8)
  }

  // Replaces list with contents of data. Used by Import.

  func updateListFromData(_ json: Data, replace: Bool) throws
  {
    let decoder = JSONDecoder()

    do
    {
      let decoded = try decoder.decode([Periodical].self, from: json)
      
      if replace
      { items = decoded }
      else
      { items = removeDuplicates(items + decoded) }
      
      save()
    }
  }
  
  // - A periodical is a duplicate if their id's are equal.
  // - The duplicate with the earliest timestamp is removed.
  // - A timestamp of nil is earlier than any other timestamp.
  // - If two items both have a nil timestamp, the item with
  //   the earliest due date is removed.
  
  func removeDuplicates(_ list: [Periodical]) -> [Periodical]
  {
    let uniques = list.reduce(into: Dictionary<String, Periodical>())
    { result, currentVal in
      // Do we already have this periodical in our result?
      if let existingVal = result[currentVal.id.uuidString]
      {
        // Yes we do, so compare the timestamps.
        switch (currentVal.timestamp, existingVal.timestamp)
        {
          case (nil, nil):
            // Both currentVal.timestamp and existingVal.timestamp are nil.
            // Save the one with the latest due date.
            if currentVal.dueDate > existingVal.dueDate
            { result[currentVal.id.uuidString] = currentVal }
        
          case (nil, .some(_)):
            // currentVal.timestamp == nil, so do nothing.
            break
          
          case (.some(_), nil):
            // existingVal.timestamp == nil so replace with currentVal.
            result[currentVal.id.uuidString] = currentVal
          
          default:
            // Both currentVal.timestamp and existingVal.timestamp have values,
            // so compare them and keep the latest one.
            if currentVal.timestamp! > existingVal.timestamp!
            { result[currentVal.id.uuidString] = currentVal }
        }
      }
      else
      {
        // No we do not, so add it.
        result[currentVal.id.uuidString] = currentVal
      }
    }
    
    return Array(uniques.values)
  }

  // **********************************************
  // *** End of Import/Export support functions ***
  // **********************************************

  // Remove a single periodical from the list, based on the ID.

  func removeItem(id: UUID)
  {
    let index = items.firstIndex { $0.id == id }
    previousItem = items[index!]
    items.remove(at: index!)
    save()
  }

  // 1) Set the icon badge number to the number of items with expired due dates.
  // 2) Set up notifications to increase the icon badge number for future items.

  func setNotifications()
  {
    var badgeValue = 0

    // 1)

    if iconBadge
    { badgeValue = numberPastDue }

    UIApplication.shared.applicationIconBadgeNumber = badgeValue

    // 2)

    let center = UNUserNotificationCenter.current()
    center.removeAllPendingNotificationRequests()

    if iconBadge || notifications
    {
      // There is a limit of 64 pending notifications.  If this limit
      // is exceeded, the 64 soonest notifications are supposed to
      // survive.  This doesn't seem to be the case.  What actually
      // happens is that the last 64 notifications that were activated
      // are the ones that survive.  To get around this, I'm cutting the
      // unexpired items array down to the first 30, which means there
      // won't be any more than 60 notifications activated.

      let unexpiredItems = items.filter { !$0.dateExpired }.prefix(30)

      for item in unexpiredItems
      {
        if notifications
        { addNotification(for: item) }

        if iconBadge
        {
          badgeValue += 1
          setFutureIconBadgeValue(for: item, badgeValue: badgeValue)
        }
      }
    }
    print("List of Requests")
    center.getPendingNotificationRequests(completionHandler:
                                            { notifications in
                                              for notification in notifications
                                              { print(notification) }
                                            })
  }

  // Activate a notification for the item's due date.

  private func addNotification(for item: Periodical)
  {
    let center = UNUserNotificationCenter.current()

    let content = UNMutableNotificationContent()
    content.title = "'\(item.name)' is due today."
    content.sound = UNNotificationSound.default
    if #available(iOS 15, *)
    { content.interruptionLevel = .active }

    var triggerDate = Calendar.current.dateComponents([.year, .month, .day], from: item.dueDate)
    triggerDate.hour = 9

    let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDate, repeats: false)
    let request = UNNotificationRequest(identifier: item.id.uuidString, content: content, trigger: trigger)
    center.add(request)
  }

  // Activate a notification for future icon badge value.

  private func setFutureIconBadgeValue(for item: Periodical, badgeValue: Int)
  {
    var iconBadgeID: String
    {
      let formatter = DateFormatter()
      formatter.dateStyle = .short
      let idModifier = formatter.string(from: item.dueDate)
      return "iconBadge." + idModifier
    }

    let center = UNUserNotificationCenter.current()

    let content = UNMutableNotificationContent()
    content.badge = badgeValue as NSNumber

    var triggerDate = Calendar.current.dateComponents([.year, .month, .day], from: item.dueDate)
    triggerDate.hour = 0
    triggerDate.minute = 0
    let trigger = UNCalendarNotificationTrigger(dateMatching: triggerDate, repeats: false)

    let request = UNNotificationRequest(identifier: iconBadgeID, content: content, trigger: trigger)
    center.add(request)
  }

  // Returns the index of the item whose id matches the
  // supplied UUID or -1 if the item is not found.

  private func itemIndex(id: UUID) -> Int
  { items.firstIndex(where: { $0.id == id }) ?? -1;}

  // Return the URL of the app's documents directory.

  private func getDocumentsDirectory() -> URL
  {
    // Find all possible documents directories for this user.
    let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)

    // Just return the first one, which ought to be the only one.
    return paths[0]
  }

  // Save the data to the Documents dirctory.

  private func saveFile(data: Data)
  {
    let url = getDocumentsDirectory().appendingPathComponent(Self.itemKey + Self.fileExt)

    do
    { try data.write(to: url, options: [.atomicWrite, .completeFileProtection]) }
    catch
    {
      print(error.localizedDescription)
      print(error)
      errorHeading = "Error saving data"
      appError = AppError(errorString: error.localizedDescription)
    }
  }

  // Load the data from the Documents directory.

  private func loadFile() -> Data?
  {
    let url = getDocumentsDirectory().appendingPathComponent(Self.itemKey + Self.fileExt)

    if !FileManager.default.fileExists(atPath: url.relativePath)
    { return nil }

    do
    {
      let data = try Data(contentsOf: url)
      return data
    }
    catch
    {
      print(error.localizedDescription)
      print(error)
      errorHeading = "Error reading data"
      appError = AppError(errorString: error.localizedDescription)
      return nil
    }
  }
}

When I made this change, I received the following run-time error:

SwiftUI/EnvironmentObject.swift:70: Fatal error: No ObservableObject of type Periodicals found. A View.environmentObject(_:slight_smile: for Periodicals may be missing as an ancestor of this view.

2022-11-18 16:41:59.786381-0500 Bills & Periodicals[4038:217788] SwiftUI/EnvironmentObject.swift:70: Fatal error: No ObservableObject of type Periodicals found. A View.environmentObject(_:slight_smile: for Periodicals may be missing as an ancestor of this view.

@PeteTheGolfer

Do you want to share the project so I can take a closer look?

If you do, can you compress the entire project into a zip file and post that to either Dropbox or Google Drive. Then create a share link and post that link in a reply.

If you choose to use Google Drive then when you create the Share link, ensure that the “General Access” option is set to “Anyone with the Link” rather than “Restricted”.

PM those details if you prefer not to make it visible to all and sundry.

@PeteTheGolfer

Hi Peter,

I created an App (sort of a cludge really) using your ContentView and your Periodical file that you posted above. I had to figure out what you were doing with .removeTime() and with a bit of help from StackOverFlow, I guessed that it was something like this:

extension Date {
    func removeTime() -> Date {
       Calendar.current.date(from: Calendar.current.dateComponents([.year, .month, .day], from: self))!
    }
}

Anything else I could not figure out got commented out.

With regard to numberPastDue in your Periodicals class, I changed it to a @Published variable like so:

@Published var numberPastDue = 0 {
    didSet {
        if !items.isEmpty {
            numberPastDue = items.filter { $0.dateExpired }.count
        }
    }
}

When a change occurs, it broadcasts that change to any View that relies on it so you see the badge update straight away.

The check for items.isEmpty ensures that it only performs the filter when there is data in items. In my case items is not populated since I have no sample data to make all the functionality work.

In your ContentView, change the parameter reference in the modifier .badge() from:

.badge(numberPastDue)

to

.badge(periodicals.numberPastDue)

You can now remove the declaration at the top @State private var numberPastDue = 0 and also remove the .onAppear and .onChange modifiers since the Published property is referenced directly.

Hope that helps.

Don’t worry about posting a copy of the project as that may not be necessary.

Thanks for working with me on this. I appreciate the effort you’re putting into it.

Unfortunately, the result was that the badge value stays at zero in all cases.

I think it’s the definition of numberPastDue. It seems to wait for the value to be set, and when it is, it sets it again. but nothing is setting it. If something did set it, wouldn’t it go into a ‘didSet’ loop?

I’m still newish with Swift, so I’m not sure if what I’m saying makes sense.

@PeteTheGolfer

Ah jeepers, I am an idiot. Some sleep would have helped I’m sure of that.

Remove the didSet closure so that you just have the Published declaration:

@Published var numberPastDue = 0

and then below that have a function:

func updatePastDue() {
    numberPastDue = items.filter { $0.dateExpired }.count
}

then add a didSet { } to the Published items to call that function.

@Published var items = [Periodical]() {
    didSet {
        updatePastDue()
    }
}

This time I hope I’ve got it right.

<sigh>

I had to set up some test data on my project, with dates in the past, to ensure that if there was a change to the items array no matter if it was a new entry added or removed, the numberPastDue would update. I also had to ensure that if the dueDate of an item was updated so that it was in the future, that change was reflected in the badge too. That proved successful so I am confident that my solution is fine.

It’s very tricky dealing with dates since the time component was driving me crazy.

That’s better. Works fine so far. I’ll see tomorrow if it solved the update problem.

I wish there were a way to change the date in the simulator without changing the device’s date.

I hear you about dealing with dates. That’s why I wrote the removeTime function.

Instead of stripping the time and then comparing using operators, a better way to test dates would be to use the built-in Calendar functions, using something like this:

extension Date {
    func hasPassed(dueDate: Date, by testing: Calendar.Component = .day) -> Bool {
        //self is the date you are checking
        //dueDate is the date you are checking against
        //if the result is orderedDescending, then dueDate is before self
        //and thus self is past dueDate
        Calendar.current.compare(self, to: dueDate, toGranularity: testing) == .orderedDescending
    }
}

Usage:

let today = Date.now
let yesterday = Calendar.current.date(byAdding: .day, value: -1, to: today)!
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: today)!
let halfHourAgo = Calendar.current.date(byAdding: .minute, value: -30, to: today)!
let threeHoursFromNow = Calendar.current.date(byAdding: .hour, value: 3, to: today)!

print(today)
//2022-11-20 02:38:16 +0000

print(yesterday)
//2022-11-19 02:38:16 +0000
print(today.hasPassed(dueDate: yesterday))
//true

print(tomorrow)
//2022-11-21 02:38:16 +0000
print(today.hasPassed(dueDate: tomorrow))
//false

print(halfHourAgo)
//2022-11-20 02:08:16 +0000
print(today.hasPassed(dueDate: halfHourAgo, by: .minute))
//true

print(threeHoursFromNow)
//2022-11-20 05:38:16 +0000
print(today.hasPassed(dueDate: threeHoursFromNow, by: .hour))
//false

Dates are tricky and best to let the system handle them whenever possible.

I looked back at my original question and I see that it’s not very clear. The problem is this:

Say it’s November 1 and there are no items past due, but an item is due on November 2. The badge shows no number on November 1, but the next day it should show a 1. It doesn’t. Only when I add, delete, or edit an item will the badge value be updated. (Or if I refresh the app)

I need a way to update the badge when the app starts if today’s date is different from the date the app was last run.

There’s also the case where the app is open during the date change (e.g. open the app at 11:55 pm and at midnight the badge should update). Is that being too picky?

I would not have thought that’s being picky at all. Quite reasonable in fact.

What you could implement is something to trigger an update when the App comes back into the foreground (in the case where you might have switched to another App but your Periodicals App remains in the background).

There is an @Environment(.scenePhase) which has the states .active, .inactive and .background. So what you could do is declare that at, say, your ContentView like this:

@Environment(\.scenePhase) var scenePhase

and use .onChange on the closing brace of your TabView like this for example:

.onChange(of: scenePhase) { newPhase in
    if newPhase == .active {
        print("Active")
        periodicals.updatePastDue()
    } else if newPhase == .inactive {
        print("Inactive")
    } else if newPhase == .background {
        print("Background")
    }
}

When you switch back to your App the update function is triggered.

There’s probably other ways of achieving the same but this is just a thought.

Detecting a day change is something else but it could be configured as a recurring notification at midnight to perform the same update. Just thinking out aloud.

My problem was that I consider an item past due if the due date is equal to the current date. Having hours and minutes in the Date variables complicated that, so I just strip those out of the dates before I compare them.

Not working. It looks like the onChange isn’t being activated. Nothing is printed and the badge doesn’t show. Here’s my code:

import SwiftUI

enum Tab { case list, settings, importExport }

struct ContentView: View
{
  @StateObject var periodicals = Periodicals()
  @Environment(\.scenePhase) private var scenePhase
  
  init()
  {
    UITabBar.appearance().backgroundColor = UIColor(color1)
    UINavigationBar.appearance().backgroundColor = UIColor(color1)
    UITableView.appearance().backgroundColor = UIColor(color2)
  }

  var body: some View
  {
    TabView
    {
      ItemsView()
        .tabItem
        { Label("List", systemImage: "list.bullet.rectangle.fill") }
        .tag(Tab.list)
        .badge(periodicals.numberPastDue)
      
      SettingsView()
        .tabItem
        { Label("Settings", systemImage: "gearshape.fill") }
        .tag(Tab.settings)

      ImportExport()
        .tabItem
        { Label("Import-Export", systemImage: "arrow.up.arrow.down.square") }
        .tag(Tab.importExport)
    }
    .environmentObject(periodicals)
    .navigationViewStyle(.stack)
    .accentColor(colorContrast)
    .onChange(of: scenePhase)
    { newPhase in
      if newPhase == .active
      {
        print("Active")
        periodicals.updatePastDue()
      }
      else if newPhase == .inactive
      { print("Inactive")}
      else if newPhase == .background
      { print("Background")}
      else
     { print("None of the above")}
    }
  }
}

@PeteTheGolfer

Works on my version of ContentView. What version of Xcode are you using?

14.0.1