Android WearのWatch Faceを作る 目次
今回は針の動きを滑らかにして設定画面の作り方を見ていきます。
Engineクラスのメンバ変数宣言部にあるTime mTimeをCalendar mCalendarに置き換えます。
mTime関連の場所がエラーになるので順番にCalendarの処理に置き換えていきます。
mTimeZoneReceiverのonReceive()を書き換えます。
onCreate()のインスタンス生成部分を置き換えます。
onDraw()の時間設定部分を書き換えます。
onDraw()で時針の角度を求めている部分を修正します。
分針の角度を求めている部分を修正します。
秒針の角度を求めている部分を修正します。
onVisibilityChanged()でタイムゾーンの値を再取得している処理を修正します。
そのためには描画の更新間隔を現在の1秒からもっと細かくする必要があります。
1秒間にどれだけ画像を更新するかをFPSと言います。FPSが多いほど滑らかになりますがCPUの負荷が増えバッテリーに負担がかかります。
サンプルは1秒間に1度だけ表示していたので1FPSです。なめらかなアニメーションを実現するには一般的には30FPSや60FPSがよく使われます。
今回は30FPSで更新を行います。
画面の更新頻度はINTERACTIVE_UPDATE_RATE_MSで行っていました。ここでは次の更新までどれだけの時間を待機するかを設定します。
サンプルは1秒間に1回処理を行うので、待機時間は1秒でした。
今回は1秒間に60回処理を行うので待機時間を1/30秒とします。
これだけではなめらかな針の動きになりません。
画面の描画が1/60秒ごとになっても、針の動きが秒までしか考慮していないので無駄にCPUを浪費しているだけになります。
そこで秒針の角度をミリ秒を想定した動きに変えます。
onDraw()で秒針の角度(secRot)を求めている処理にミリ秒を追加します
プログラムを実行すると秒針がなめらかに動くのがわかります。
秒針がなめらかに動くのも良いのですが人によっては秒針は1秒毎カチカチと動くほうが好みという人もいるかと思います。
それについても、よりリアル感を出すために、こった動きをするようにしてみましょう。
針が一瞬で動くのではなく、0.8秒間は止まって残り0.2秒かけて次の秒へ移動するアニメーションを追加します。
なめらかに動くロジックも後で使うのでコメントアウトしておきます。
0.8秒間、すなわちミリ秒が0〜800までは秒針の位置に留まります。
ミリ秒が800を超えたら、ミリ秒から800を引いた値を200で割った値(すなわち0〜1)を求めて、その値を2乗した値を秒に足します。
2乗しているのは針の速度が直線的にならないようにするためです。
例えば2乗しなかった場合、ミリ秒が820の時は
(820-800)/200=0.1
ミリ秒が840の時は
(840-800)/200=0.2
ミリ秒が860の時は
(860-800)/200=0.3
という感じで20ミリ秒ごとにきっちり0.1増加していきます。
この値を2乗すると
ミリ秒が820の時は
0.1*0.1=0.01
で、最初の20ミリ秒は0.01しか増加しません。
ミリ秒が840の時は
0.2*0.2=0.04
で、次の20ミリ秒は0.03増加します。
次の20ミリ秒では0.05増加するという具合に、時間が経つほど加速する動きになり、針をより自然に動かすことが出来ます。
Watch FaceはWear上とスマートフォン上の2つの方法でカスタマイズ可能です。
これらはどちらか片方だけ作ることも出来ますし両方作る事もできます。
今回はWear上の設定画面を作ってみます。
Configクラスの作成Android Wearは通常のAndroidと同様に様々な方法でデータを保管することが出来ます。
今回は針の動きを滑らかにして設定画面の作り方を見ていきます。
Timeの置き換え
非推奨のTimeをCalendarに置き換えます。Engineクラスのメンバ変数宣言部にあるTime mTimeをCalendar mCalendarに置き換えます。
Time mTime; // 削除
Calendar mCalendar; // 追加
mTime関連の場所がエラーになるので順番にCalendarの処理に置き換えていきます。
mTimeZoneReceiverのonReceive()を書き換えます。
mTime.clear(intent.getStringExtra("time-zone")); // 削除
mTime.setToNow(); // 削除
mCalendar.setTimeZone(TimeZone.getTimeZone(intent.getStringExtra("time-zone"))); //追加
onCreate()のインスタンス生成部分を置き換えます。
mTime = new Time(); // 削除
mCalendar = Calendar.getInstance(); //追加
onDraw()の時間設定部分を書き換えます。
mTime.setToNow(); // 削除
mCalendar.setTimeInMillis(System.currentTimeMillis()); // 追加
onDraw()で時針の角度を求めている部分を修正します。
float hourRotate = (mTime.hour + mTime.minute / 60f) * 30; // 削除
float hourRotate
= (mCalendar.get(Calendar.HOUR)
+ mCalendar.get(Calendar.MINUTE) / 60f) * 30; // 追加
分針の角度を求めている部分を修正します。
float minuteRotate= (mTime.minute + mTime.second/ 60f) * 6; // 削除
float minuteRotate = (mCalendar.get(Calendar.MINUTE)
+ mCalendar.get(Calendar.SECOND) / 60f) * 6; // 追加
秒針の角度を求めている部分を修正します。
float secRot = mTime.second / 30f * (float) Math.PI; // 削除
float secRot = mCalendar.get(Calendar.SECOND) / 30f * (float) Math.PI; // 追加
onVisibilityChanged()でタイムゾーンの値を再取得している処理を修正します。
mTime.clear(TimeZone.getDefault().getID()); // 削除
mTime.setToNow(); // 削除
mCalendar.setTimeZone(TimeZone.getDefault()); // 追加
秒針を滑らかにする
まずは秒針がスーッとなめらかに動くような処理を作ってみます。そのためには描画の更新間隔を現在の1秒からもっと細かくする必要があります。
1秒間にどれだけ画像を更新するかをFPSと言います。FPSが多いほど滑らかになりますがCPUの負荷が増えバッテリーに負担がかかります。
サンプルは1秒間に1度だけ表示していたので1FPSです。なめらかなアニメーションを実現するには一般的には30FPSや60FPSがよく使われます。
今回は30FPSで更新を行います。
画面の更新頻度はINTERACTIVE_UPDATE_RATE_MSで行っていました。ここでは次の更新までどれだけの時間を待機するかを設定します。
サンプルは1秒間に1回処理を行うので、待機時間は1秒でした。
今回は1秒間に60回処理を行うので待機時間を1/30秒とします。
private static final long INTERACTIVE_UPDATE_RATE_MS = TimeUnit.SECONDS.toMillis(1) / 30;
これだけではなめらかな針の動きになりません。
画面の描画が1/60秒ごとになっても、針の動きが秒までしか考慮していないので無駄にCPUを浪費しているだけになります。
そこで秒針の角度をミリ秒を想定した動きに変えます。
onDraw()で秒針の角度(secRot)を求めている処理にミリ秒を追加します
secRot = (mCalendar.get(Calendar.SECOND)
+ mCalendar.get(Calendar.MILLISECOND) / 1000f) / 30f * (float) Math.PI;
プログラムを実行すると秒針がなめらかに動くのがわかります。
秒針がなめらかに動くのも良いのですが人によっては秒針は1秒毎カチカチと動くほうが好みという人もいるかと思います。
それについても、よりリアル感を出すために、こった動きをするようにしてみましょう。
針が一瞬で動くのではなく、0.8秒間は止まって残り0.2秒かけて次の秒へ移動するアニメーションを追加します。
なめらかに動くロジックも後で使うのでコメントアウトしておきます。
/*secRot = (mCalendar.get(Calendar.SECOND)
+ mCalendar.get(Calendar.MILLISECOND) / 1000f) / 30f * (float) Math.PI;*/
0.8秒間、すなわちミリ秒が0〜800までは秒針の位置に留まります。
int milliSecond = mCalendar.get(Calendar.MILLISECOND);
if(milliSecond <= 800){
secRot = mCalendar.get(Calendar.SECOND) / 30f * (float) Math.PI;
secRot = (mCalendar.get(Calendar.SECOND) + shift * shift) / 30f * (float) Math.PI;
}
ミリ秒が800を超えたら、ミリ秒から800を引いた値を200で割った値(すなわち0〜1)を求めて、その値を2乗した値を秒に足します。
2乗しているのは針の速度が直線的にならないようにするためです。
例えば2乗しなかった場合、ミリ秒が820の時は
(820-800)/200=0.1
ミリ秒が840の時は
(840-800)/200=0.2
ミリ秒が860の時は
(860-800)/200=0.3
という感じで20ミリ秒ごとにきっちり0.1増加していきます。
この値を2乗すると
ミリ秒が820の時は
0.1*0.1=0.01
で、最初の20ミリ秒は0.01しか増加しません。
ミリ秒が840の時は
0.2*0.2=0.04
で、次の20ミリ秒は0.03増加します。
次の20ミリ秒では0.05増加するという具合に、時間が経つほど加速する動きになり、針をより自然に動かすことが出来ます。
設定画面を作る
次に設定画面でなめらかな動きにするか、1秒毎に動くようにするか切り替えられるようにしましょう。Watch FaceはWear上とスマートフォン上の2つの方法でカスタマイズ可能です。
これらはどちらか片方だけ作ることも出来ますし両方作る事もできます。
今回はWear上の設定画面を作ってみます。
Configクラスの作成Android Wearは通常のAndroidと同様に様々な方法でデータを保管することが出来ます。
Wearableの設定を使う場合はData Itemsを使うのが便利です。
Data itemsはウェアラブル端末向けに最適化されており、スマートフォンとウェアラブルの同期を自動的に行なってくれるなど便利な機能がたくさんあります。
設定値を保存するためのConfigクラスを作ります。INNERクラスではなく新しいJavaファイルで作っておきます。
public class Config {
}
定数の設定
データを保存するパスを指定します。
private static final String PATH = "/config";
設定したい値はDataMapというクラスにkey-value形式で保存します。
今回は針の動きを表すキーを設定します。
private static final String KEY_SMOOTH_MOVE = "SMOOTH_MOVE";
リスナーの実装
データの更新を取り扱うために DataApi.DataListener、GoogleApiClient.ConnectionCallbacks、GoogleApiClient.OnConnectionFailedListener を実装します。
public class Config implements DataApi.DataListener,
GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener{
}
必要なメソッドを実装します。
public class Config implements DataApi.DataListener,
private static final String PATH = "/config";
private static final String KEY_SMOOTH_MOVE = "SMOOTH_MOVE";
GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener{
@Override
public void onConnected(Bundle bundle) {
}
@Override
public void onConnectionSuspended(int i) {
}
@Override
public void onDataChanged(DataEventBuffer dataEventBuffer) {
}
@Override
public void onConnectionFailed(ConnectionResult connectionResult) {
}
}
設定が更新された時にConfigを呼び出した画面側もコールバックを受け取れるように、独自のコールバックを作成します。
private final WeakReference<OnConfigChangedListener> mConfigChangedListenerWeakReference;
public interface OnConfigChangedListener {
void onConfigChanged(Config config);
}
GoogleApiClientの設定
データの同期を取り扱うGoogleApiClientをメンバ変数として宣言します。
private GoogleApiClient mGoogleApiClient;
外部からデータの同期を開始、終了出来るようにメソッドを作ります。
接続時はデータの更新を受け取れるようにリスナーも登録します。Config自身がDataListenerを実装しているためthisを指定します。
切断時はリスナーを解除して切断します。
public void connect() {
mGoogleApiClient.connect();
Wearable.DataApi.addListener(mGoogleApiClient, this);
}
public void disconnect() {
if (mGoogleApiClient != null && mGoogleApiClient.isConnected()) {
Wearable.DataApi.removeListener(mGoogleApiClient, this);
mGoogleApiClient.disconnect();
}
}
コンストラクタの作成
GoogleApiClientの初期化はConfigクラスのコンストラクターで行います。
GoogleApiClient.Builder()は引数としてContextを渡します。Contextは実行しているActivityなどを使うのでコンストラクターの引数としてContextを取るようにしましょう。
コンストラクターの第二引数として先ほど追加したコールバックも受け取ることにします。
public Config(Context context, OnConfigChangedListener reference) {
if (reference == null) {
mConfigChangedListenerWeakReference = null;
} else {
mConfigChangedListenerWeakReference = new WeakReference<>(reference);
}
mGoogleApiClient = new GoogleApiClient.Builder(context)
.addConnectionCallbacks(this)
.addOnConnectionFailedListener(this)
.addApi(Wearable.API)
.build();
}
設定値を保存する領域を作ります。秒針が滑らかに動くか1秒毎動くかの2択ですのでboolean型としましょう。
今後もっと多彩な針の動きを用意する予定でしたらIntegerに設定するという手もあります。
private boolean mIsSmooth;
GetterとSetterも作りましょう。
Setter内ではDataItemを使用して同期も行います。
同期を行うためにPutDataMapRequest.create(PATH);を呼んでDataMapRequestを作成します。
DataMapRequestのインスタンスに対してgetDataMap()を呼び値を同期するためのDataMapを取得します。
取得したDataMapにputBoolean(キー,値)を設定することでキーに紐づく値を保存できます。
int型で保存したいときはputInt(キー,値)と設定してください。
Wearable.DataApi.putDataItem()を呼ぶことでDataMapがDataItemとして保存・同期されます。
public boolean isSmooth() {
return mIsSmooth;
}
public void setIsSmooth(boolean isSmooth) {
mIsSmooth = isSmooth;
PutDataMapRequest putDataMapRequest = PutDataMapRequest.create(PATH);
DataMap dataMap = putDataMapRequest.getDataMap();
dataMap.putBoolean(KEY_SMOOTH_MOVE, mIsSmooth);
Wearable.DataApi.putDataItem(mGoogleApiClient, putDataMapRequest.asPutDataRequest());
}
設定を取得する。
現在の設定値を取得するためにonConnected()に値を取得する処理を追加します。
現在の値を取得するにはstaticメソッドであるWearable.DataApi.getDataItems()に引数としてGoogleApiClientを渡して、setResultCallback()で値が取れた時のコールバック処理を設定します。
@Override
public void onConnected(Bundle bundle) {
Wearable.DataApi.getDataItems(mGoogleApiClient)
.setResultCallback(new ResultCallback<DataItemBuffer>() {
@Override
public void onResult(DataItemBuffer dataItems) {
}
});
}
DataItemBufferは複数の値を持っていることがあるため、forで各値を確認します。取得したDataItemのパスがデータを保存するパスと一致している場合(今回は他に保存している処理がないため一致するはずですが)DataItemからDataMapを取り出し、KEYを指定して設定値を取得します。最後にdataItemsをrelease()しましょう。
@Override
public void onConnected(Bundle bundle) {
Wearable.DataApi.getDataItems(mGoogleApiClient)
.setResultCallback(new ResultCallback<DataItemBuffer>() {
@Override
public void onResult(DataItemBuffer dataItems) {
for (DataItem dataItem : dataItems) {
if (dataItem.getUri().getPath().equals(PATH)) {
DataMap dataMap = DataMap.fromByteArray(dataItem.getData());
mIsSmooth = dataMap.getBoolean(KEY_SMOOTH_MOVE, true);
if (mConfigChangedListenerWeakReference != null) {
OnConfigChangedListener listener = mConfigChangedListenerWeakReference.get();
if (listener != null) {
listener.onConfigChanged(Config.this);
}
}
}
}
dataItems.release();
}
});
}
設定値が変わった場合の処理も追加します。
設定値が変わるとonDataChangedが呼ばれます。引数としてDataEventBufferが渡されます。
そこでDataEventBufferからforで各イベントを処理します。
DataEventのgetType()がTYPE_CHANGEDの時だけ処理を行います。
これとは別に値が削除された時にTYPE_DELETEDが呼ばれる可能性もありますが、今回は割愛します。
getType()がTYPE_CHANGEDだった場合、getDataItem()でDataItemを求め、そのパスがこのアプリで設定したPATHと一致する場合に、変更されたデータを格納します。
画面からコールバックが設定されていればコールバックを呼びます。
@Override
public void onDataChanged(DataEventBuffer dataEventBuffer) {
for (DataEvent event : dataEventBuffer) {
if (event.getType() == DataEvent.TYPE_CHANGED) {
DataItem item = event.getDataItem();
if (item.getUri().getPath().equals(PATH)) {
DataMap dataMap = DataMapItem.fromDataItem(item).getDataMap();
mIsSmooth = dataMap.getBoolean(KEY_SMOOTH_MOVE);
if (mConfigChangedListenerWeakReference != null) {
OnConfigChangedListener listener = mConfigChangedListenerWeakReference.get();
if (listener != null) {
listener.onConfigChanged(Config.this);
}
}
}
}
}
}
Config全体は次のとおりです。
かなり大きくなっていますが、これを用意しておくことで設定の実装が簡単になります。
package org.firespeed.myapplication;
import android.content.Context;
import android.os.Bundle;
import android.util.Log;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.common.api.PendingResult;
import com.google.android.gms.common.api.ResultCallback;
import com.google.android.gms.wearable.DataApi;
import com.google.android.gms.wearable.DataEvent;
import com.google.android.gms.wearable.DataEventBuffer;
import com.google.android.gms.wearable.DataItem;
import com.google.android.gms.wearable.DataItemBuffer;
import com.google.android.gms.wearable.DataMap;
import com.google.android.gms.wearable.DataMapItem;
import com.google.android.gms.wearable.PutDataMapRequest;
import com.google.android.gms.wearable.Wearable;
import java.lang.ref.WeakReference;
public class Config implements DataApi.DataListener,
GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener {
private static final String PATH = "/config";
private static final String KEY_SMOOTH_MOVE = "SMOOTH_MOVE";
private GoogleApiClient mGoogleApiClient;
private boolean mIsSmooth;
public boolean isSmooth() {
return mIsSmooth;
}
public void setIsSmooth(boolean isSmooth) {
mIsSmooth = isSmooth;
PutDataMapRequest putDataMapRequest = PutDataMapRequest.create(PATH);
DataMap dataMap = putDataMapRequest.getDataMap();
dataMap.putBoolean(KEY_SMOOTH_MOVE, mIsSmooth);
Wearable.DataApi.putDataItem(mGoogleApiClient, putDataMapRequest.asPutDataRequest());
}
private final WeakReference<OnConfigChangedListener> mConfigChangedListenerWeakReference;
public Config(Context context, OnConfigChangedListener reference) {
if (reference == null) {
mConfigChangedListenerWeakReference = null;
} else {
mConfigChangedListenerWeakReference = new WeakReference<>(reference);
}
mGoogleApiClient = new GoogleApiClient.Builder(context)
.addConnectionCallbacks(this)
.addOnConnectionFailedListener(this)
.addApi(Wearable.API)
.build();
}
public void connect() {
mGoogleApiClient.connect();
Wearable.DataApi.addListener(mGoogleApiClient, this);
}
public void disconnect() {
if (mGoogleApiClient != null && mGoogleApiClient.isConnected()) {
Wearable.DataApi.removeListener(mGoogleApiClient, this);
mGoogleApiClient.disconnect();
}
}
@Override
public void onConnected(Bundle bundle) {
Wearable.DataApi.getDataItems(mGoogleApiClient)
.setResultCallback(new ResultCallback<DataItemBuffer>() {
@Override
public void onResult(DataItemBuffer dataItems) {
for (DataItem dataItem : dataItems) {
if (dataItem.getUri().getPath().equals(PATH)) {
DataMap dataMap = DataMap.fromByteArray(dataItem.getData());
mIsSmooth = dataMap.getBoolean(KEY_SMOOTH_MOVE, true);
if (mConfigChangedListenerWeakReference != null) {
OnConfigChangedListener listener = mConfigChangedListenerWeakReference.get();
if (listener != null) {
listener.onConfigChanged(Config.this);
}
}
}
}
dataItems.release();
}
});
}
@Override
public void onConnectionSuspended(int i) {
}
@Override
public void onDataChanged(DataEventBuffer dataEventBuffer) {
for (DataEvent event : dataEventBuffer) {
if (event.getType() == DataEvent.TYPE_CHANGED) {
DataItem item = event.getDataItem();
if (item.getUri().getPath().equals(PATH)) {
DataMap dataMap = DataMapItem.fromDataItem(item).getDataMap();
mIsSmooth = dataMap.getBoolean(KEY_SMOOTH_MOVE);
if (mConfigChangedListenerWeakReference != null) {
OnConfigChangedListener listener = mConfigChangedListenerWeakReference.get();
if (listener != null) {
listener.onConfigChanged(Config.this);
}
}
}
}
}
}
@Override
public void onConnectionFailed(ConnectionResult connectionResult) {
}
public interface OnConfigChangedListener {
void onConfigChanged(Config config);
}
}
設定に応じてWatch Faceの動きを変える
それではConfigの値を元にWatch Faceの動きを変えてみましょう。
そのためにはまずMyWatchFaceを開いて、Configを適切に取得する処理を追加する必要があります。
設定の更新を確認する必要があるのはonCraete()でWatch Faceが生成されてonDestroy()で破棄されるまでです。
画面が非表示の時は確認する必要がありません。
今回は秒針に関する処理なのでAmbientModeの時も取得する必要はありません。
時針のデザインを変えるなど、AmbientMode時でも描画される項目を変更する場合はAmbientMode時も設定の変更を監視し続けてください。
Configの作成と破棄
MyWatchFace.Engineのメンバ変数としてConfig mConfigを作ります。
private Config mConfig;
onCreate()でmConfigを作成し接続します。
コンストラクタの引数はContextとリスナーです。
今回はデータの更新を確認しなくても高頻度でConfigの値を参照することになるのでリスナーは登録しません。
mConfig = new Config(MyWatchFace.this, null);
mConfig.connect();
onDestroy()でmConfigを破棄します。
@Override
public void onDestroy() {
mUpdateTimeHandler.removeMessages(MSG_UPDATE_TIME);
mConfig.disconnect();
mConfig = null;
super.onDestroy();
}
onAmbientModeChanged()でAmbientModeになった時に設定の確認を停止、AmbientModeから外れた時に再開します。
@Override
public void onAmbientModeChanged(boolean inAmbientMode) {
super.onAmbientModeChanged(inAmbientMode);
if (mAmbient != inAmbientMode) {
mAmbient = inAmbientMode;
if (mLowBitAmbient) {
mDrawPaint.setAntiAlias(!inAmbientMode);
mBitmapPaint.setFilterBitmap(!inAmbientMode);
}
mConfig.connect();
invalidate();
}else{
mConfig.disconnect();
}
// Whether the timer should be running depends on whether we're visible (as well as
// whether we're in ambient mode), so we may need to start or stop the timer.
updateTimer();
}
onVisibilityChanged()で非表示になった時に設定の確認を停止、再表示された時に設定の確認を再開します。
@Override
public void onVisibilityChanged(boolean visible) {
super.onVisibilityChanged(visible);
if (visible) {
registerReceiver();
// Update time zone in case it changed while we weren't visible.
mCalendar.setTimeZone(TimeZone.getDefault());
mConfig.connect(); // 追加
} else {
unregisterReceiver();
mConfig.disconnect(); // 追加
}
// Whether the timer should be running depends on whether we're visible (as well as
// whether we're in ambient mode), so we may need to start or stop the timer.
updateTimer();
}
Configの値を反映する
onDraw()で針の動きを設定している部分をmConfigの設定値に従って書き換えます。
if (!mAmbient) {
float secRot;
if(mConfig.isSmooth()) { // Configの値を取得する。
secRot = (mCalendar.get(Calendar.SECOND) + mCalendar.get(Calendar.MILLISECOND) / 1000f) / 30f * (float) Math.PI;
}else{
int milliSecond = mCalendar.get(Calendar.MILLISECOND);
if(milliSecond <= 800){
secRot = mCalendar.get(Calendar.SECOND) / 30f * (float) Math.PI;
}else {
float shift = (mCalendar.get(Calendar.MILLISECOND) - 800) / 200f;
secRot = (mCalendar.get(Calendar.SECOND) + shift * shift) / 30f * (float) Math.PI;
}
}
float secX = (float) Math.sin(secRot) * mSecLength;
float secY = (float) -Math.cos(secRot) * mSecLength;
canvas.drawLine(mCenterX, mCenterY, mCenterX + secX, mCenterY + secY, mDrawPaint);
}
canvas.drawCircle(mCenterX, mCenterY, mHoleRadius, mDrawPaint);
これでConfigの内容を監視して針の動きを変える処理は出来ました。
しかし、これだけでは、Configの内容を書き換える処理がないため実際には設定を変えることが出来ません。
そこでまずは、タップ時に設定を書き換える処理を追加してみましょう。
前回無効にしたonTapCommand()を書き換えてタップすると針の動きが変わることを確認してください。
この時、この値は永続化されるため、他のWatch Faceに変えて、戻ってきた時も前回の値が保存されます。
@Override
public void onTapCommand(int tapType, int x, int y, long eventTime) {
Resources resources = MyWatchFace.this.getResources();
switch (tapType) {
case TAP_TYPE_TOUCH:
// The user has started touching the screen.
break;
case TAP_TYPE_TOUCH_CANCEL:
// The user has started a different gesture or otherwise cancelled the tap.
break;
case TAP_TYPE_TAP:
// The user has completed the tap gesture.
mConfig.setIsSmooth(!mConfig.isSmooth()); // 追加
break;
}
invalidate();
}
設定画面を作る
Watch上で設定できる設定画面を作ります。設定画面はActivityとして作ります。
新たにConfigActivityクラスを作ります。
public class ConfigActivity {
}
strings.xmlに設定画面のタイトルと設定項目を追加します。
<string name="title_activity_config">Config</string>
<string name="smooth">Smooth</string>
AndroidManifestにActivityを定義します。
IntentFilterを設定して設定から起動するように指定します。
action android:nameのorg.firespeed.myapplicationの部分はそれぞれのパッケージ名に置き換えてください。
<activity
android:name=".ConfigActivity"
android:label="@string/title_activity_config">
<intent-filter>
<action android:name= "org.firespeed.myapplication.CONFIG" />
<category android:name= "com.google.android.wearable.watchface.category.WEARABLE_CONFIGURATION" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
Serviceにも設定画面が有ることをmeta-dataで定義します。
android:nameの値は上記のaction android:name=で指定した値に合わせてください。
<service
android:name=".MyWatchFace"
android:label="@string/my_analog_name"
android:permission="android.permission.BIND_WALLPAPER">
<meta-data
android:name="android.service.wallpaper"
android:resource="@xml/watch_face"/>
<!-- 四角い時計のプレビュー -->
<meta-data
android:name="com.google.android.wearable.watchface.preview"
android:resource="@drawable/preview_analog"/>
<!-- 丸い時計のプレビュー -->
<meta-data
android:name="com.google.android.wearable.watchface.preview_circular"
android:resource="@drawable/preview_analog"/>
<!-- ここから追加 -->
<meta-data
android:name="com.google.android.wearable.watchface.wearableConfigurationAction"
android:value="org.firespeed.myapplication.CONFIG" />
<!-- ここまで追加 -->
<intent-filter>
<action android:name="android.service.wallpaper.WallpaperService"/>
<category android:name="com.google.android.wearable.watchface.category.WATCH_FACE"/>
</intent-filter>
</service>
layoutフォルダに新たに設定画面のレイアウト用のactivity_config.xmlを作成します。
今回はCheckBoxを一つだけ作ります。
レイアウトの作成方法は普通のAndroidと同様です。
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<CheckBox
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:id="@+id/smooth"
android:text="@string/smooth"/>
</FrameLayout>
ConfigActivityの作成
ConfigAcitivity.javaを開いて
Configを格納するためのメンバ変数を作成します。
private Config mConfig;
onCraete()をOverrideしてレイアウトを取得する定番の処理を追加してViewからチェックボックスを取得します。
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_config);
final CheckBox smooth = (CheckBox)findViewById(R.id.smooth);
}
mConfigを初期化します。
フレームを再描画するタイミングで設定を見なおしていたWatchFaceと違って、ConfigActivityはフレームの書き換えがないため、Config.OnConfigChangedListenerで設定が取得・変更されたことを取得してチェックボックスの値を反映します。
mConfig = new Config(this, new Config.OnConfigChangedListener() {
@Override
public void onConfigChanged(Config config) {
smooth.setChecked(config.isSmooth());
}
});
逆にチェックボックスをチェックした時にConfigの値を書き換える処理を追加します。
smooth.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
mConfig.setIsSmooth(isChecked);
}
});
Configの同期をonResume()で開始、
onPauseで終了します。
@Override
protected void onResume() {
super.onResume();
mConfig.connect();
}
@Override
protected void onPause() {
super.onPause();
mConfig.disconnect();
}
ConfigActivity全体は次のようになります。
package org.firespeed.myapplication;
import android.app.Activity;
import android.os.Bundle;
import android.support.wearable.view.WatchViewStub;
import android.util.Log;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.TextView;
public class ConfigActivity extends Activity {
private Config mConfig;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_config);
final CheckBox smooth = (CheckBox)findViewById(R.id.smooth);
mConfig = new Config(this, new Config.OnConfigChangedListener() {
@Override
public void onConfigChanged(Config config) {
smooth.setChecked(config.isSmooth());
}
});
smooth.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
mConfig.setIsSmooth(isChecked);
}
});
}
@Override
protected void onResume() {
super.onResume();
mConfig.connect();
}
@Override
protected void onPause() {
super.onPause();
mConfig.disconnect();
}
}
WatchFaceをインストールして、Watch Faceを選択する画面に、歯車が追加されていることを確認してください。
![]()
歯車をタップすると設定画面が開きます。
![]()
右側にスワイプすると設定画面が閉じてWatch Faceに戻ります。
チェック画面に応じて秒針の動きが変わることを確認してください。
また、Watch Faceをタップして秒針の動きを変えた時も設定画面の値が反映されてデータが同期されることを確認できます。
このように設定を追加して画面タップや設定画面で動きをカスタマイズできるのもAndroid Wear Watch Faceの魅力です・
次回はスマートフォン側アプリを作成してスマートフォンでも設定が行えるようにします。
スマートフォン側で設定を変えることでより高度な画面操作も可能になります。
スマートフォン側アプリを開発することでGooglePlay Storeに公開することも出来るようになります。
public class Config {
}
private static final String PATH = "/config";
private static final String KEY_SMOOTH_MOVE = "SMOOTH_MOVE";
public class Config implements DataApi.DataListener,
GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener{
}
public class Config implements DataApi.DataListener,
private static final String PATH = "/config";
private static final String KEY_SMOOTH_MOVE = "SMOOTH_MOVE";
GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener{
@Override
public void onConnected(Bundle bundle) {
}
@Override
public void onConnectionSuspended(int i) {
}
@Override
public void onDataChanged(DataEventBuffer dataEventBuffer) {
}
@Override
public void onConnectionFailed(ConnectionResult connectionResult) {
}
}
private final WeakReference<OnConfigChangedListener> mConfigChangedListenerWeakReference;
public interface OnConfigChangedListener {
void onConfigChanged(Config config);
}
private GoogleApiClient mGoogleApiClient;
public void connect() {
mGoogleApiClient.connect();
Wearable.DataApi.addListener(mGoogleApiClient, this);
}
public void disconnect() {
if (mGoogleApiClient != null && mGoogleApiClient.isConnected()) {
Wearable.DataApi.removeListener(mGoogleApiClient, this);
mGoogleApiClient.disconnect();
}
}
public Config(Context context, OnConfigChangedListener reference) {
if (reference == null) {
mConfigChangedListenerWeakReference = null;
} else {
mConfigChangedListenerWeakReference = new WeakReference<>(reference);
}
mGoogleApiClient = new GoogleApiClient.Builder(context)
.addConnectionCallbacks(this)
.addOnConnectionFailedListener(this)
.addApi(Wearable.API)
.build();
}
private boolean mIsSmooth;
public boolean isSmooth() {
return mIsSmooth;
}
public void setIsSmooth(boolean isSmooth) {
mIsSmooth = isSmooth;
PutDataMapRequest putDataMapRequest = PutDataMapRequest.create(PATH);
DataMap dataMap = putDataMapRequest.getDataMap();
dataMap.putBoolean(KEY_SMOOTH_MOVE, mIsSmooth);
Wearable.DataApi.putDataItem(mGoogleApiClient, putDataMapRequest.asPutDataRequest());
}
@Override
public void onConnected(Bundle bundle) {
Wearable.DataApi.getDataItems(mGoogleApiClient)
.setResultCallback(new ResultCallback<DataItemBuffer>() {
@Override
public void onResult(DataItemBuffer dataItems) {
}
});
}
@Override
public void onConnected(Bundle bundle) {
Wearable.DataApi.getDataItems(mGoogleApiClient)
.setResultCallback(new ResultCallback<DataItemBuffer>() {
@Override
public void onResult(DataItemBuffer dataItems) {
for (DataItem dataItem : dataItems) {
if (dataItem.getUri().getPath().equals(PATH)) {
DataMap dataMap = DataMap.fromByteArray(dataItem.getData());
mIsSmooth = dataMap.getBoolean(KEY_SMOOTH_MOVE, true);
if (mConfigChangedListenerWeakReference != null) {
OnConfigChangedListener listener = mConfigChangedListenerWeakReference.get();
if (listener != null) {
listener.onConfigChanged(Config.this);
}
}
}
}
dataItems.release();
}
});
}
@Override
public void onDataChanged(DataEventBuffer dataEventBuffer) {
for (DataEvent event : dataEventBuffer) {
if (event.getType() == DataEvent.TYPE_CHANGED) {
DataItem item = event.getDataItem();
if (item.getUri().getPath().equals(PATH)) {
DataMap dataMap = DataMapItem.fromDataItem(item).getDataMap();
mIsSmooth = dataMap.getBoolean(KEY_SMOOTH_MOVE);
if (mConfigChangedListenerWeakReference != null) {
OnConfigChangedListener listener = mConfigChangedListenerWeakReference.get();
if (listener != null) {
listener.onConfigChanged(Config.this);
}
}
}
}
}
}
package org.firespeed.myapplication;
import android.content.Context;
import android.os.Bundle;
import android.util.Log;
import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.common.api.PendingResult;
import com.google.android.gms.common.api.ResultCallback;
import com.google.android.gms.wearable.DataApi;
import com.google.android.gms.wearable.DataEvent;
import com.google.android.gms.wearable.DataEventBuffer;
import com.google.android.gms.wearable.DataItem;
import com.google.android.gms.wearable.DataItemBuffer;
import com.google.android.gms.wearable.DataMap;
import com.google.android.gms.wearable.DataMapItem;
import com.google.android.gms.wearable.PutDataMapRequest;
import com.google.android.gms.wearable.Wearable;
import java.lang.ref.WeakReference;
public class Config implements DataApi.DataListener,
GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener {
private static final String PATH = "/config";
private static final String KEY_SMOOTH_MOVE = "SMOOTH_MOVE";
private GoogleApiClient mGoogleApiClient;
private boolean mIsSmooth;
public boolean isSmooth() {
return mIsSmooth;
}
public void setIsSmooth(boolean isSmooth) {
mIsSmooth = isSmooth;
PutDataMapRequest putDataMapRequest = PutDataMapRequest.create(PATH);
DataMap dataMap = putDataMapRequest.getDataMap();
dataMap.putBoolean(KEY_SMOOTH_MOVE, mIsSmooth);
Wearable.DataApi.putDataItem(mGoogleApiClient, putDataMapRequest.asPutDataRequest());
}
private final WeakReference<OnConfigChangedListener> mConfigChangedListenerWeakReference;
public Config(Context context, OnConfigChangedListener reference) {
if (reference == null) {
mConfigChangedListenerWeakReference = null;
} else {
mConfigChangedListenerWeakReference = new WeakReference<>(reference);
}
mGoogleApiClient = new GoogleApiClient.Builder(context)
.addConnectionCallbacks(this)
.addOnConnectionFailedListener(this)
.addApi(Wearable.API)
.build();
}
public void connect() {
mGoogleApiClient.connect();
Wearable.DataApi.addListener(mGoogleApiClient, this);
}
public void disconnect() {
if (mGoogleApiClient != null && mGoogleApiClient.isConnected()) {
Wearable.DataApi.removeListener(mGoogleApiClient, this);
mGoogleApiClient.disconnect();
}
}
@Override
public void onConnected(Bundle bundle) {
Wearable.DataApi.getDataItems(mGoogleApiClient)
.setResultCallback(new ResultCallback<DataItemBuffer>() {
@Override
public void onResult(DataItemBuffer dataItems) {
for (DataItem dataItem : dataItems) {
if (dataItem.getUri().getPath().equals(PATH)) {
DataMap dataMap = DataMap.fromByteArray(dataItem.getData());
mIsSmooth = dataMap.getBoolean(KEY_SMOOTH_MOVE, true);
if (mConfigChangedListenerWeakReference != null) {
OnConfigChangedListener listener = mConfigChangedListenerWeakReference.get();
if (listener != null) {
listener.onConfigChanged(Config.this);
}
}
}
}
dataItems.release();
}
});
}
@Override
public void onConnectionSuspended(int i) {
}
@Override
public void onDataChanged(DataEventBuffer dataEventBuffer) {
for (DataEvent event : dataEventBuffer) {
if (event.getType() == DataEvent.TYPE_CHANGED) {
DataItem item = event.getDataItem();
if (item.getUri().getPath().equals(PATH)) {
DataMap dataMap = DataMapItem.fromDataItem(item).getDataMap();
mIsSmooth = dataMap.getBoolean(KEY_SMOOTH_MOVE);
if (mConfigChangedListenerWeakReference != null) {
OnConfigChangedListener listener = mConfigChangedListenerWeakReference.get();
if (listener != null) {
listener.onConfigChanged(Config.this);
}
}
}
}
}
}
@Override
public void onConnectionFailed(ConnectionResult connectionResult) {
}
public interface OnConfigChangedListener {
void onConfigChanged(Config config);
}
}
private Config mConfig;
mConfig = new Config(MyWatchFace.this, null);
mConfig.connect();
@Override
public void onDestroy() {
mUpdateTimeHandler.removeMessages(MSG_UPDATE_TIME);
mConfig.disconnect();
mConfig = null;
super.onDestroy();
}
@Override
public void onAmbientModeChanged(boolean inAmbientMode) {
super.onAmbientModeChanged(inAmbientMode);
if (mAmbient != inAmbientMode) {
mAmbient = inAmbientMode;
if (mLowBitAmbient) {
mDrawPaint.setAntiAlias(!inAmbientMode);
mBitmapPaint.setFilterBitmap(!inAmbientMode);
}
mConfig.connect();
invalidate();
}else{
mConfig.disconnect();
}
// Whether the timer should be running depends on whether we're visible (as well as
// whether we're in ambient mode), so we may need to start or stop the timer.
updateTimer();
}
@Override
public void onVisibilityChanged(boolean visible) {
super.onVisibilityChanged(visible);
if (visible) {
registerReceiver();
// Update time zone in case it changed while we weren't visible.
mCalendar.setTimeZone(TimeZone.getDefault());
mConfig.connect(); // 追加
} else {
unregisterReceiver();
mConfig.disconnect(); // 追加
}
// Whether the timer should be running depends on whether we're visible (as well as
// whether we're in ambient mode), so we may need to start or stop the timer.
updateTimer();
}
if (!mAmbient) {
float secRot;
if(mConfig.isSmooth()) { // Configの値を取得する。
secRot = (mCalendar.get(Calendar.SECOND) + mCalendar.get(Calendar.MILLISECOND) / 1000f) / 30f * (float) Math.PI;
}else{
int milliSecond = mCalendar.get(Calendar.MILLISECOND);
if(milliSecond <= 800){
secRot = mCalendar.get(Calendar.SECOND) / 30f * (float) Math.PI;
}else {
float shift = (mCalendar.get(Calendar.MILLISECOND) - 800) / 200f;
secRot = (mCalendar.get(Calendar.SECOND) + shift * shift) / 30f * (float) Math.PI;
}
}
float secX = (float) Math.sin(secRot) * mSecLength;
float secY = (float) -Math.cos(secRot) * mSecLength;
canvas.drawLine(mCenterX, mCenterY, mCenterX + secX, mCenterY + secY, mDrawPaint);
}
canvas.drawCircle(mCenterX, mCenterY, mHoleRadius, mDrawPaint);
@Override
public void onTapCommand(int tapType, int x, int y, long eventTime) {
Resources resources = MyWatchFace.this.getResources();
switch (tapType) {
case TAP_TYPE_TOUCH:
// The user has started touching the screen.
break;
case TAP_TYPE_TOUCH_CANCEL:
// The user has started a different gesture or otherwise cancelled the tap.
break;
case TAP_TYPE_TAP:
// The user has completed the tap gesture.
mConfig.setIsSmooth(!mConfig.isSmooth()); // 追加
break;
}
invalidate();
}
public class ConfigActivity {
}
<string name="title_activity_config">Config</string>
<string name="smooth">Smooth</string>
<activity
android:name=".ConfigActivity"
android:label="@string/title_activity_config">
<intent-filter>
<action android:name= "org.firespeed.myapplication.CONFIG" />
<category android:name= "com.google.android.wearable.watchface.category.WEARABLE_CONFIGURATION" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<service
android:name=".MyWatchFace"
android:label="@string/my_analog_name"
android:permission="android.permission.BIND_WALLPAPER">
<meta-data
android:name="android.service.wallpaper"
android:resource="@xml/watch_face"/>
<!-- 四角い時計のプレビュー -->
<meta-data
android:name="com.google.android.wearable.watchface.preview"
android:resource="@drawable/preview_analog"/>
<!-- 丸い時計のプレビュー -->
<meta-data
android:name="com.google.android.wearable.watchface.preview_circular"
android:resource="@drawable/preview_analog"/>
<!-- ここから追加 -->
<meta-data
android:name="com.google.android.wearable.watchface.wearableConfigurationAction"
android:value="org.firespeed.myapplication.CONFIG" />
<!-- ここまで追加 -->
<intent-filter>
<action android:name="android.service.wallpaper.WallpaperService"/>
<category android:name="com.google.android.wearable.watchface.category.WATCH_FACE"/>
</intent-filter>
</service>
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<CheckBox
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:id="@+id/smooth"
android:text="@string/smooth"/>
</FrameLayout>
private Config mConfig;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_config);
final CheckBox smooth = (CheckBox)findViewById(R.id.smooth);
}
mConfig = new Config(this, new Config.OnConfigChangedListener() {
@Override
public void onConfigChanged(Config config) {
smooth.setChecked(config.isSmooth());
}
});
smooth.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
mConfig.setIsSmooth(isChecked);
}
});
@Override
protected void onResume() {
super.onResume();
mConfig.connect();
}
@Override
protected void onPause() {
super.onPause();
mConfig.disconnect();
}
package org.firespeed.myapplication;
import android.app.Activity;
import android.os.Bundle;
import android.support.wearable.view.WatchViewStub;
import android.util.Log;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.TextView;
public class ConfigActivity extends Activity {
private Config mConfig;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_config);
final CheckBox smooth = (CheckBox)findViewById(R.id.smooth);
mConfig = new Config(this, new Config.OnConfigChangedListener() {
@Override
public void onConfigChanged(Config config) {
smooth.setChecked(config.isSmooth());
}
});
smooth.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
mConfig.setIsSmooth(isChecked);
}
});
}
@Override
protected void onResume() {
super.onResume();
mConfig.connect();
}
@Override
protected void onPause() {
super.onPause();
mConfig.disconnect();
}
}