Implementing a reimagined user interface: buttons

As noted in the second installment in the series of posts on the reimagined World Champ Tech workout apps interface, standard Apple Watch user interface buttons have a significant shortcoming: they don’t work terribly well when the screen is wet from perspiration or rain. If you are working out, you are probably going to get sweaty, and that perspiration is going to make standard buttons perform inconsistently. Apple provided developers with a mechanism for creating context menus that responded to deep press touches, that worked more reliably than standard buttons, but then deprecated those context menus beginning with WatchOS 7. We solved this issue by creating custom buttons that could be triggered either with a button tap or long press gesture. This post provides an in-depth explanation of how these round buttons are implemented using Objective-C, storyboards, and Apple’s WatchKit.

Context menu with record & interval controls

The custom Watch Interface buttons combine standard Xcode Watch Storyboard elements with custom WatchKit code in Objective-C to create smoothly animating buttons that mimic standard Apple buttons, but respond to long press (force touch) gestures as well as tap gestures, and have a round shape. They are implemented with a set of nested WKInterfaceGroup objects containing a WKInterfaceImage object. Both interface groups have equal fixed widths and heights, and the radius of each is set to half of the size of the button. Both have horizontal and vertical center alignment, horizontal layout, and default insets. The outer button group’s color is set to transparent, and the inner button group’s color is set to the button color. The image is tinted white, center aligned, and set with a relative width and height of 0.67, or 2/3rds of the height of its containing (inner) button group object. Setting the radius value to 1/2 of the width and height creates the round shape of the button.

Storyboard custom button interface object structure

To animate the buttons, both buttons are connected to IBOutlet connections in the interface controller displaying the buttons.

@property (strong) IBOutlet WKInterfaceGroup *outerButtonGroup;
@property (strong) IBOutlet WKInterfaceGroup *innerButtonGroup;

The outer button group also contains two gesture recognizers: a WKTapGestureRecognizer set to respond to one tap gesture, and a WKLongPressGestureRecognizer set to respond to 0 taps with 0.5 min duration and movement 10. Note that these are the default settings for a tap and long press gesture recognizer. Both gesture recognizers are connected to a single button pressed IBAction method.

-(IBAction)buttonPressed:(id)sender
{
    [self animateButtonGroupPressForOuterButtonGroup:self.outerButtonGroup innerButtonGroup:self.innerButtonGroup forSender:sender completion:^{
        // Code to Perform on Button Press Completion
    }];
}

The first step in the animateButtonGroupPressForOuterButtonGroup:innerButtonGroup:forSender:completion: method is to verify that a button press actually occurred. For a WKLongPressGestureRecognizer this occurs when the gesture state transitions to ‘began’, or WKGestureRecognizerStateBegan. For a WKTapGestureRecognizer this occurs when the gesture state transitions to either ‘ended’ (WKGestureRecognizerStateEnded) or ‘recognized’ (WKGestureRecognizerStateRecognized).

if (sender)
{
    BOOL gestureTriggered = NO;

    if ([sender isKindOfClass:[WKLongPressGestureRecognizer class]])
    {
        WKLongPressGestureRecognizer *gestureRecognizer = (WKLongPressGestureRecognizer *) sender;

        // Continuous gesture recognizers call state began when triggered. System does not call action method for state failed messages.

        if (gestureRecognizer.state == WKGestureRecognizerStateBegan)
        {
            gestureTriggered = YES;
        }
    }
    else if ([sender isKindOfClass:[WKTapGestureRecognizer class]])
    {
        WKTapGestureRecognizer *gestureRecognizer = (WKTapGestureRecognizer *) sender;

        // Discrete gesture recognizers call state ended when triggered on iOS and state recognized on watchOS. Simulator send state ended message. System does not call action method for state failed messages.

        if (gestureRecognizer.state == WKGestureRecognizerStateEnded || gestureRecognizer.state == WKGestureRecognizerStateRecognized)
        {
            gestureTriggered = YES;
        }
    }

    if (gestureTriggered)
    {
        // Animate Button
    }
}

The animation code is a bit more complex. First, WatchKit only allows for the animation of the alpha value (opacity) of an object, the width and height of the object, the horizontal and vertical alignment of the object, the background or tint color, or the group content insets. From testing, animating the width and height of a WKInterfaceGroup did not change the radius of the group, so unpleasant animation artifacts would occur. To create the button press animation where the button size shrinks, the insets for the outer group are increased from the default value of 0, which animates both the change in width and height of the inner group, along with the radius value of the inner group. Additionally, the animation requires two steps: first you need to animate the insets to decrease in size and change the button to a darker color, then you need to reverse these steps to return to the original button state.

Two-step button animation

Unfortunately, Apple’s WatchKit animation methods don’t include a completion callback like similar animation methods on iOS with UIKit. We resolved this by using Apple’s Grand Central Dispatch (GCD) concurrent code execution methods to wait for the animations to complete. For each animation step, we created a dispatch group, began the animation, entered the dispatch group, and the enqueue a small code block on the default queue to leave the dispatch group after the same duration as the animation time. On completion, the another code block is performed on the main queue using dispatch notify.

dispatch_group_t forwardAnimationGroup = dispatch_group_create();

NSTimeInterval forwardAnimationTime = 0.1;

[self animateWithDuration:forwardAnimationTime animations:^{

    [outerGroup setContentInset:UIEdgeInsetsMake(2, 2, 2, 2)];

    [innerGroup setBackgroundColor:[UIColor colorNamed:@"ActivatedButtonColor"]];
}];

dispatch_group_enter(forwardAnimationGroup);

dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(forwardAnimationTime * NSEC_PER_SEC)), dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{
    dispatch_group_leave(forwardAnimationGroup);
});

dispatch_group_notify(forwardAnimationGroup, dispatch_get_main_queue(), ^{

    dispatch_group_t reverseAnimationGroup = dispatch_group_create();

    dispatch_group_enter(reverseAnimationGroup);

    NSTimeInterval reverseAnimationTime = 0.1;

    [self animateWithDuration:reverseAnimationTime animations:^{

        [outerGroup setContentInset:UIEdgeInsetsZero];

        [innerGroup setBackgroundColor:[UIColor colorNamed:@"ButtonColor"]];
    }];

    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(reverseAnimationTime * NSEC_PER_SEC)), dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{
        dispatch_group_leave(reverseAnimationGroup);
    });

    dispatch_group_notify(reverseAnimationGroup, dispatch_get_main_queue(), ^{

        // Call Completion

        if (completion)
        {
            completion();
        }
    });
});

Combing all of the code and storyboard elements produced a custom, round Apple Watch interface button that would respond to either taps or force touch, and smoothly animate in fashion to mimic the standard Apple WatchKit WKInterfaceButtons

Custom round Apple Watch interface button

Previous
Previous

Implementing a reimagined user interface: crown swipes

Next
Next

Reimagining a User Interface, Part 5