Implementing a reimagined user interface: a custom monospaced font timer
Apple provides developers with a rich tool set for creating workouts apps that integrate with Apple’s Health system. One of the main objects on Apple Watch is the HKLiveWorkoutBuilder object which incrementally constructs a workout using data from a HKWorkoutSession. HKLiveWorkoutBuilders conveniently provide a method for getting the current workout duration - the elapsedTimeAtDate: method. When you pass in the current date, or [NSDate now], the live workout builder will return the duration of the workout, excluding any time when the workout was paused.
Apple also provides developers with a nice set of user interface objects for displaying data on an Apple Watch, including a timer object, WKInterfaceTimer. An interface timer displays either a count down or count up timer, depending on how it is configured, and can be formatted to display times in a range of formats. The problem is that there is no means to link the time displayed in a WKInterfaceTimer to the current workout time, or duration, in a HKLiveWorkoutBuilder. Apple also cautions developers that the elapsed time value provided by the live workout builder can decrease in some cases when a pause event is added to a workout. This makes it challenging to insure that the time displayed to a user through an interface timer exactly matches the live workout builder.
To address this issue we created a customer timer object that could be connected a standard user interface label object to a live workout builder to insure that the displayed time precisely matched the workout duration.
Conceptually, the implementation of the custom timer is a subclass of NSObject that uses either a NSTimer or dispatch timer to repeatedly check the elapsedTimeAtDate: for a connected HKLiveWorkoutBuilder. The custom timer then provides an updated, formatted time string to a WKInterfaceLabel in a user interface storyboard.
The dispatch_source_t is configured to run on the main queue with an a tightly toleranced updated interval to insure that the time display is updated smoothly without any apparent variation in the update time. Using a tight tolerance increases the system energy use, but insures that the time updates appear precise. Since the screen is only visible for a few seconds at a time, and the timer only updates when the screen is visible to the user, the increased energy use from the tight time tolerance is minimal.
NSTimeInterval updateTimeInterval = (1.0 / 5.0);
dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
__weak __typeof(self) weakSelf = self;
dispatch_source_set_event_handler(timer, ^{
__strong __typeof(self) strongSelf = weakSelf;
NSAttributedString *displayString = [strongSelf updateTime];
strongSelf.timeString = displayString;
});
dispatch_source_set_timer(timer, dispatch_walltime(NULL, 0), updateTimeInterval * NSEC_PER_SEC, 0.05 * NSEC_PER_SEC);
//dispatch_source_set_timer(timer, DISPATCH_TIME_NOW, updateTimeInterval * NSEC_PER_SEC, 0.05 * NSEC_PER_SEC);
// Start Timer (Resume)
dispatch_resume(timer);
The updateTime method gets the current elapsed workout time from the live workout builder, computes the hours, minutes, seconds, tenths, and hundredths to display, then creates a formatted time string.
-(NSAttributedString *)updateTime
{
NSAttributedString *timeString = nil;
// Update Time Can be Called on Main Thread or a Background Thread
Float64 timerTime = [self.liveWorkoutBuilder elapsedTimeAtDate:[NSDate now]];
// Calculate Hours, Minutes, and Seconds
NSInteger hours = (NSInteger) (timerTime / 3600) % 3600;
NSInteger minutes = (NSInteger) (timerTime / 60) % 60;
NSInteger seconds = (NSInteger) timerTime % 60;
// Only Update if Time Values are Positive
if (hours >= 0.0 && minutes >= 0.0 && seconds >= 0.0)
{
NSString *timeStringText = [NSString stringWithFormat:@"%01zd:%02zd:%02zd", hours, minutes]; // Hours, Minutes, Seconds
timeString = [[NSAttributedString alloc] initWithString:timeStringText attributes:TextTimer.textAttributes];
}
else
{
NSString *timeStringText = [NSString stringWithFormat:@"-:--:--"]; // Hours, Minutes, Seconds
timeString = [[NSAttributedString alloc] initWithString:timeStringText attributes:TextTimer.textAttributes];
}
}
Now, it’s possible to use a WKInterfaceTimer in much the same way that the WKInterfaceLabel is used in the above implementation by using a timer loop to repeatedly call setDate: on the WKInterfaceTimer. The advantage of using a WKInterfaceLabel is the extra formatting options available through WKInterfaceLabel setAttributedText: method to set the label text with all of the rich and detailed text formatting attributes offered in Apple’s CoreText framework. There are a few limited options to change the text formatting of WKInterfaceTimer through a storyboard, but you can only change the text color programmatically. WKInterfaceLabel offers nearly unlimited programatic control of the text formatting. For example, the custom timer formats the time using the monospaced numbers format option. Modern fonts use individually varying spacing between each character type to enhance readability on a printed page. So, the time 1:11:11 will take up less space on the screen that 2:22:22. But, if you use standard spacing between the numbers, as the time shifts from 1:11:59 to 1:12:00, you will notice that the centered text label will appear to shift on the screen - you will notice the colons (‘:’) will shift position slightly. By setting the monospaced numbers format option, each number will have the same text spacing between each digit, and the digits will stay in a fixed position on screen as the time changes. The text formatting options are set based on the desired font size, color, alignment, and features.
+(NSDictionary *)textAttributesForFontSize:(Float32)fontSize
fontColor:(UIColor *)fontColor
textAlignment:(NSTextAlignment)textAlignment
monospaced:(BOOL)monospaced
italic:(BOOL)italic
bold:(BOOL)bold
{
// Create Updated Font
UIFont *originalFont = nil;
if (bold)
{
originalFont = [UIFont boldSystemFontOfSize:fontSize];
}
else if (italic)
{
originalFont = [UIFont italicSystemFontOfSize:fontSize];
}
else if (monospaced)
{
if (bold)
{
originalFont = [UIFont monospacedDigitSystemFontOfSize:fontSize weight:UIFontWeightBold];
}
else
{
originalFont = [UIFont monospacedDigitSystemFontOfSize:fontSize weight:UIFontWeightRegular];
}
}
else
{
originalFont = [UIFont systemFontOfSize:fontSize weight:UIFontWeightRegular];
}
UIFont *font = originalFont;
if (monospaced)
{
// Setup Font Features (SFNLayoutTypes.h)
NSArray *fontFeatures = @[@{UIFontFeatureTypeIdentifierKey : @(kNumberSpacingType), UIFontFeatureSelectorIdentifierKey : @(kMonospacedNumbersSelector)}];
// Update Font Descriptor
UIFontDescriptor *originalDescriptor = [originalFont fontDescriptor];
UIFontDescriptor *updatedDescriptor = [originalDescriptor fontDescriptorByAddingAttributes: @{UIFontDescriptorFeatureSettingsAttribute : fontFeatures}];
// Setup Font from Descriptor
font = [UIFont fontWithDescriptor:updatedDescriptor size:fontSize];
}
// Setup Font Baseline Offset Value
Float32 fontBaselineOffset = font.descender / 2;
// Setup Paragraph Alignment Style
NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init];
paragraphStyle.alignment = textAlignment;
paragraphStyle.lineSpacing = 0;
paragraphStyle.minimumLineHeight = fontSize;
paragraphStyle.maximumLineHeight = fontSize;
// Setup Font Color & Kerning
Float32 fontKerning = 0.0;
// Create String Attributes Dictionary
NSDictionary *stringAttributes = @{NSFontAttributeName : font, NSForegroundColorAttributeName : fontColor, NSKernAttributeName : @(fontKerning), NSParagraphStyleAttributeName : paragraphStyle, NSBaselineOffsetAttributeName : @(fontBaselineOffset)};
return stringAttributes;
}
The Custom Timer object facilitates a direct connection between the HKLiveWorkoutBuilder elapsedTime and the displayed time in the user interface. This insures that the displayed time always matches the actual, recorded workout elapsed time, while also providing improved time formatting options.