Watch Faceを作る 画像を使ってオリジナル時計を作る

2015/11/18 3831hit

Android WearのWatch Faceを作る 目次
前回はサンプルコードの仕組みを詳しく追っていきました。

今回はいよいよオリジナルの時計を作ります。自作した画像を使ってアナログ時計を作ってみましょう。
画像を使うことでより多彩な表現を行うことが出来るようになります。
一方で、画像を使う場合はいくつか考慮しないといけないことが増えます。

背景、時針、分針の画像を作る

背景と時針、分針は画像を使って書き出し、秒針はラインで描画することとします。
時針と分針は12時の方向を向いている状態で書き出します。

サンプルの画像も用意しました。サンプル全体のイメージとしてはこんな感じ

もちろん、自分ごのみの画像を用意してもらっても大丈夫です。
画像を用意するときは、背景、時針、分針をそれぞれ別で切り出し、時針と分針は背景が透明なPNG形式で出力するようにしてください。
画像サイズについては今回は512pxの正方形としています。

この画像をここでは便宜上「デザインした画像」と呼ぶことにします。
また、画像アプリ上での位置(px)をデザインした座標と呼びます。

画像ファイル一式をダウンロードする。
background.pngが背景画像
hour.pngが時針
minute.pngが分針
preview.pngがプレビュー用の画像です。
です。

画像ファイルを置くフォルダはwearプロジェクトのsrc/main/res/drawable-nodpiです。
drawableフォルダ内の画像は液晶の密度に合わせて適切な画像が選ばれ、適切に拡大縮小されますが、drawable-nodpiに保存された画像は液晶の密度にかかわらず、常に同じファイルがそのまま取得できます。
今回は画面サイズに合わせた拡大縮小処理を自前で実装するため、フレームワークの余計な処理を防ぐためにdrawable-nodpiに保存します。

Engineに必要なメンバ変数を追加する

デザイン時のサイズを設定する。

MyWatchFace.javaを開いてInnerクラスのEngine部分まで移動します。

static finalな定数にデザインした画像のサイズを設定します。
今回は画像データを512px x 512pxでデザインしたため512をセットしておきます。

private static final float DESIGNED_SIZE = 512f;


Bitmapをキャッシュする変数を作る。

Bitmapの読み込みや拡大縮小は処理負荷が大きいため、描画ごとに毎回行うのではなく、一度読み込んだ画像は変数に格納して再利用するようにします。
今回は背景、時針、分針が画像のため、それぞれのBitmap変数をEngineのインスタンス変数として定義します。

private Bitmap mBackground;
private Bitmap mHour;
private Bitmap mMinute;


サンプルでは背景の描画をmBackgroundPaintが針の描画をmHandPaintが行っていました。
今回はこの役割を変更して、画像データを描画するPaintとそれ以外のPaintとします。
わかりやすいように変数名を変更しましょう。
mBackgroundPaintをmBitmapPaintにmHandPaintをmDrawPaintにそれぞれ変更します。
変数名を一度に変更するときは変更したい変数にカーソルを合わせてShift+F6を使うと便利です。
新しい変数名に変更してENTERを押すと同じ変数が全て入れ替わります。

Paint mBitmapPaint;
Paint mDrawPaint;


画面サイズに応じた値も、毎回計算するのではなく、インスタンス変数に格納するようにします。記録するのは、画面幅、画面高さ、中央位置(縦、横)、秒針の長さ、中央穴の半径、画像を使う素材(今回は背景、時針、分針)は基準となる左上の座標(縦、横)、デザインした画像のサイズ(512px)に対する実画面の比率、画像を変形させるのに使用するMatrixです。
Matrixはimportが複数あるため、android.graphics.Matrix;をインポートします。

private Bitmap mBackground;
private Bitmap mHour;
private Bitmap mMinute;
private int mWidth;
private int mHeight;
private float mCenterX;
private float mCenterY;
private float mSecLength;
private float mHoleRadius;
private float mBackgroundTop;
private float mBackgroundLeft;
private float mHourLeft;
private float mHourTop;
private float mMinuteLeft;
private float mMinuteTop;
private float mScale;
private Matrix mMatrix;



onTapCommand()を修正する。

サンプルでは画面をタップすると背景色が変わる処理が入っていましたが、今回はタップイベントを特に設定しないため、消しておきます。

mTapCount++; // 削除
mBackgroundPaint.setColor(resources.getColor(mTapCount % 2 == 0 ? // 削除
R.color.background : R.color.background2)); // 削除


onCreate()を修正する。

mDrapPaintについて、setAntiAlias(true)にてギザギザが目立たないなめらかなラインを描いていますが、mBitmapPaintは画像を取り扱います。
画像ではアンチエイリアスは画像の端部分にしか適用されず、あまり効果がありません。

変形した画像をなめらかに描画するにはsetFilterBitmap(true)を指定します。mBitmapPaintの設定にsetFilterBitmap(true)を追加しましょう。

mBitmapPaint.setColor(resources.getColor(R.color.background));
mBitmapPaint.setFilterBitmap(true); // 追加

画像を回転させるために使用するMatrixを初期化する処理を追加します。

mMatrix = new Matrix();


onCraete()全体は次のようになります。

@Override
public void onCreate(SurfaceHolder holder) {
super.onCreate(holder);

setWatchFaceStyle(new WatchFaceStyle.Builder(MyWatchFace.this)
.setCardPeekMode(WatchFaceStyle.PEEK_MODE_SHORT)
.setBackgroundVisibility(WatchFaceStyle.BACKGROUND_VISIBILITY_INTERRUPTIVE)
.setShowSystemUiTime(false)
.setAcceptsTapEvents(true)
.build());

Resources resources = MyWatchFace.this.getResources();

mBitmapPaint = new Paint();
mBitmapPaint.setColor(resources.getColor(R.color.background));
mBitmapPaint.setFilterBitmap(true);
mDrawPaint = new Paint();
mDrawPaint.setColor(resources.getColor(R.color.analog_hands));
mDrawPaint.setStrokeWidth(resources.getDimension(R.dimen.analog_hand_stroke));
mDrawPaint.setAntiAlias(true);
mDrawPaint.setStrokeCap(Paint.Cap.ROUND);
mTime = new Time();
mMatrix = new Matrix();
}

onAmbientModeChanged()を書き換える

AmbientModeに入った時にmDarwPaintのアンチエイリアスを指定しているところへ、mBitmapPaintのフィルターを設定する処理を追加します

@Override
public void onAmbientModeChanged(boolean inAmbientMode) {
super.onAmbientModeChanged(inAmbientMode);
if (mAmbient != inAmbientMode) {
mAmbient = inAmbientMode;
if (mLowBitAmbient) {
mDrawPaint.setAntiAlias(!inAmbientMode);
mBitmapPaint.setFilterBitmap(!inAmbientMode); // 追加
}
invalidate();
}

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



onDraw()を書き換える

画面サイズが切り替わった時の処理

サンプルのonDraw()では毎回中央位置を計算していましたが、実際の処理では解像度が変わることは殆どありません。
そこで解像度が替わった時だけ中央位置を計算し、合わせて画像の拡大縮小や、基準点の算出、秒針の長さの設定などを行って、それ以外の時は変数に格納された値を使用するようにします。
前回計算した画面幅、高さと現在の幅、高さを比較することで画面の拡大縮小が発生したかどうかを確認できます。


@Override
public void onDraw(Canvas canvas, Rect bounds) {
mTime.setToNow();
// 以下追加
// 画面サイズが変わったらtrue
boolean isSizeChanged
= canvas.getWidth() != mWidth || canvas.getHeight() != mHeight;
if(isSizeChanged){
// この中に処理を追加していきます
}

画面幅と高さを記憶し、中央位置はサンプル同様に縦横を半分にして計算します。

mWidth = canvas.getWidth();
mHeight = canvas.getHeight();
mCenterX = mWidth / 2;
mCenterY = mHeight / 2;

実サイズとの比率は実サイズ(長編)に対してデザインした画像のピクセル数512px(DESIGNED_SIZE)を割ることで求めています。

int longSize = Math.max(canvas.getWidth(),canvas.getHeight());
mScale = longSize / DESIGNED_SIZE;

背景画像の座標は、画面サイズと画面長辺の差を2で割ることで画像を中央に表示されるようにしています。

mBackgroundLeft= (mWidth - longSize) / 2f;
mBackgroundTop= (mHeight- longSize) / 2f;

例えば画面サイズが縦400x幅300のウォッチなら次のようになります。
left=300-400/2 = -50
top=400-400/2 = 0

画像は正方形で用意して有り、画面の長辺に合わせてリサイズするため400x400の画像です。
これにより画像が画面中央に表示されます。


サンプルでは秒針の長さを単純に画面サイズから引き算していましたが、画像と組み合わせるため、画像の拡大縮小に応じてサイズを変える必要があります。デザインした秒針のサイズ(今回は200pxとしました)にmScaleを掛けることで秒針の長さを求めます。

mSecLength = (int)(200f * mScale);

中央穴の半径はデザインしたサイズにmScaleをかけることで求めます。

mHoleRadius = 12f * mScale;

時針と分針の縦横は画面に対する針の左上の座標を指定します。

デザインした座標にmScaleを掛け、それにmBackgroundLeftあるいはmBackgroundTopを足すことで求まります。


mHourLeft = 244f * mScale + mBackgroundLeft;
mHourTop = 80f * mScale + mBackgroundTop;
mMinuteLeft = 242f * mScale + mBackgroundLeft;
mMinuteTop = 54f * mScale + mBackgroundTop;


画面サイズが切り替わった時の処理全体は次のとおりです。

boolean isSizeChanged = canvas.getWidth() != mWidth || canvas.getHeight() != mHeight;
if (isSizeChanged) {
mWidth = canvas.getWidth();
mHeight = canvas.getHeight();
mCenterX = mWidth / 2;
mCenterY = mHeight / 2;
int longSize = Math.max(canvas.getWidth(), canvas.getHeight());
mScale = longSize / DESIGNED_SIZE;
mBackgroundLeft = (mWidth - longSize) / 2f;
mBackgroundTop = (mHeight - longSize) / 2f;

mSecLength = (int) (200f * mScale);

// 中央穴の位置
mHoleRadius = mScale * 12f;

// 縦横の取得
mHourLeft = 244f * mScale + mBackgroundLeft;
mHourTop = 80f * mScale + mBackgroundTop;
mMinuteLeft = 242f * mScale + mBackgroundLeft;
mMinuteTop = 54f * mScale + mBackgroundTop;


画面の取得

画面サイズが変わった時は画像を取得して画面サイズに応じた拡大・縮小処理を行う必要があります。
画像サイズが変わった時に加えて画像が何らかの理由で取得できていなかったり、破棄されている場合も処理を行うようにします。

画像の取得と拡大縮小は背景と、長針、短針の全てで同じ方法を使いますし、今後画像が増えた時のためにメソッド化しておきましょう。

Engineクラスに次のメソッドを追加します。
Resourcesと画像のidを指定してリサイズされた画像を作成するメソッドを作ります。
最初にIDにもとづいて画像を取得したら、Bitmap.createScaledBitmap()により必要なサイズに画面を縮小・拡大したBitmapを返します。


private Bitmap createSaledBitmap(Resources resources, int id){
Bitmap original=((BitmapDrawable)(resources.getDrawable(id))).getBitmap();
Bitmap scaled = Bitmap.createScaledBitmap(original, (int)(original.getWidth() * mScale), (int)(original.getHeight() * mScale), true );
return scaled;
}


再度onDraw()に戻ります。
画面サイズが切り替わった時の処理の後に画面サイズが変わった時に加えて画像が取得できていない時、画像が破棄された時にも、先ほど追加したメソッドを呼び出して新しいサイズに適合した画像を取得する処理を追加します。。

if(isSizeChanged
|| mBackground == null || mBackground.isRecycled()
|| mHour== null || mHour.isRecycled()
|| mMinute == null || mMinute.isRecycled()){
Resources resources = getResources();
mBackground = createSaledBitmap(resources, R.drawable.background);
mHour = createSaledBitmap(resources, R.drawable.hour);
mMinute = createSaledBitmap(resources, R.drawable.minute);
}


Draw the background部分の書き換え。

サンプルではAmbientModeの時は黒、通常時は背景色を描画していましたが、今回ではAmbientMode時はこれまでどおり黒、通常時はBitmapを中央揃えで描画します。

if (isInAmbientMode()) {
canvas.drawColor(Color.BLACK);
} else {
canvas.drawBitmap(mBackground,mBaseX , mBaseY, mBackgroundPaint);
}


中央位置の計算を毎回やっていた処理は削除します。

float centerX = bounds.width() / 2f; // 削除する
float centerY = bounds.height() / 2f; // 削除する


針の長さをサイズから引き算で求めていた処理も不要です。

float secLength = centerX - 20; // 削除する
float minLength = centerX - 40; // 削除する
float hrLength = centerX - 80; // 削除する


針の描画

サンプルではここから針の描画処理となっていますが、
サンプルでは秒針、分針、時針の順に描画されています。
描画は後から処理されたほうが上書きされるのでこのままでは秒針が一番後ろ、時針が一番前になってしまいます。
そこで、描画順を時針、分針、秒針の順に描画します。

まずは分針と時針の処理を削除します。

float minX = (float) Math.sin(minRot) * minLength; // 削除する
float minY = (float) -Math.cos(minRot) * minLength; // 削除する
canvas.drawLine(centerX, centerY, centerX + minX, centerY + minY, mHandPaint); // 削除する

float hrX = (float) Math.sin(hrRot) * hrLength; // 削除する
float hrY = (float) -Math.cos(hrRot) * hrLength; // 削除する
canvas.drawLine(centerX, centerY, centerX + hrX, centerY + hrY, mHandPaint); // 削除する


次に時針と分針の描画を加えますが、今回は画像を使用するためdrawLineが使えません。
代わりに画像を回転させるためにMatrixを使用します。Matrixを使用することでキャンバスを好きなように変形させることが出来ます。

時針の角度を求めます。
前回はradで求めましたがMatrixは360度で指定します。時間の場合は1〜12時間を360度に分割するため時に30を掛けます。
60分=1時間となるように分も加えます。

float hourRotate= (mTime.hour + mTime.minute / 60f) * 30;


針の描画は最初に針の位置を移動して、針を回転させます。順番を逆にすると針の回転位置がずれてしまうので注意してください。
Matrixでは最初にsetから始まるメソッドで変形を指定し、次にpostから始まるメソッドで変形を加えていきます。
setから始まるメソッドを実行するとそれまでに設定されている変形が全てリセットされるため必ず最初にsetから行います。

setTranslate()により座標を平行移動させることが出来ます。
引数は横方向の移動距離、縦方向の移動距離です。


mMatrix.setTranslate(mHourLeft, mHourTop);


postはこれまで指定されている変形に新たな変形を加えます。
postと別にpreというのもありますが、これは変形を適用する順番をこれまでの変形よりも前に割り込ませたい場合に行います。あまり直感的とは言えないので特に理由がない場合はpostを使ったほうが読みやすいコードになります。

postRotate()により画像を回転させることが出来ます。引数は角度(°)、回転させる中央位置の横方向、中央位置の縦方向です。
位置を指定しなかった場合は画像の中央が指定されたものとみなされます。

今回は画面の真ん中を基準に回転するので既に取得済みのmCenterXと,mCenterYを指定します。

mMatrix.postRotate(hourRotate, mCenterX, mCenterY);


時針を描画します。
drawBitmapの第二引数にmMatrixを指定することで変形を反映できます。

canvas.drawBitmap(mHour, mMatrix, mBitmapPaint);


同様に分の角度も求めて分針を描画します。
分針の角度は分×6で求めることが出来ます。
秒を加える事でよりなめらかな分針にすることも出来ます。

float minuteRotate= (mTime.minute + mTime.second/ 60f) * 6;
mMatrix.setTranslate(mMinuteLeft, mMinuteTop);
mMatrix.postRotate(minuteRotate, mCenterX, mCenterY);
canvas.drawBitmap(mMinute, mMatrix, mBitmapPaint);


秒針を描画します。
秒針はdrawLineで描くので基本的にはこれまで通りです。一部変数をインスタンス変数に移動しているのでそれに合わせて調整します。

if (!mAmbient) {
float secRot = mTime.second / 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);


onDraw()全体は次のようになります。


@Override
public void onDraw(Canvas canvas, Rect bounds) {
mTime.setToNow();
boolean isSizeChanged = canvas.getWidth() != mWidth || canvas.getHeight() != mHeight;
if (isSizeChanged) {
mWidth = canvas.getWidth();
mHeight = canvas.getHeight();
mCenterX = mWidth / 2;
mCenterY = mHeight / 2;
int longSize = Math.max(canvas.getWidth(), canvas.getHeight());
mScale = longSize / DESIGNED_SIZE;
mBackgroundLeft = (mWidth - longSize) / 2f;
mBackgroundTop = (mHeight - longSize) / 2f;

mSecLength = (int) (200f * mScale);

// 中央穴の半径
mHoleRadius = mScale * 12f;

// 縦横の取得
mHourLeft = 244f * mScale + mBackgroundLeft;
mHourTop= 80f * mScale + mBackgroundTop;
mMinuteLeft = 242f * mScale + mBackgroundLeft;
mMinuteTop = 54f * mScale + mBackgroundTop;

}

if (isSizeChanged
|| mBackground == null || mBackground.isRecycled()
|| mHour == null || mHour.isRecycled()
|| mMinute == null || mMinute.isRecycled()) {
Resources resources = getResources();
mBackground = createScaledBitmap(resources, R.drawable.background);
mHour = createScaledBitmap(resources, R.drawable.hour);
mMinute = createScaledBitmap(resources, R.drawable.minute);
}

// Draw the background.
if (isInAmbientMode()) {
canvas.drawColor(Color.BLACK);
} else {
canvas.drawBitmap(mBackground, mBackgroundLeft, mBackgroundTop, mBitmapPaint);
}
float hourRotate= (mTime.hour + mTime.minute / 60f) * 30;
mMatrix.setTranslate(mHourLeft, mHourTop);
mMatrix.postRotate(hourRotate, mCenterX, mCenterY);
canvas.drawBitmap(mHour, mMatrix, mBitmapPaint);

float minuteRotate= (mTime.minute + mTime.second/ 60f) * 6;
mMatrix.setTranslate(mMinuteLeft, mMinuteTop);
mMatrix.postRotate(minuteRotate, mCenterX, mCenterY);
canvas.drawBitmap(mMinute, mMatrix, mBitmapPaint);

if (!mAmbient) {
float secRot = mTime.second / 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);

}


XML等を調整する

秒針を補足する

時針や分針に対して秒針が太すぎる感じがします。そこで秒針の太さを変えてみます。
onCreate()を見ると秒針の太さがXMLで指定されていることがわかります。

mDrawPaint.setStrokeWidth(resources.getDimension(R.dimen.analog_hand_stroke));

dimens.xmlを開くとanalog_hand_strokeが3dpで指定されているので、これを1dpに変更します。

これで秒針が細くなりました。

<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="analog_hand_stroke">1dp</dimen>
</resources>


プレビューを変更する

WatchFaceを変更するときのプレビューはdrawable-nodpiフォルダ内にあるanalog_preview.pngに格納されています。
このファイルを上書きすることでプレビューを変更できます。

別のファイル名に指定したい場合はAndroidManifest.xmlを指定します。

<!-- 四角い時計のプレビュー -->
<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" />



時計の名前を変更する

時計の名前を変更したい場合はStrings.xmlのmy_analog_name要素を書き換えます。

<resources>
<string name="app_name">My Application</string>
<string name="my_analog_name">My Analog</string>
</resources>


次回はより滑らかなアニメーションを行うためにミリ秒を取り扱う方法とユーザーがカスタマイズ可能な設定画面の作り方を紹介します。

前:Watch Faceを作る サンプルのコードを読み解く 次:Watch Faceを作る 設定画面の作成

関連キーワード

[Android][Java][モバイル][IT][ウェアラブル]

コメントを投稿する

名前URI
コメント