Watchface Implementierung

Category: 


Wenn man zum ersten mal ein Watchface erstellt, weiß man unter Umständen nicht genau wie man anfangen soll.
Daher soll dieser Artikel die Grundlagen anhand einer beispielhaften Implementierung genauer erläutern.

Dieser Artikel setzt voraus, dass ihr bereits ein Watchface Projekt konfiguriert habt.
Wie dies geht, könnt ihr in meinem Artikel Watchface Projekt Konfiguration nachlesen.

Als erstes schauen wir uns das folgende Grundgerüst an:

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 dem gesamten Tutorial betrachten wir im Grunde nur die innere Klasse Engine. Die umliegende SampleWatchfaceService Klasse dient lediglich dazu die Engine zu erzeugen und in der onCreateEngine-Methode zurückzugeben.

Variablen deklarieren

Als erstes sollten wir alle Variablen deklarieren. Wir benötigen folgende Elemente:
Grafikobjekte
In unserem Beispiel benötigen wir nur Paint Objekte, aber die meisten Watchfaces haben zumeist mindestens eine Bitmap.
Timer
Wenn die Smartwatch im Ambient Mode ist, so wird jede Minute die onTimerTick Methode aufgerufen. Wenn wir jedoch im Interaktiven Modus sind, so müssen wir uns selbst um das Timing kümmern.
Zeitzonen Receiver
Grundsätzlich ist dieser nicht zwingend erforderlich, jedoch sollte man ihn implementieren, damit sich die Uhr bei einem Wechsel der Zeitzone auch anpasst.

Das folgende Codebeispiel deklariert die eben genannten Variablen. Der Timer wird durch einen Handler implementiert, welcher ein neuzeichnen erzwingt (invalidate) und eine verzögerte Nachricht verschickt. In unserem Beispiel verschickt der Handler eine Message, welche eine Sekunde später abgearbeitet werden soll.

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();
  }
};

Variablen initialisieren

In der onCreate Methode sollte der WatchFaceStyle festgelegt, alle notwendigen Assets (Bilder, Schriftarten, etc.) geladen und Resourcenobjekte erzeugt werden. In unserem Fall haben wir keine Assets, aber dafür benötigen wir mehrere Paint Objekte. Wenn wir später Linien zeichnen wollen, geben wir durch die Paint-Objekte an, welche Farbe und Strichstärke benutzt werden soll.

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

  //Displaygröße abfragen
  Resources resources = SampleWatchfaceService.this.getResources();
  DisplayMetrics metrics = resources.getDisplayMetrics();
  mHalfWidth = metrics.widthPixels / 2;
  mHalfHeight = metrics.heightPixels / 2;

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

  mTime = new Time();
}

Als erstes sollte man den Style setzen. In unserem Beispiel legen wir fest, das Karten nur klein dargestellt werden sollen. Auf eckigen Bildschirmen wird somit nur eine Zeile der Karten angezeigt.
Außerdem unterbinden wir, dass das System die Zeit selbst anzeigt, denn immerhin wollen wir dies selbst erledigen.

Als weitere Angaben kann man z.B. die Position der Status Icons und dem HotWord ("OK Google") setzen. Die entsprechenden Methoden sind setStatusBarGravity und setHotwordIndicatorGravity.
Gültige Parameter sind die Konstanten der Gravity Klasse (z.B. Gravity.LEFT).

Da wir den Mittelpunkt des Bildschirms zum Zeichnen wieder brauchen, lohnt es sich diesen zu speichern. Anschließend erzeugen wir die Paint Objekte und ein einzelnes Time Objekt.

Timer updaten

Wenn wir im interaktiven Modus sind, müssen wir uns wie bereits erwähnt selbst um das Timing kümmern. Allerdings müssen wir auch darauf achten, dass wir den Timer abschalten, sobald wir in den Ambient Modus wechseln.
Dazu definieren wir folgendes:

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();
}

Die Update-Methode entfernt alle noch offenen Update Nachrichten und sendet ggf. eine neue, sofern die nötigen Bedingungen erfüllt sind. Die onDestroy Methode wird automatisch aufgerufen, wenn unsere App beendet wird. In diesem Fall sollten wir natürlich noch alle offenen Messages löschen.

Jetzt müssen wir einerseits diese Update Methode aufrufen und außerdem noch den Zeitzonen Receiver an- bzw. abmelden.

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

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

Je nachdem ob unser Watchface sichtbar ist oder nicht, wird der Receiver an- oder abgemeldet. Da es sein kann, dass sich die Zeitzone geändert hat, während wir keinen Receiver registriert hatten, passen wir die Zeitzone manuell an.
Nun fehlen noch die beiden Methoden registerReceiver und 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);
}

Wenn wir uns im Ambient Modus befinden, kümmert sich Android selbst darum, dass wir einmal in der Minute ein Update erhalten. Wir müssen nur veranlassen, dass unser Watchface neu gezeichnet wird. Dies geschieht am einfachsten durch die bereits bekannte invalidate Methode.

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

Wechsel zwischen den Modi

Wenn die Smartwatch zwischen den Modi wechselt, wird automatisch die Methode onAmbientModechanged aufgerufen. In diesem Fall sollten wir einerseits den Timer aktualisieren, aber auch die Grafikeinstellungen anpassen.
Einige Smartwatches geben vor, dass im Ambient Mode nur wenige Farben angezeigt werden dürfen (nur schwarz/weiß).
Hier sollte man daher das sogenannte Antialiasing(Kantenglättung) abschalten, da hierbei Teile von Linien halbtransparent gezeichnet werden (vergleichbar mit einem blur Effekt).
Anschließend sollte die Anzeige neu gezeichnet werden.

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 zeichnen

Nun kommen wir auch endlich zu dem eigentlichen zeichnen des Watchfaces. Als erstes updaten wir unser Time Objekt und zeichnen einen schwarzen Hintergrund.
Da unser Watchface um die Mitte herum zentriert ist, bietet es sich an, den Canvas so zu verschieben, dass der Nullpunkt genau in der Mitte liegt. Somit vereinfachen sich unsere Aufrufe zum zeichnen wie folgt:

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

Das Zeichnen findet in zwei Schritten statt. Als erstes zeichnen wir die Sachen, welche nur im interaktiven Modus zu sehen sind (!isInAmbientMode).
Anschließend können wir die Sachen zeichnen, welche immer zu sehen sind. Natürlich dürfen wir nicht vergessen, den Canvas wieder zurückzusetzen.

canvas.save() und canvas.restore() müssen immer symmetrisch auftreten. Das heißt, dass jedes save auch genau ein restore haben muss.

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

  canvas.drawColor(Color.BLACK); //schwarzer Hintergrund
  canvas.save(); // Transformationsmatrix sichern
  canvas.translate(mHalfWidth, mHalfHeight); // Nullpunkt in die Mitte verschieben

  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(); // Transformationsmatrix zurücksetzen
}

Abschließend findet ihr auf der folgenden Seite einmal den kompletten Code, welcher zu diesem Ergebnis führt: