1

Using SwiftUI (or Combine) how might I set up a series of one or more events that are triggered by the (system) clock. Examples might include:

  • Every night at midnight,
  • On the hour,
  • Every fifteen minutes on the quarter hour,
  • Finally, on a slightly different note: On the 29th of February 2020 at 12:15.

An approximation is easily achieved by setting up a timer event that fires every second and then checking the hours/minutes/seconds, etc. but this seems very inefficient for events that may be many hours or days apart.

I'm looking for something that is closely synchronised to the actual system clock and fires off a single event at the required time rather than firing loads of events and having each one ask "Are we there yet?".

Vince O'Sullivan
  • 2,611
  • 32
  • 45
  • 1
    These all sound like events you should be [scheduling as local notifications](https://developer.apple.com/documentation/usernotifications/scheduling_a_notification_locally_from_your_app), not using Combine or a `Timer`. – rob mayoff Jan 13 '20 at 17:34
  • @robmayoff Notifications appear to be exactly what I'm looking for. Thanks. – Vince O'Sullivan Jan 20 '20 at 13:53

2 Answers2

1

I would suggest the following:

DispatchQueue.global(qos: .background).async {
    let isoDate = "2020-01-13T16:58:30+0000"

    let dateFormatter = ISO8601DateFormatter()
    let date = dateFormatter.date(from:isoDate)!
    let t = Timer(fire: date, interval: 2, repeats: true) { timer in
        print("fired")
    }
    let runLoop = RunLoop.current
    runLoop.add(t, forMode: .default)
    runLoop.run()
}

string to date conversion I used this answer to format the time correctly.

The example is in GMT.

documentation apple you can look up timer tolerance which can be adjusted if you need the timer to be very accurate.

interval is in seconds so this solution won't get more accurate than seconds

You might want to enable the Background Modes capability to go for the very long running timers. Never done that so I can't help here.

All your examples should work. I hope this helps!

krjw
  • 4,070
  • 1
  • 24
  • 49
1

I had to implement this feature too using Combine / SwiftUI : a Timer that would execute at start then every day, hour or minutes (for testing), here is my solution if it can be useful or improved :)

class PeriodicPublisher {

    var periodicFormat: PeriodicFormat = .daily

    init(_ format: PeriodicFormat = .daily) {
        self.periodicFormat = format
    }

    // Must have an equatable for removeDuplicate
    struct OutputDate: Equatable {

        let compared: String
        let original: String

        init(_ comparedDatePart: String, _ originalDate: String) {
            self.compared = comparedDatePart
            self.original = originalDate
        }

        static func ==(lhs: OutputDate, rhs: OutputDate) -> Bool {
            return lhs.compared == rhs.compared
        }
    }

    enum PeriodicFormat {
        case daily
        case hourly
        case minutely

        func toComparableDate() -> String {
            switch self {
                case .daily:
                    return "yyyy-MM-dd"
                case .hourly:
                    return "HH"
                case .minutely:
                    return "mm"
            }
        }
    }

    func getPublisher() -> AnyPublisher<OutputDate, Never> {

        let compareDateFormatter = DateFormatter()
        compareDateFormatter.dateFormat = self.periodicFormat.toComparableDate()

        let originalTimerDateFormatter = DateFormatter()
        originalTimerDateFormatter.dateFormat = "yyyy-MM-dd HH:mm"

        var nowDate: Just<OutputDate> {

            let comparedDate = compareDateFormatter.string(from: Date())
            let originalDate = originalTimerDateFormatter.string(from: Date())

            return Just(OutputDate(comparedDate, originalDate))
        }

        let timerDate = Timer.publish(every: 2.0, tolerance: 1.0, on: .main, in: .default, options: nil)
            .autoconnect()
            .map { dateString -> OutputDate in
                return OutputDate(compareDateFormatter.string(from: dateString), originalTimerDateFormatter.string(from: dateString))
        }
        .eraseToAnyPublisher()

        return Publishers.Merge(nowDate, timerDate)
            .map { $0 }
            .removeDuplicates()
            .eraseToAnyPublisher()
    }
}

How does it work ?

Every 2 seconds the scheduler issue current date (with Timer.publish()), this date is used to create a "OutputDate" holding two properties : one "comparable" part used to compare if something has changed and one "original" part so it can be useful for the consumer.

Comparable property is Timer's date formatted with toComparableDate given the provided configuration (.daily, .hourly, .minutely). Using "removeDuplicates" on this property allow to publish "OutputDate" only when this value changes. Every day or hour or minute.

Publishers.Merge is used to publish a value immediately after instantiation, otherwise nothing happens before the first Timer.publish(every). Here 2 seconds.

How to use it ?

You would use it with Combine like this :

PeriodicPublisher(.daily).getPublisher().sink { date in 
    print("Day has changed \(date.original)") 
}
Manel
  • 1,616
  • 19
  • 42