Watchface Implementation

Category: 

If you create a watchface for the first time, you may don't know where to start. This article will demonstrate you how to implement a watchface.

This article assumes that you have already configured a watchface project.
To do this you can read my previous article Watchface Project Configuration.

Let's look at the barebone:

public class SampleWatchfaceService extends CanvasWatchFaceService {
    @Override
    public Engine onCreateEngine() {
        return new Engine();
    }

    private class Engine extends CanvasWatchFaceService.Engine {
        @Override
        public void onCreate(SurfaceHolder holder) {
           
        }

        @Override
        public void onTimeTick() {
           
        }

        @Override
        public void onDraw(Canvas canvas, Rect bounds) {
           
        }
    }
}

In the whole tutorial we will only look at the inner class Engine. Die outer class SampleWatchfaceService is only needed to create a new engine in the onCreateEngine method.

Declare Variables

First we should declare some important variables. We need:
Graphic objects
In our example, we just need a Paint object, but in most cases you will also need some Bitmaps.
Timer
If the watch is in ambient mode, the onTimerTick method is called once a minute. But if we're in interactive mode we must take care of the timing by ourself.
Timezone Receiver
Basically, this is not necessary, but you should implement it, so that the clock also adjusts when you change the time zone.

The following code example declares the variables just mentioned. The timer is implemented by a Handler, which will force a repaint (invalidate) and sent a delayed message. In our example, the handler sends a message which is processed a second later.

private final int MSG_UPDATE_TIME = 0;
private final long INTERACTIVE_UPDATE_RATE_MS = TimeUnit.SECONDS.toMillis(1);

private Paint mHourPaint;
private Paint mMinutePaint;
private Paint mSecondPaint;
private Paint mTickPaint;

private Time mTime;
private boolean mRegisteredTimeZoneReceiver = false;
private int mHalfWidth, mHalfHeight;

private boolean mLowBitAmbient;

private final Handler mUpdateTimeHandler = new Handler() {
  @Override
  public void handleMessage(Message msg) {
    if(msg.what == MSG_UPDATE_TIME) {
      invalidate();
      if (shouldTimerBeRunning()) {
        long timeMs = System.currentTimeMillis();
        long delayMs = INTERACTIVE_UPDATE_RATE_MS - (timeMs % INTERACTIVE_UPDATE_RATE_MS);
        mUpdateTimeHandler.sendEmptyMessageDelayed(MSG_UPDATE_TIME, delayMs);
      }
    }
  }
};

private final BroadcastReceiver mTimeZoneReceiver = new BroadcastReceiver() {
  @Override
  public void onReceive(Context context, Intent intent) {
    mTime.clear(intent.getStringExtra("time-zone"));
    mTime.setToNow();
  }
};

Initialize Variables

In the onCreate method, we should define the WatchFaceStyle, load all necessary assets (images, fonts, etc.) and create the resource objects. In our case, we have no assets, but we need some Paint objects. If we want to draw lines later, we passed to the Paint objects which color and line thickness to be used.

public void onCreate(SurfaceHolder holder) {
  super.onCreate(holder);
  // initialze Watch Face
  setWatchFaceStyle(new WatchFaceStyle.Builder(SampleWatchfaceService.this)
    .setCardPeekMode(WatchFaceStyle.PEEK_MODE_SHORT)
    .setShowSystemUiTime(false)
    .build());

  // get display size
  Resources resources = SampleWatchfaceService.this.getResources();
  DisplayMetrics metrics = resources.getDisplayMetrics();
  mHalfWidth = metrics.widthPixels / 2;
  mHalfHeight = metrics.heightPixels / 2;

  // create Paint objects
  mHourPaint = new Paint();
  mHourPaint.setARGB(255, 255, 0, 0);
  mHourPaint.setStrokeWidth(4.0f);
  mHourPaint.setAntiAlias(true);
  mHourPaint.setStrokeCap(Paint.Cap.ROUND);
  // ...

  mTime = new Time();
}

First of all we should set the style. In our example we define that the cards are displayed in short mode. On square screen, we will see only one line of a card. In addition, we prevent that the system displays the time, because we want to do the job.

You can also define the rough position of the status icon and the hotword ("OK Google"). The needed methods are setStatusBarGravity and setHotwordIndicatorGravity.
Valid parameters are the constants from the Gravity class (like Gravity.LEFT).

Since we need the center of the screen to draw our watchface, we should store it. Afterwards we create our Paint objects and a single Time object.

Timer Update

If we're in interactive mode, we need to take care of the timing. But we have to make sure, that our timer only runs iff we're in interactive mode. If the mode is changed to ambient mode, we should deactivate the timer.
This is done like this:

private void updateTimer() {
  mUpdateTimeHandler.removeMessages(MSG_UPDATE_TIME);
  if (shouldTimerBeRunning()) {
    mUpdateTimeHandler.sendEmptyMessage(MSG_UPDATE_TIME);
  }
}

private boolean shouldTimerBeRunning() {
  return isVisible() && !isInAmbientMode();
}

public void onDestroy() {
  mUpdateTimeHandler.removeMessages(MSG_UPDATE_TIME);
  super.onDestroy();
}

The update method removes all open update messages and sends a new one iff the conditions are met. The onDestroy method is called automatically if our app is closed. In this case we should also remove all open messages.

Now we need to call the update method and we should also add or remove the Timezone Receiver.

public void onVisibilityChanged(boolean visible) {
  super.onVisibilityChanged(visible);

  if (visible) {
    registerReceiver();
    mTime.clear(TimeZone.getDefault().getID());
    mTime.setToNow();
  } else {
    unregisterReceiver();
  }
  updateTimer();
}

Depending on the visibility of our watchface, we register or unregister the Receiver. In the case time has changed when our Receiver was not active, we set the timezone manually.
Now there are two methods missing: registerReceiver and unregisterReceiver.

private void registerReceiver() {
  if (mRegisteredTimeZoneReceiver)
    return;
  mRegisteredTimeZoneReceiver = true;
  IntentFilter filter = new IntentFilter(Intent.ACTION_TIMEZONE_CHANGED);
  SampleWatchfaceService.this.registerReceiver(mTimeZoneReceiver, filter);
}

private void unregisterReceiver() {
  if (!mRegisteredTimeZoneReceiver)
    return;
  mRegisteredTimeZoneReceiver = false;
  SampleWatchfaceService.this.unregisterReceiver(mTimeZoneReceiver);
}

If we're in ambient mode, Android makes sure that we get an update once a minute. Now we need to redraw our watchface. The easiest way for this is to call the invalidate method.

public void onTimeTick() {
  super.onTimeTick();
  invalidate();
}

Mode Changing

When the watch changes between two modes, the onAmbientModeChanged method is called. In this case, we should on the one hand update the timer, but also adjust the graphics settings.
Some smartwatches only allow a few colors in ambient mode (only black/white). We should also deactivate Antialiasing, because this effects costs a bit more performance and therefore energy. But in ambient mode, we should consume as less energy as possible.
Afterwards we should redraw again:

public void onPropertiesChanged(Bundle properties) {
  super.onPropertiesChanged(properties);
  mLowBitAmbient = properties.getBoolean(CanvasWatchFaceService.PROPERTY_LOW_BIT_AMBIENT, false);
}

public void onAmbientModeChanged(boolean inAmbientMode) {
  super.onAmbientModeChanged(inAmbientMode);
  if (mLowBitAmbient) {
    boolean antiAlias = !inAmbientMode;
    setAntiAlias(antiAlias);
  }
  invalidate();
  updateTimer();
}

private void setAntiAlias(boolean antiAlias) {
  mHourPaint.setAntiAlias(antiAlias);
  mMinutePaint.setAntiAlias(antiAlias);
  mSecondPaint.setAntiAlias(antiAlias);
  mTickPaint.setAntiAlias(antiAlias);
}

Watchface Drawing

Now we come to the fun part. The actual drawing of our watchface. First we update our Time object and draw a black background. Our watchface is centered, so we could just translate our canvas, such that the origin is at the center of the screen. So our draw call can be simplified like this:

//without canvas.translate
canvas.drawLine(mHalfWidth, mHalfHeight, mHalfWidth+hrX, mHalfWidth+hrY, mHourPaint);
//with canvas.translate
canvas.drawLine(0, 0, hrX, hrY, mHourPaint);

The drawing will take place in two steps. First, we draw everything that can be seen only in interactive mode (!isInAmbientMode).
After that we can draw everything that is visible in both modes. When we're finished we should reset our canvas translation.

canvas.save() and canvas.restore() must occure symmetrically. Every save must have a corresponding restore.

public void onDraw(Canvas canvas, Rect bounds) {
  mTime.setToNow();

  canvas.drawColor(Color.BLACK); //black background
  canvas.save(); // save transformation matrix
  canvas.translate(mHalfWidth, mHalfHeight); // translate origin to center

  float hourLen = mHalfHeight - 25;
  float minLen = mHalfHeight - 10;

  if(!isInAmbientMode()) {
    for (int i = 0; i < 12; i++) {
      float rad = i / 6f * (float) Math.PI;
      float innerX = (float) Math.sin(rad) * hourLen;
      float innerY = -(float) Math.cos(rad) * hourLen;
      float outerX = (float) Math.sin(rad) * minLen;
      float outerY = -(float) Math.cos(rad) * minLen;
      canvas.drawLine(innerX, innerY, outerX, outerY, mTickPaint);
    }

    float second = mTime.second / 30f * (float)Math.PI;
    float secX = (float) Math.sin(second) * minLen;
    float secY = (float) -Math.cos(second) * minLen;
    canvas.drawLine(0, 0, secX, secY, mSecondPaint);
  }

  float minute = mTime.minute / 30f * (float)Math.PI;
  float minX = (float) Math.sin(minute) * minLen;
  float minY = (float) -Math.cos(minute) * minLen;
  canvas.drawLine(0, 0, minX, minY, mMinutePaint);

  float hour = ((mTime.hour + (mTime.minute / 60f)) / 6f) * (float)Math.PI;
  float hrX = (float) Math.sin(hour) * hourLen;
  float hrY = (float) -Math.cos(hour) * hourLen;
  canvas.drawLine(0, 0, hrX, hrY, mHourPaint);

  canvas.restore(); // restore transformation matrix
}

And that's all! On the next page you can find the whole code.