Поиск по этому блогу

понедельник, 29 мая 2017 г.

Создаем циферблат для Android Wear

Купил я значит себе часы на android wear, и думал что они будут полезные мне. На деле совершенно безсмысленная хрень, на деле — фитнес трекер с функцией приема звонков и уведомлений. На них конечно можно еще играть в игры которые адаптированы под маленький экранчик, но это выглядит дико, когда в метро или на улице идет дядька и втыкает в часы на руке. Дико в общем.

Посмотрев на экраны часов которые предоставленны в google play, я подумал что лучше я буду использовать стандартные циферблаты которые предустановленны в часах. Но потом меня осенило, я же типа программист, я могу сам запедалить себе циферблат который мне будет подходить, ну и в общем я решил попробовать и сделать себе какую-то красоту на часы. Так как у меня вкуса нет вообще, и дизайнер из меня никакой экран я сделал очень простой, ну и тут я вам покажу основные функции. У нас часы будут уметь показывать время, дату и уровень батареи.


Рисовать мы будем с помощью canvas'a, в CanvasWatchFaceService. Для этого вам нужно создать проект специальный для часов. Вам создастся проект в котором у вас будет пример с аналоговыми часами. Стираем все по самое
public class WatchFaceService extends CanvasWatchFaceService { }
Теперь у нас есть пустой класс, мы разобьем логику часов на две части, в первой у нас будет вся логика относящаяся к работе часов в фоне, и во второй у нас будет вся отрисовка с помощью canvas'a на экране. Так и понятней и логичней как по мне.

Сначало создадим dimens и colors файлы, они нам понадобятся для установки размеров текста на экране, и их цветов. А дальше приведу код который реализует нам наш циферблат.

dimens.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <dimen name="time_size">46dp</dimen>
    <dimen name="date_size">20dp</dimen>
</resources>

colors.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="primaryColorBlue">#3498db</color>
    <color name="primaryBackgroundColor">#1a1d1d</color>
</resources>

Изначально хотел сделать циферблат какого-то темного серого цвета, но потом в итоге переиграл и решил сделать черного, так что на параметер primaryBackgroundColor можете не обращать внимания, но если захотите поменять на свой цвет то можете просто изменить его тут и потом в коде :)

Теперь наш AndroidManifest файл, он немного отличается от тех что мы обычно имеем когда делаем обычное приложение.

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.project.androidwearexample">

    <uses-feature android:name="android.hardware.type.watch" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@android:style/Theme.DeviceDefault">
        <service
            android:name=".WatchFaceService"
            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/icon" />
            <meta-data
                android:name="com.google.android.wearable.watchface.preview_circular"
                android:resource="@drawable/icon" />

            <intent-filter>
                <action android:name="android.service.wallpaper.WallpaperService" />

                <category android:name="com.google.android.wearable.watchface.category.WATCH_FACE" />
            </intent-filter>
        </service>

        <meta-data
            android:name="com.google.android.gms.version"
            android:value="@integer/google_play_services_version" />
    </application>

</manifest>

Тут у нас есть один uses-feature который указывает что это приложение будет для часов, так же у нас есть еще uses-permission который позволяет разблокировать экран часов при повороте. Тут же мы указываем в параметре service, что мы запускаем WatchFaceService сразу после запуска приложения на часах, устанавливаем что оно работает как обои для андроида, устанавливаем превью, иконку и указываем что можем принимать уведомления на экране.

Теперь нам нужно написать наш WatchFaceService который будет собственно нашим циферблатом.

WatchFaceService.java

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.os.BatteryManager;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.support.wearable.watchface.CanvasWatchFaceService;
import android.support.wearable.watchface.WatchFaceStyle;
import android.util.Log;
import android.view.SurfaceHolder;

import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.api.GoogleApiClient;
import com.google.android.gms.common.api.ResultCallback;
import com.google.android.gms.wearable.DataApi;
import com.google.android.gms.wearable.DataEventBuffer;
import com.google.android.gms.wearable.DataItemBuffer;
import com.google.android.gms.wearable.Wearable;

import java.util.concurrent.TimeUnit;

/**
 * Created by gleb on 5/28/17.
 */

public class WatchFaceService extends CanvasWatchFaceService {

    private static final long TICK_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(1);

    @Override
    public Engine onCreateEngine() {
        return new SimpleEngine();
    }

    private class SimpleEngine extends CanvasWatchFaceService.Engine implements
            GoogleApiClient.ConnectionCallbacks, GoogleApiClient.OnConnectionFailedListener {

        private static final String ACTION_TIME_ZONE = "time-zone";
        private static final String TAG = "SimpleEngine";

        private WatchFaceView watchFace;
        private Handler timeTick;
        private GoogleApiClient googleApiClient;

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

            setWatchFaceStyle(new WatchFaceStyle.Builder(WatchFaceService.this)
                    .setCardPeekMode(WatchFaceStyle.PEEK_MODE_SHORT)
                    .setAmbientPeekMode(WatchFaceStyle.AMBIENT_PEEK_MODE_HIDDEN)
                    .setBackgroundVisibility(WatchFaceStyle.BACKGROUND_VISIBILITY_INTERRUPTIVE)
                    .setShowSystemUiTime(false)
                    .build());

            timeTick = new Handler(Looper.myLooper());
            startTimerIfNecessary();

            watchFace = WatchFaceView.newInstance(WatchFaceService.this);
            googleApiClient = new GoogleApiClient.Builder(WatchFaceService.this)
                    .addApi(Wearable.API)
                    .addConnectionCallbacks(this)
                    .addOnConnectionFailedListener(this)
                    .build();
        }

        private void startTimerIfNecessary() {
            timeTick.removeCallbacks(timeRunnable);
            if (isVisible() && !isInAmbientMode()) {
                timeTick.post(timeRunnable);
            }
        }

        private final Runnable timeRunnable = new Runnable() {
            @Override
            public void run() {
                onSecondTick();

                if (isVisible() && !isInAmbientMode()) {
                    timeTick.postDelayed(this, TICK_PERIOD_MILLIS);
                }
            }
        };

        private void onSecondTick() {
            invalidateIfNecessary();
        }

        private void invalidateIfNecessary() {
            if (isVisible() && !isInAmbientMode()) {
                invalidate();
            }
        }

        @Override
        public void onVisibilityChanged(boolean visible) {
            super.onVisibilityChanged(visible);
            if (visible) {
                registerTimeZoneReceiver();
                googleApiClient.connect();
            } else {
                unregisterTimeZoneReceiver();
                releaseGoogleApiClient();
            }

            startTimerIfNecessary();
        }

        private void releaseGoogleApiClient() {
            if (googleApiClient != null && googleApiClient.isConnected()) {
                Wearable.DataApi.removeListener(googleApiClient, onDataChangedListener);
                googleApiClient.disconnect();
            }
        }

        private void unregisterTimeZoneReceiver() {
            unregisterReceiver(timeZoneChangedReceiver);
            unregisterReceiver(mBatInfoReceiver);
        }

        private void registerTimeZoneReceiver() {
            IntentFilter timeZoneFilter = new IntentFilter(Intent.ACTION_TIMEZONE_CHANGED);
            registerReceiver(timeZoneChangedReceiver, timeZoneFilter);

            IntentFilter batteryFilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
            registerReceiver(mBatInfoReceiver, batteryFilter);
        }

        private BroadcastReceiver timeZoneChangedReceiver = new BroadcastReceiver() {
            @Override
            public void onReceive(Context context, Intent intent) {
                if (Intent.ACTION_TIMEZONE_CHANGED.equals(intent.getAction())) {
                    watchFace.updateTimeZoneWith(intent.getStringExtra(ACTION_TIME_ZONE));
                }
            }
        };

        private BroadcastReceiver mBatInfoReceiver = new BroadcastReceiver(){
            @Override
            public void onReceive(Context arg0, Intent intent) {
                watchFace.updateBattery(String.valueOf(intent.getIntExtra(BatteryManager.EXTRA_LEVEL, 0) + "%"));
            }
        };

        @Override
        public void onDraw(Canvas canvas, Rect bounds) {
            super.onDraw(canvas, bounds);
            watchFace.draw(canvas, bounds);
        }

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

        @Override
        public void onAmbientModeChanged(boolean inAmbientMode) {
            super.onAmbientModeChanged(inAmbientMode);
        }

        @Override
        public void onConnected(Bundle bundle) {
            Log.d(TAG, "connected GoogleAPI");

            Wearable.DataApi.addListener(googleApiClient, onDataChangedListener);
            Wearable.DataApi.getDataItems(googleApiClient).setResultCallback(onConnectedResultCallback);
        }

        private final DataApi.DataListener onDataChangedListener = new DataApi.DataListener() {
            @Override
            public void onDataChanged(DataEventBuffer dataEvents) {
                dataEvents.release();
                invalidateIfNecessary();
            }
        };

        private final ResultCallback<DataItemBuffer> onConnectedResultCallback = new ResultCallback<DataItemBuffer>() {
            @Override
            public void onResult(DataItemBuffer dataItems) {
                dataItems.release();
                invalidateIfNecessary();
            }
        };

        @Override
        public void onConnectionSuspended(int i) {
            Log.e(TAG, "suspended GoogleAPI");
        }

        @Override
        public void onConnectionFailed(ConnectionResult connectionResult) {
            Log.e(TAG, "connectionFailed GoogleAPI");
        }

        @Override
        public void onDestroy() {
            timeTick.removeCallbacks(timeRunnable);
            releaseGoogleApiClient();

            super.onDestroy();
        }
    }
}

Если в кратце, то тут мы имеем стандартный код для запуска работы наших часов, что бы у нас они умели тикать, считывать время, дату, уровень батареи, можем тут же назначить получение погоды для определенного города и получение количества шагов из приложения которое у вас идет по дефолту в часах. В нашем случае мы запускаем два бродкаст ресивера, один у нас получает дату и время, второй уровень батареи и потом эти данные в onReceive мы передаем во второй класс, который мы напишем далее, в нем как я ранее говорил мы будем рисовать часы. 

Этот класс мы вызываем в onCreate, можете посмотреть как оно там реализовано.

WatchFaceView.java

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.text.format.Time;

/**
 * Created by gleb on 5/28/17.
 */

public class WatchFaceView {

    private static final String TIME_FORMAT_WITHOUT_SECONDS = "%02d:%02d";
    private static final String TIME_FORMAT_WITH_SECONDS = TIME_FORMAT_WITHOUT_SECONDS + ":%02d";
    private static final String DATE_FORMAT = "%02d.%02d.%d";

    private final Paint timePaint;
    private final Paint datePaint;
    private final Paint batteryPaint;
    private final Paint backgroundPaint;
    private final Paint secondStickPaint;
    private final Time time;

    private boolean shouldShowSeconds = true;

    private String batteryText = "100%";

    private int mWidth;
    private int mHeight;

    public static WatchFaceView newInstance(Context context) {
        Paint timePaint = new Paint();
        timePaint.setColor(context.getResources().getColor(R.color.primaryColorBlue));
        timePaint.setTextSize(context.getResources().getDimension(R.dimen.time_size));
        timePaint.setAntiAlias(true);

        Paint datePaint = new Paint();
        datePaint.setColor(context.getResources().getColor(R.color.primaryColorBlue));
        datePaint.setTextSize(context.getResources().getDimension(R.dimen.date_size));
        datePaint.setAntiAlias(true);

        Paint backgroundPaint = new Paint();
        backgroundPaint.setColor(context.getResources().getColor(R.color.black));

        Paint batteryPaint = new Paint();
        batteryPaint.setColor(context.getResources().getColor(R.color.primaryColorBlue));
        batteryPaint.setTextSize(context.getResources().getDimension(R.dimen.date_size));
        batteryPaint.setAntiAlias(true);

        Paint secondStickPaint = new Paint();
        secondStickPaint.setColor(context.getResources().getColor(R.color.primaryColorBlue));
        secondStickPaint.setStrokeWidth(3);
        secondStickPaint.setAntiAlias(true);

        return new WatchFaceView(timePaint, datePaint, batteryPaint, secondStickPaint, backgroundPaint, new Time());
    }

    WatchFaceView(Paint timePaint, Paint datePaint, Paint batteryPaint, Paint secondStickPaint, Paint backgroundPaint, Time time) {
        this.timePaint = timePaint;
        this.datePaint = datePaint;
        this.backgroundPaint = backgroundPaint;
        this.batteryPaint = batteryPaint;
        this.secondStickPaint = secondStickPaint;
        this.time = time;
    }

    public void draw(Canvas canvas, Rect bounds) {
        mWidth = canvas.getWidth();
        mHeight = canvas.getHeight();

        float mCenterX = mWidth / 2f;
        float mCenterY = mHeight / 2f;

        time.setToNow();
        canvas.drawRect(0, 0, bounds.width(), bounds.height(), backgroundPaint);

        String timeText = String.format(shouldShowSeconds ? TIME_FORMAT_WITH_SECONDS : TIME_FORMAT_WITHOUT_SECONDS, time.hour, time.minute, time.second);
        float timeXOffset = computeXOffset(timeText, timePaint, bounds);
        float timeYOffset = bounds.centerY();
        canvas.drawText(timeText, timeXOffset, timeYOffset, timePaint);

        String dateText = String.format(DATE_FORMAT, time.monthDay, (time.month + 1), time.year);
        float dateXOffset = computeXOffset(dateText, datePaint, bounds);
        float dateYOffset = computeYOffset(dateText, datePaint);
        canvas.drawText(dateText, dateXOffset, timeYOffset + dateYOffset, datePaint);

        float batteryXOffset = computeXOffset(batteryText, batteryPaint, bounds);
        float batteryYOffset = computeYOffset(batteryText, batteryPaint);
        canvas.drawText(batteryText, batteryXOffset, timeYOffset + dateYOffset + batteryYOffset, batteryPaint);

        float mSecondHandLength = mCenterX;
        float secondsRotation = time.second * 6f;
        canvas.rotate(secondsRotation, mCenterX, mCenterY);
        canvas.drawLine(mCenterX, mCenterY - 120, mCenterX, mCenterY - mSecondHandLength, secondStickPaint);
    }

    private float computeXOffset(String text, Paint paint, Rect watchBounds) {
        float centerX = watchBounds.exactCenterX();
        float timeLength = paint.measureText(text);
        return centerX - (timeLength / 2.0f);
    }

    private float computeYOffset(String dateText, Paint datePaint) {
        Rect textBounds = new Rect();
        datePaint.getTextBounds(dateText, 0, dateText.length(), textBounds);
        return textBounds.height() + 10.0f;
    }

    public void updateTimeZoneWith(String timeZone) {
        time.clear(timeZone);
        time.setToNow();
    }

    public void updateBattery(String batteryText) {
        this.batteryText = batteryText;
    }
}

Вот наш класс который рисует наш циферблат. В конструкторе мы задаем все нужные цвета для текста, толщину текста и т.д. Дальше у нас следует метод draw. в нем мы рисуем, с нужными отступами, нужный текст по центру экрана, и тут же мы рисуем секундную стрелку которая тикает по кругу экрана. А дальше у нас просто идет метод вычисления центра экрана по X и Y и сеттеры для задавания времени, даты и уровня батареи. Вот и весь код который нам нужен. 

Для компиляции нам нужно в настройках компилятора убрать в Launch Options — Default activity, и поменять его на nothing. Тогда вы сможете скомпилировать проект под часы, иначе просто студия не сможет запустить компиляцию, так как у нас не будет активити для стартового запуска.


Исходники:

GitHub