I have an Apple Watch complication that initializes correctly and show the data I am expecting. However, when the NSDate returned in my getNextRequestedUpdateDateWithHandler method triggers a refresh, the only method in my delegate that gets called again is the getNextRequestedUpdateDateWithHandler method (I set breakpoints at every method to determine this). I would have expected requestedUpdateDidBegin to get called when the requested update date occurs, but that doesn't seem to be the case. Does anyone know what could be causing this? Here is my code:
class ComplicationController: NSObject, CLKComplicationDataSource {
/// Provide the time travel directions your complication supports (forward, backward, both, or none).
func getSupportedTimeTravelDirectionsForComplication(complication: CLKComplication, withHandler handler: (CLKComplicationTimeTravelDirections) -> Void) {
handler(.Backward)
}
/// Depending on which time travel directions you support, you will be asked for the start/end dates of your timeline (or both, or neither).
/// The start/end dates will determine at what point during time travel we dim out your data to indicate that the timeline does not continue in this direction.
/// Timeline entries after the timeline end date or before the timeline start date will not be displayed.
func getTimelineStartDateForComplication(complication: CLKComplication, withHandler handler: (NSDate?) -> Void) {
let calendar = NSCalendar.currentCalendar()
let now = NSDate()
var startDate: NSDate? = nil
var interval: NSTimeInterval = 0
calendar.rangeOfUnit(NSCalendarUnit.WeekOfMonth, startDate: &startDate, interval: &interval, forDate: now)
handler(startDate)
}
func getTimelineEndDateForComplication(complication: CLKComplication, withHandler handler: (NSDate?) -> Void) {
handler(NSDate())
}
/// Indicate whether your complication's data should be hidden when the watch is locked.
func getPrivacyBehaviorForComplication(complication: CLKComplication, withHandler handler: (CLKComplicationPrivacyBehavior) -> Void) {
// Since this is showing health data, we want to secure this when the device is locked.
handler(.HideOnLockScreen)
}
/// Indicate your complication's animation behavior when transitioning between timeline entries.
func getTimelineAnimationBehaviorForComplication(complication: CLKComplication, withHandler handler: (CLKComplicationTimelineAnimationBehavior) -> Void) {
handler(.Always)
}
/// Provide the entry that should currently be displayed.
/// If you pass back nil, we will conclude you have no content loaded and will stop talking to you until you next call -reloadTimelineForComplication:.
func getCurrentTimelineEntryForComplication(complication: CLKComplication, withHandler handler: (CLKComplicationTimelineEntry?) -> Void) {
// ... custom entry code
handler(currentTemplate)
}
/// The owning complication will use these methods to extend its timeline backwards or forwards.
/// #param date The date of the first/last entry we already have. Return the batch of entries before/after this date.
/// #param limit Maximum number of entries to return.
func getTimelineEntriesForComplication(complication: CLKComplication, beforeDate date: NSDate, limit: Int, withHandler handler: ([CLKComplicationTimelineEntry]?) -> Void) {
//... custom entry code
handler(templates)
}
func getTimelineEntriesForComplication(complication: CLKComplication, afterDate date: NSDate, limit: Int, withHandler handler: ([CLKComplicationTimelineEntry]?) -> Void) {
handler([CLKComplicationTimelineEntry]())
}
/// Return the date when you would next like to be given the opportunity to update your complication content.
/// We will make an effort to launch you at or around that date, subject to power and budget limitations.
func getNextRequestedUpdateDateWithHandler(handler: (NSDate?) -> Void) {
// Refresh in 30 minutes
let refreshDate = NSDate().dateByAddingTimeInterval(60*30)
handler(refreshDate)
}
/// This method will be called when you are woken due to a requested update. If your complication data has changed you can
/// then call -reloadTimelineForComplication: or -extendTimelineForComplication: to trigger an update.
func requestedUpdateDidBegin() {
let complicationServer = CLKComplicationServer.sharedInstance()
for complication in complicationServer.activeComplications {
complicationServer.reloadTimelineForComplication(complication)
}
}
/// This method will be called when we would normally wake you for a requested update but you are out of budget. You can can
/// trigger one more update at this point (by calling -reloadTimelineForComplication: or -extendTimelineForComplication:) but
/// this will be the last time you will be woken until your budget is replenished.
func requestedUpdateBudgetExhausted() {
let complicationServer = CLKComplicationServer.sharedInstance()
for complication in complicationServer.activeComplications {
complicationServer.reloadTimelineForComplication(complication)
}
}
/// When your extension is installed, this method will be called once per supported complication, and the results will be cached.
/// If you pass back nil, we will use the default placeholder template (which is a combination of your icon and app name).
func getPlaceholderTemplateForComplication(complication: CLKComplication, withHandler handler: (CLKComplicationTemplate?) -> Void) {
//... custom template code
handler(template)
}
}
The endDate is the latest date for which your complication data source is prepared to supply data. If your end date has passed, this implies two things:
The latest timeline entry would be dimmed, as the end of the timeline has been reached.
The complication server would realize that it would be pointless to ask for entries past your endDate as no future entries could possibly exist. In that case, the complication server will only ask for a new update date, as you notice.
The complication server uses a 72-hour sliding window, to ensure 24 hours of time travel in either direction.
Even though you do not support forward time travel, you should ensure a future endDate for a couple of reasons.
If you never want your current entry to be dimmed, the end date must not be reached before the timeline is reloaded.
The complication server must know that your data source can be asked for additional entries, since your timeline is still current.
Each time the timeline is reloaded, it will ask for a new start and end date. Your end date never has to be in the distant future since at best it will be updated every 30 minutes, but it should still be a day in the future, in case your daily budget is exhausted due to frequent updates.
As an aside, the complication server does factor in the time travel boundary dates to determine whether an entry should be added to the timeline.
earliestTimeTravelDate
When constructing your timeline, do not create any entries before this date. Doing so is a waste of time because those entries will never be displayed.
If you're providing entries that go back to the start of a week, you may want to avoid creating entries outside the time travel boundary, as to not exhaust your budget.
Related
My question is the following:
Can I change the HKWorkoutConfiguration.activityType during a
HKWorkoutSession or does each HKWorkoutSession has to have its own
HKWorkoutConfiguration.activityType?
I want to create a workout app where you can create a workout consisting of different sets with different activity types. For example a Shadowing Boxing Workout, consisting of 3 sets of Boxing and 3 sets of Kickboxing (Boxing and Kickboxing are the different activities).
Ideally I would just start the HKWorkoutSession once at the beginning and end it after all sets for each activity are done, changing the HKWorkoutConfiguration.activityType in-between.
My current approach is based on the sample provided by Apple: https://developer.apple.com/documentation/healthkit/workouts_and_activity_rings/speedysloth_creating_a_workout
I adjusted the startWorkout() method to startWorkout(for type: String). It now looks like this:
// Start the workout.
func startWorkout(for type: String) {
// Start the timer.
setUpTimer()
self.running = true
// Create the session and obtain the workout builder.
/// - Tag: CreateWorkout
do {
session = try HKWorkoutSession(healthStore: healthStore, configuration: self.workoutConfiguration(for: type))
builder = session.associatedWorkoutBuilder()
} catch {
// Handle any exceptions.
return
}
// Setup session and builder.
session.delegate = self
builder.delegate = self
// Set the workout builder's data source.
/// - Tag: SetDataSource
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore,
workoutConfiguration: workoutConfiguration(for: type))
// Start the workout session and begin data collection.
/// - Tag: StartSession
session.startActivity(with: Date())
builder.beginCollection(withStart: Date()) { (success, error) in
// The workout has started.
}
print("New Workout has started")
}
In the method I get the respective activity by workoutConfiguration(for: type) which looks up the right activity from a string.
After a set is done (e.g. the boxing set), I end the session and start a new workout and session for the new set with the new activity.
My problem with the current approach is that I need to end the current HKWorkoutSession before I start the new one. But ending the session the way its done in the example does not execute immediately and therefore the new set of the workouts starts without saving the old set to the HKStore with the right activity.
Therefore I thought I would be nice to start the session just once and switch activityTypes in-between. However, I don't know if it is possible (maybe complications with HKStore) and how it is done.
Or is there any other smart way of doing things to achieve this?
I'm just starting out with iOS Programming.
Any help is greatly appreciated!
Disclaimer: This isn't a full answer, but an approach I'd take to address the problem.
Look at the workoutSession(_:didChangeTo:from:date:) method. Documentation Link
When one type of workout ends that method will receive a notification. As long as you haven't called session.end() the workout session should still be active. Use your healthStore instance to save the previous workout session before starting a new one.
It could look like this;
func workoutSession(_ workoutSession: HKWorkoutSession, didChangeTo toState: HKWorkoutSessionState, from fromState: HKWorkoutSessionState, date: Date) {
if toState == .ended {
workoutBuilder.endCollection(withEnd: date) { (success, error) in
// collection has ended for one type of workout
self.workoutBuilder.finishWorkout { (workout, error) in
// unwrap the optional `workout`
guard let completedWorkout = workout else { return }
// save workout to health store
healthStore.save(completedWorkout) { (success, error) in
if success {
// begin a new workout by calling your method to start a new workout with a new configuration here
startWorkout(for type: String)
}
}
}
}
}
}
That is likely going to cause issues with your Timer since you're calling setUpTimer again but you can work with that. Also this doesn't address the point when the user is completely done with their workout and doesn't want to start a new workout type.
We are trying to update our home widget around 4 to 5 times an hour within a 10 hours period a day after our application reaches a given state (we can not predict when exactly this will happen).
When the application is in the foreground, everything works fine and as expected, is the application in the background though, the widget gets refresh maybe once an hour or not at all (background processing is enabled and working properly) . It feels totally random and currently we simply can't retrace or comprehend what is happening or why it is happening.
This is our TimelineProvider:
struct Provider: TimelineProvider {
func getSnapshot(in context: Context, completion: #escaping (SimpleEntry) -> Void) {
let preferences = UserDefaults.init(suiteName:widgetGroupId)
let title = preferences?.string(forKey: "title")
let entry = SimpleEntry(date: Date(), title: title ?? "")
completion(entry)
}
func placeholder(in context: Context) -> SimpleEntry {
...
}
func getTimeline(in context: Context, completion: #escaping (Timeline<Entry>) -> ()) {
getSnapshot(in: context) { (entry) in
let timeline = Timeline(entries: [entry], policy: .never)
completion(timeline)
}
}
}
Our application tries to update the home widget via WidgetCenter.shared.reloadAllTimelines().
I have now the following questions:
How can I understand why the refresh is not working and when it will be possible again? Is there something like a cooldown time?
How can I check the refresh budget counter? Can we obtain it somehow in the code?
How can I reset the budget counter for testing? Shall I recreate the widget? Restart the phone? Reinstall the app?
From my experience, calling reloadAllTimelines() from anywhere but the foreground app seems to be unreliable. Instead of setting your timeline expiration policy to .never, try using .after with a date 15 minutes or so in the future. That should ensure your widget is updated ~4x an hour with the latest data. Hopefully that’s close enough for your use case.
I want to measure how long (in seconds) it takes users to do certain things in my app. Some examples are logging in, pressing a button on a certain page, etc.
I am using an NSTimer for that. I am starting it in the viewDidLoad of a specific page, and stopping it at the point that I want to measure.
I also want to measure cumulative time for certain things. I would like to start the timer on the log-in screen, and then continue the timer until the user gets to the next view controller and clicks on a certain button.
I'm not sure how to do this. Should create a global variable in my app delegate? Or is there some other better way?
No need for an NSTimer, you just need to record the start times and compare them to the stop times. Try using a little helper class such as:
class MyTimer {
static let shared = MyTimer()
var startTimes = [String : Date]()
func start(withKey key: String) {
startTimes[key] = Date()
}
func measure(key: String) -> TimeInterval? {
if let start = startTimes[key] {
return Date().timeIntervalSince(start)
}
return nil
}
}
To use this, just call start(withKey:) right before you start a long-running task.
MyTimer.shared.start(withKey: "login")
Do something that takes a while and then call measure(key:) when you're done. Because MyTimer is a singleton, it can be called from anywhere in your code.
if let interval = MyTimer.shared.measure("login") {
print("Logging in time: \(interval)")
}
If you're using multiple threads, you may to to add some thread safety to this, but it should work as is in simple scenarios.
I'm looking for a way to detect workout detect / stop using HealthKit and it seems there is no way to detect it.
In Android you get "ACTION_SESSION_START" and "ACTION_SESSION_END" for sessions.
Has anyone tried detecting workout start / stop?
Thank you for your time!
There is no API for observing workouts being recorded by another app.
I was thinking you could detect events. Not save a workout.
In HealthKit you can create workout events. Therefore you could theoretically code something to detect pause/resume events.
Create a running workout that goes for ___ time.
let finish = NSDate() // Now
let start = finish.dateByAddingTimeInterval(0000) // workout time
let workout = HKWorkout(activityType: .Running, startDate: start, endDate: finish)
Create Pause (stop) and Resume (start) events.
let workoutEvents: [HKWorkoutEvent] = [
HKWorkoutEvent(type: .Pause, date: startDate.dateByAddingTimeInterval(000)),
HKWorkoutEvent(type: .Resume, date: startDate.dateByAddingTimeInterval(000))
]
Then you need to alter the creation of the HKWorkout object to use the more complex constructor, which allows you to include workoutEvents.
let workout = HKWorkout(
activityType: .Running
startDate: start,
endDate: end,
workoutEvents: workoutEvents,
device: nil,
metadata: nil
)
At this point you would normally pass the workout to HKHealthStore.saveObject to save it like this.
healthStore.saveObject(workout) { (success: Bool, error: NSError?) -> Void in
if success {
// Workout was saved
}
else {
// Workout was not saved
}
}
But
in your case you don't want to save. You want to detect events. could have a switch statement that could detect a Pause or Resume.
I'm not sure about the specific dateByAddingTimeInterval values you would want but it's definitely something you could experiment with using zero values maybe? Because technically 00 is still an event.
Workout events toggle a workout object between an active and an inactive state. You could create a method detectWorkoutEvent.
I am trying to build a watchOS 2 complication that displays a user's health data, such as steps (but in theory it should be able to display any health data the user has given the app permission to view). When the complication first launches, I can query Healthkit and get all the data I want because the first launch is considered to be in the foreground. However, I am having trouble retrieving the HealthKit data in the background when new health data is available. There are two places I could get this data, the watch and the iPhone.
I have tried to get the data from the watch itself when the complication's background refresh is triggered from the date set in getNextRequestedUpdateDateWithHandler. However, when I call HKHealthStore's execute method, it does not return any query results if the app (or in this case the complication) is running the background. I have also tried to setup an HKAnchoredObject query that should return my results immediately when the process resumes, but this also doesn't seem to return any results unless I manually launch the app extension on the watch. Here is my watch code, called from my ExtensionDelegate's init method after health kit permissions are requested:
func setupComplicationDataCache() {
let now = NSDate()
var startDate: NSDate? = nil
var interval: NSTimeInterval = 0
self.calendar.rangeOfUnit(NSCalendarUnit.Day, startDate: &startDate, interval: &interval, forDate: now)
let stepSampleType = HKQuantityType.quantityTypeForIdentifier(HKQuantityTypeIdentifierStepCount)!
// Match samples with a start date after the workout start
let predicate = HKQuery.predicateForSamplesWithStartDate(startDate, endDate: nil, options: .None)
let query = HKAnchoredObjectQuery(type: stepSampleType, predicate: predicate, anchor: nil, limit: 0) { (query, samples, deletedObjects, anchor, error) -> Void in
// Handle when the query first returns results
self.handleStepResults(query, samples: samples, deletedObjects: deletedObjects, anchor: anchor, error: error)
}
query.updateHandler = { (query, samples, deletedObjects, anchor, error) -> Void in
// Handle update notifications after the query has initially run
self.handleStepResults(query, samples: samples, deletedObjects: deletedObjects, anchor: anchor, error: error)
}
self.healthStore.executeQuery(query);
}
func handleStepResults(query: HKAnchoredObjectQuery, samples: [HKSample]?, deletedObjects: [HKDeletedObject]?, anchor: HKQueryAnchor?, error: NSError?) {
if error != nil {
self.timelineModel.currentEntry = TimelineEntryModel(value: NSNumber(int: -1), endDate: NSDate())
} else if samples == nil || samples?.count == 0 {
self.timelineModel.currentEntry = TimelineEntryModel(value: NSNumber(int: 0), endDate: NSDate())
} else {
let newStepSamples = samples as! [HKQuantitySample]
var stepCount = self.timelineModel.currentEntry.value.doubleValue
var currentDate = self.timelineModel.currentEntry.endDate
// Add the current entry to the collection of past entries
self.timelineModel.pastEntries.append(self.timelineModel.currentEntry)
// Add all new entries to the collection of past entries
for result in newStepSamples {
stepCount += result.quantity.doubleValueForUnit(self.countUnit)
currentDate = result.endDate
self.timelineModel.pastEntries.append(TimelineEntryModel(value: NSNumber(double: stepCount), endDate: currentDate))
}
// Retrieve the latest sample as the current item
self.timelineModel.currentEntry = self.timelineModel.pastEntries.popLast()
if self.timelineModel.currentEntry == nil {
self.timelineModel.currentEntry = TimelineEntryModel(value: NSNumber(int: -3), endDate: NSDate())
}
}
// Reload the complication
let complicationServer = CLKComplicationServer.sharedInstance()
for complication in complicationServer.activeComplications {
complicationServer.reloadTimelineForComplication(complication)
}
}
I have also tried to get the data from the iPhone using HKObserverQuery. I have the observer query that can wake up the iPhone once an hour (the max time for step data). However, if the iPhone is locked when the observer completion handler executes my step query, HKHealthStore's execute method also refuses to return any query results. I think this makes sense here and there is probably not a way around this because Apple's docs mention that the Health Store is encrypted when a device is locked and you can't read from the store (only write). BUT in the watch's case when it is on someones wrist it is not locked, the screen is just turned off.
Does anyone know how to get HealthKit updates to show up on a complication when a refresh happens in the background, either in iOS or on watchOS 2?
After extensive testing I have determined that this currently is not possible. On watchOS 2, Apple seems to have completely disabled HealthKit queries from returning results when an extension or complication is running in the background. This includes execution from remote notifications, Watch Connectivity, and from the complications scheduled refresh. The iPhone's HealthKit queries fail if the screen is off and the device has a passcode set. The queries fail because the health data store is encrypted when the device is locked. The queries fail even if observer queries and background delivery is enabled. You can get notified that something changed, but you can't query for the changes until the iPhone is unlocked (because again, the data is encrypted).
Other apps that show healthkit related data, such as steps and walk+run distance do so by querying the pedometer (CMPedometer) directly, whose data is accessible in these background modes.
One could make a complication that updates in the background exclusivly for iPhone users who do not have a passcode set on their device, but this seems like a terrible idea to promote.