Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Ambient Mode On Demand for Android Wear

0.00/5 (No votes)
10 Oct 2015 1  
Give your users the option of keeping your smartwatch app open when the device sleeps.

Introduction

In May 2015, Google made an exciting change to Android Wear, supporting ambient mode for third-party apps.

The operating system has always had something called ambient mode: it was the low-power state that a wearable would return to after some period of user inactivity. But historically, if you had an app open when the watch went to ambient, that app would close (and the watch face would come to the fore).

The new “ambient mode for apps” meant that you could flag your app to stay on screen after the watch went to ambient. It was a logical extension of Google’s vision for smartwatches as being centred around “glanceable” information: if you have an app that the user might want to refer to for longer than the interactive-mode timeout, you could now enable it for ambient mode, and it’d remain visible until the user closed it manually. Wearables are all about quick and easy access to information - this feature takes that idea one step further, extending the inherent convenience of smartwatches to third-party apps.

Ambient mode on demand

In Google’s vision, ambient mode is all or nothing - either it makes sense for your app, or it doesn’t. You make that decision at design-time, and if it’s a yes, you build the behavior into your app. A good example is the default Timer app on Wear: if you start a timer for (say) ten minutes, the app stays on the screen in ambient mode while that timer is running, rather than dimming back to your watch face after thirty seconds. When the timer has finished, the app is done - and so is its presence in ambient mode.

But what if your app needs more flexibility than this? What if you can’t predict at design-time whether the user might want ambient mode, or not? I faced a situation like this with my Wearable Widgets app: depending on what widget the user has chosen to put on his watch, ambient mode might or might not be useful. There’s just no way for me to predict it.

An obvious solution might be to build it into a settings screen someplace, to allow the user to turn ambient mode on or off. And this was the route I initially went. But when I dogfooded this implementation, I found it cumbersome and inefficient; I would switch between widgets on the fly, and having to go back into a settings screen to toggle ambient mode was a pain.

What I wanted, in short, was ambient mode on demand. At any given moment, to be able to tell the watch to keep its current content on screen, until I told it to stop. So I built a UI to make this happen - and in this article, I’ll help you add this same capability to your own Android Wear app.

Prerequisites: I assume here that you have some experience building Android apps, and ideally a little bit developing for Android Wear. There are plenty of good tutorials online to get you started on either, but bringing a beginner up to speed on everything is beyond the scope of this article. 

User interface design

My vision for how to achieve ambient-on-demand was based on long-press. This is a gesture with a long history in Android UI design for surfacing additional options, and this fit right in with my plan to add an Ambient option.

On Android Wear specifically, the long-press gesture is generally recommended to provide access to an Exit action, to close the app. This means that most well-written Wear apps shouldn’t have other functionality tied into long-press; in other words, this gesture shouldn’t collide with existing functionality in apps.Exit action button

Except for the Exit action, of course. How to integrate my Ambient Mode action with the Exit behavior that users are expecting after a long-press in my app?

Again, I took my lead from the overall UI design of Android Wear: parallel actions are frequently presented as full-screen action buttons, with a horizontal swipe to move between them. Since the standard Exit UI is effectively an action button already, this was a natural fit: just add a second “button” for Ambient Mode.

With this overall design in hand, all I needed was an icon for my Ambient Mode action button. This turned into a bit of a challenge, as I couldn’t find a standardized image that’s associated with ambient mode; as a concept, it’s something Google created for Android Wear, and they apparently haven’t had a need to make it graphical so far.

After some searching, the closest analog I could find was the standard icon for suspend, as used for sending a computer to a low-power standby mode. Ambient isn’t exactly the same as standby, but I decided that it was probably the best fit I could hope for, within the realm of standard icons that users would recognize.Suspend icon

I’m not much of an artist, but a simple line-and-circle design like this was within even my capabilities, and after a few minutes with GIMP I’d created a version of the standby icon that was (more or less) in conformance with Android Wear action button specs. You’ll see it in a screenshot a bit later.

And with that, I was ready to begin implementation. Let’s write some code!

The WearActionButton class

The first step in converting a design to a working app component is to build the required layouts. For this project, there’s really only one: the full-screen action button, as shown in the Exit screenshot above.

Ideally, we’d like this to look as much like the Wear system’s own action buttons as possible - but when creating this implementation, I discovered that Wear’s action buttons aren’t well standardized. Google has developed design guidelines, but the action buttons that the system generates (attached to notifications, for example) don’t exactly follow them.

I went back and forth on this, spent some time trying to replicate the system buttons, but eventually decided to go with the published guidelines instead. In no small part, because of this StackOverflow answer, which supplied a full-blown layout file for a guideline-compliant action button. You just need to save the two XML files from there to your own res subdirectories (they’re also in the ZIP file attached to this article).

Note that the SO answer linked above includes a Fragment for implementing the action button itself. For my implementation, a Fragment wasn’t the best way to go, so I adapted that code into a simple LinearLayout subclass. Here it is:

public class WearActionButton extends LinearLayout {
    private CircledImageView vIcon;
    private TextView vLabel;

    public WearActionButton(Context context) {
        this(context, null, 0, 0);
    }

    public WearActionButton(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        LayoutInflater.from(context).inflate(R.layout.wear_action_button, this);
        vIcon = (CircledImageView) findViewById(R.id.icon);
        vLabel = (TextView) findViewById(R.id.label);
    }

    public void setIcon(int iconResId) {
        vIcon.setImageResource(iconResId);
        if (iconResId == R.drawable.ic_full_cancel) {
            // Override the default blue button color with red (for Exit)
            vIcon.setCircleColorStateList(
                    getContext().getResources().getColorStateList(R.color.dismiss_button));
        }
    }

    public void setLabel(int labelResId) {
        vLabel.setText(labelResId);
    }
}

It’s really pretty straightforward. In the constructor, it inflates the wear_action_button.xml file into the underlying LinearLayout, and initializes two fields for later use. And it declares a couple of public set methods for use by the containing class, which we’ll see in the next section.

There’s just one more file you’ll need to make WearActionButton work, and that’s dismiss_button.xml:

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:color="#ff5151" android:state_pressed="false" />
    <item android:color="#b83120" android:state_pressed="true" />
</selector>

By default, the action buttons have a blue color, declared in action_button.xml from the StackOverflow answer. This file simply sets up an equivalent color selector for the Exit (dismiss) button’s red disc.

The OverlayView class

We’re now ready to assemble our WearActionButtons into the desired horizontal-swipe container. Android has a framework class that’s reasonably well-suited to this pattern - the ViewPager - but making it behave as we’d like will take a bit of work. I call my ViewPager descendant OverlayView, because it overlays the other content in the parent activity.

[Note: for clarity I’m going to introduce OverlayView in sections here. For the full listing, please see the attached ZIP file.]

We begin with some pretty basic, almost boilerplate code, initializing the class and declaring its fields:

public class OverlayView extends ViewPager {
    private WearActionButton ambientView, dismissView;
    private OverlayAdapter adapter;

    public OverlayView(final Context context, AttributeSet attrs) {
        super(context, attrs);

        setBackgroundResource(android.support.wearable.R.color.dismiss_overlay_bg);
        setClickable(true);
        setVisibility(GONE);
    }
}

Not surprisingly, we have two WearActionButtons, for the Ambient Mode and Exit (dismiss) actions. Note also that the OverlayView is invisible by default; we’ll be making it visible in the next section.

If you’ve done any amount of Android development, most likely you’ve used an Adapter of some sort. Adapters are a basic pattern in Android that underlie virtually any UI made of repeating items (like a list). This description - a UI of repeating items - also applies to a ViewPager, so it should come as no surprise that it’s Adapter-driven.

The driver here is called a PagerAdapter, so that’s what we need to subclass to make our ViewPager work. I’ve called my class OverlayAdapter:

public class OverlayAdapter extends PagerAdapter {
   @Override
   public int getCount() {
       return 3;
   }

   @Override
   public Object instantiateItem(ViewGroup viewGroup, int page) {
       switch (page) {
           case 1:
               if (ambientView == null) {
                   // Initialize the "Enable Ambient" button
                   ambientView = new WearActionButton(getContext());
                   ambientView.setIcon(R.drawable.ic_full_standby);
                   ambientView.setLabel(R.string.enable_ambient);
                   ambientView.setOnClickListener(new OnClickListener() {
                       public void onClick(View v) {
                          setAmbient();
                       }
                   });

                   viewGroup.addView(ambientView);
               }
               return ambientView;

           case 2:
               if (dismissView == null) {
                   // Initialize the "Exit" button
                   dismissView = new WearActionButton(getContext());
                   dismissView.setIcon(R.drawable.ic_full_cancel);
                   dismissView.setLabel(R.string.exit);
                   dismissView.setOnClickListener(new OnClickListener() {
                       public void onClick(View v) {
                           if (getContext() instanceof Activity) {
                               ((Activity) getContext()).finish();
                           }
                       }
                   });

                   viewGroup.addView(dismissView);
               }
               return dismissView;
       }

       return null;
   }

   @Override
   public void destroyItem(ViewGroup viewGroup, int page, Object o) {
       switch (page) {
           case 1:
               ambientView = null;
               break;
           case 2:
               dismissView = null;
               break;
       }
   }

   @Override
   public boolean isViewFromObject(View view, Object obj) {
       return (view == obj);
   }
}

If you’ve used any Adapter before, the basic methods - getCount, instantiateItem and the like - should look familiar. If you haven’t, I suggest you familiarize yourself with this area of the platform; you’ll be needing it at some point in your Android career. Accordingly, I’m not going to dwell on the “boring” parts of this class

The interesting parts of this class are in the instantiateItem method. There - based on which page has been selected - we create a WearActionButton, initialize it for either Ambient Mode or Exit, and add it to the containing ViewGroup.

1037952/ambient.png

We also set up an onClick listener for each action button. The listener for the Exit (dismiss) button does exactly what you’d expect: closes the parent Activity. The listener for the Ambient Mode button calls a method called setAmbient: 

private void setAmbient(WearWidgetActivity activity) {
    final WearableActivity activity;
    if (getContext() instanceof WearWidgetActivity) {
        activity = ((WearableActivity) getContext());

        // Show confirmation to the user
        Intent intent = new Intent(activity, ConfirmationActivity.class);
        intent.putExtra(ConfirmationActivity.EXTRA_ANIMATION_TYPE,
                ConfirmationActivity.SUCCESS_ANIMATION);
        intent.putExtra(ConfirmationActivity.EXTRA_MESSAGE,
                activity.getString(R.string.ambient_enabled));
        activity.startActivity(intent);

        // Hide the OverlayView (after a short delay to avoid flicker)
        new Handler() {
            @Override
            public void handleMessage(Message msg) {
                setVisibility(GONE);
            }
        }.sendEmptyMessageDelayed(0, 250);

        // Turn on ambient mode for the parent activity
        activity.setAmbientEnabled();
    }
}

This is what it’s all about, the moment we’ve all been waiting for:

  1. We start a standard Wear confirmation activity, letting the user know that Ambient Mode has been enabled for this app.

  2. The small Handler merely hides the OverlayView, whose job is now done.

  3. Finally, the call to setAmbientEnabled tells the Wear framework to keep this Activity visible after the watch exits interactive mode.

So apparently, we’re done with OverlayView - but there is one last thing to mention before we move on.

If you were paying close attention to the OverlayAdapter listing above, you may have noticed something odd: the getCount method returns a value of 3. But we only have two pages to display, Ambient Mode and Exit. What’s going on here?

The answer requires just a bit of explanation. Recall that our design here is based on the recommended Exit button pattern - and one other aspect to that pattern is that it should be dismissable with a left-to-right swipe, in case the user long-presses but then decides not to exit the app. In essence, the user can “swipe away” the Exit button - a standard gesture in Wear.

When I first created my OverlayView, I struggled for a while with a clean way for the user to swipe it away. I found that the ViewPager consumed most swipe gestures, and wouldn’t let me attach a “dismiss” action to one.

So, I came up with a trick that accomplishes the same effect: I created a blank first page - to the left of the Ambient Mode action - that hides the overlay when the user swipes to it. Besides the code you’ve already seen in OverlayAdapter, there are just a couple of other pieces required to make this work, and they go into a setVisibility method on OverlayView:

@Override
public void setVisibility(int visibility) {
   if (visibility == VISIBLE) {
       adapter = new OverlayAdapter();

       setOnPageChangeListener(new OnPageChangeListener() {
           @Override
           public void onPageSelected(int page) {
               if (page == 0) {
                   // Blank first page selected - hide the overlay
                   setVisibility(GONE);
               }
           }

           // Required by interface, not needed in this implementation
           @Override
           public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {}
           @Override
           public void onPageScrollStateChanged(int i) {}
       });
   } else {
       setOnPageChangeListener(null);
       adapter = null;
   }

   setAdapter(adapter);
   setCurrentItem(1);    // skip the first (blank) page when initializing the adapter

   super.setVisibility(visibility);
}

First, note the OnPageChangeListener. Its job is simple: when the OverlayView changes to page 0 - the blank first page - hide the view. This is the “dismiss” action when the user has swiped the Ambient Mode action button off to the right.

And second, toward the end of this method is a call to setCurrentItem(1). This ensures that the OverlayView always opens to the first visible page, skipping the blank first page.

With that, we’re done with the OverlayView class, and most of our work. All that really remains is to show it to the user when she long-presses on the containing Activity.

Detecting the long-press

The Android framework makes fairly robust gesture detection available to developers in the GestureDetector class, so this was the obvious route for detecting long-presses in my Wear app. If you’ve developed an Android app with any sort of standard gesture UI - long-press, single-tap, double-tap, and so on - you’ve probably used GestureDetector. It’s no different on Wear, but I’ll walk you through the specifics for this project.

Implementing an instance of GestureDetector is actually a two-step process, also involving a GestureListener (specifically, the SimpleGestureListener is a good fit for our needs). You’ll first need to declare a class-level field for each, like this:

private GestureDetector gestureDetector;
private SimpleOnGestureListener gestureListener;

And then you’ll need to instantiate both variables. A fine place to do this is in your Activity’s onCreate method, but feel free to move these elsewhere as needed by your own code.

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    gestureDetector = new GestureDetector(this, gestureListener);

    gestureListener = new SimpleOnGestureListener() {
        public void onLongPress(MotionEvent e) {
            actionOverlay = (OverlayView) findViewById(R.id.action_overlay);
            actionOverlay.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS,
                 HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING);
            actionOverlay.setVisibility(View.VISIBLE);
        }
    }
}

The GestureDetector has a simple one-line constructor, but the SimpleGestureListener also needs the onLongPress handler defined, as shown here. When the user long-presses, we give them a bit of haptic feedback (vibration) to let them know the gesture has registered. We then create and show an OverlayView, as discussed in the previous section.

Our gesture handler is now ready to go, but in order for the user to interact with it, we need to attach it to the containing Activity. Again, this is pretty basic Android boilerplate:

@Override
public boolean onTouch(View view, MotionEvent event) {
    return gestureDetector.onTouchEvent(event);
}

By overriding the Activity’s onTouch handler, we can forward the touch event on to the GestureDetector, which will do the work of deciding if that touch is a long-press.

We have one last loose end to tie off before we’re done here. If the OverlayView has been shown, but then the containing Activity goes away before the user has interacted with it (say, they put their palm over the screen to return to the watch face), we want to ensure that the OverlayView isn’t still visible the next time the Activity is shown. We do this by simply hiding it in the Activity’s onStop method:

@Override
protected void onStop() {
    if (actionOverlay != null) {
        actionOverlay.setVisibility(View.GONE);
    }

    super.onStop();
}

Final thoughts

You’re now ready to give your Wear users the ability to keep your app on when the device goes to ambient mode. Before I turn you loose, however, there are a few other items that I need to mention.

The first is an observation about how this technique works. Unlike putting an “Ambient mode” option in your app’s settings, my approach here is a one-shot action. Whether in ambient mode or not, the user dismisses the Activity when they want to leave - and the next time it opens, ambient is back to being disabled.

In my view, this is a good design. Keeping an app running when a watch goes ambient will likely use more power than not doing so - and because their batteries are so small, power usage is even more critical on a smartwatch than most other devices. So if you're giving the user this control, I believe that the use of ambient should generally be a conscious decision on their part each time.

Which brings me to a (hopefully) obvious point about apps in ambient mode: you should take whatever steps you can to minimize their power usage. Frequently, this means reducing the update rate, or perhaps the display refresh rate. The specifics will depend on your app - but don’t ignore this issue, or your users will punish you.

There are also a few other concerns, to do with display color depth, burn-in protection, and such. Google has done a good job of outlining these issues in the Android Wear developer documentation. I strongly encourage you to read and apply this advice.

Finally, you may be wondering where in your app you implement all these recommendations? Remember that the code I’ve given above is all run before your app actually goes to ambient: the user has indicated that they want ambient mode enabled, but it won’t actually happen until they leave the watch alone long enough for the screen to time out.

Fortunately, the platform provides a couple of methods to ambient-enabled activities that let you know when the transition occurs. They’re named onEnterAmbient and onExitAmbient; the names are quite self-explanatory, but if you need more detail, please refer to the documentation.

And with that, you’re ready to go add an ambient option to your Wear apps. If you’d like to share apps where you’ve applied this technique, or if you have any questions, please tell us about them in the comments below!

History

Oct 9, 2015: first publication

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here