名前はまだない。

プログラミングのことや趣味のことに関して綴ります。

AndroidのAppWidgetに挑戦してみた

最近、個人プロジェクトでやってるAndroidアプリにWidgetを実装してみたので、その手順をメモ。
ここで言うWidgetは、アプリを起動せずともホームアプリ内でよろしくできるインターフェースの事で、
要するに他アプリに埋め込めるミニアプリみたいなものです。

詳しくは、App Widgets | Android Developersを参照してください。

今回やりたいこと

「一週間メモリーズ。」というメモ帳アプリで作成したメモのリストをWidgetに表示してやろうと思います。
イメージとしてはこんな感じ。

f:id:kobaken0029:20160216053557p:plain:w300

Toolbar的なものがあったり、メニューがあったり、リストがあったりな感じです。
では、順を追って実装してみましょう。

↓興味があったら是非DLを(笑)↓ play.google.com

Layoutを定義

まずはWidgetのLayoutを定義していきます。
Widgetでは使用できるLayoutやViewに制限があります。

利用できるLayout Class
FrameLayout
LinearLayout
RelativeLayout
GridLayout
利用できるView Class
AnalogClock
Button
Chronometer
ImageButton
ImageView
ProgressBar
TextView|
ViewFlipper
ListView
GridView
StackView
AdapterViewFlipper

今回のLayoutはこんな感じ↓

<!-- widget_layout.xml -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/background"
    android:orientation="vertical">

    <FrameLayout
        android:id="@+id/header"
        android:layout_width="match_parent"
        android:layout_height="48dp"
        android:background="@color/main_color"
        android:padding="@dimen/activity_padding">

        <TextView
            android:id="@+id/header_text"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginEnd="8dp"
            android:layout_marginStart="8dp"
            android:gravity="center|start"
            android:text="@string/app_name"
            android:textColor="@color/white"
            android:textSize="@dimen/text_widget_title" />

        <FrameLayout
            android:id="@+id/icon_container"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="end"
            android:layout_marginEnd="4dp"
            android:layout_marginStart="4dp"
            android:clickable="true">

            <ImageView
                android:id="@+id/create_memo_button"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:contentDescription="@string/create_view"
                android:src="@drawable/ic_action_editor_mode_edit" />
        </FrameLayout>
    </FrameLayout>

    <ListView
        android:id="@+id/memo_list"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:divider="@color/grey400"
        android:dividerHeight="0.5dp" />
</LinearLayout>

AppWidgetProviderのサブクラスを実装

Widgetを実装するにあたってAppWidgetProviderを継承したサブクラスを実装していきましょう。
AppWIdgetProviderはBroadcastReceiverを継承しているため、ほぼReceiverと同様の扱いでいけます。
以下のメソッドはAppWidgetProviderで定義されたものです。

onEnabled(Context context)

Widgetが追加されるときに呼ばれる

onUpdate(Context context, AppWidgetManager appWidgetManager, int appWidgetIds)

Widget が追加されるとき、またはupdatePeriodMillisで指定されたインターバルに達したときに呼ばれる

onDeleted(Context context, int appWidgetIds)

Widgetインスタンスが削除されたときに呼ばれる

onDisabled(Context context)

Widgetの最後のインスタンスが削除されたときに呼ばれる

今回はonUpdateでレイアウト定義したものをRemoteViewsとして取り出して扱っていきます。
WidgetのView押下時のイベント等の設定もここでします。
基本的にIntentを飛ばしてやりたいことする感じ。

onUpdateでWidgetを定義
public class OneMemoWidgetProvider extends AppWidgetProvider {
    private static final String ACTION_ITEM_CLICK = "com.kobaken0029.android_appwidget.ACTION_ITEM_CLICK";
    private static final String ACTION_CLICK = "com.kobaken0029.android_appwidget.ACTION_CLICK";

    @Override
    public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
        if (appWidgetIds != null && appWidgetIds.length > 0) {
            for (int appWidgetId : appWidgetIds) {
                // ウィジェットレイアウトの初期化
                RemoteViews remoteViews = new RemoteViews(context.getPackageName(), R.layout.widget_layout);

                // ヘッダー
                Intent activityIntent = new Intent(context, NavigationActivity.class);
                PendingIntent activityPendingIntent = PendingIntent.getActivity(context, 0, activityIntent, 0);
                remoteViews.setOnClickPendingIntent(R.id.header, activityPendingIntent);

                // 新規作成アイコン
                Intent clickIntent = new Intent(context, OneMemoWidgetProvider.class);
                clickIntent.setAction(ACTION_CLICK);
                PendingIntent createMemoPendingIntent = PendingIntent.getBroadcast(
                        context,
                        0,
                        clickIntent,
                        PendingIntent.FLAG_UPDATE_CURRENT
                );
                remoteViews.setOnClickPendingIntent(R.id.icon_container, createMemoPendingIntent);

                // リスト
                Intent widgetServiceIntent = new Intent(context, OneMemoWidgetService.class);
                remoteViews.setRemoteAdapter(R.id.memo_list, widgetServiceIntent);

                Intent itemClickIntent = new Intent(context, OneMemoWidgetProvider.class);
                itemClickIntent.setAction(ACTION_ITEM_CLICK);
                PendingIntent itemClickPendingIntent = PendingIntent.getBroadcast(
                        context,
                        0,
                        itemClickIntent,
                        PendingIntent.FLAG_UPDATE_CURRENT
                );
                remoteViews.setPendingIntentTemplate(R.id.memo_list, itemClickPendingIntent);

                appWidgetManager.updateAppWidget(appWidgetId, remoteViews);
            }
        }
    }
}
onReceiveでWidget or アプリ内からのアクションを受け取る

onUpdateで怒りの矛先(?)を自身に向けた場合(投げたIntentが自身だったら)、onReceiveで受け取ることができます。
受け取ったIntentからIntent#getActionで処理を分けていきます。
投げるIntentにIntent.FLAG_ACTIVITY_NEW_TASKというフラグをセットする必要があります。

public class OneMemoAppWidgetProvider extends AppWidgetProvider {
    private static final String ACTION_ITEM_CLICK = "com.kobaken0029.android_appwidget.ACTION_ITEM_CLICK";
    private static final String ACTION_CLICK = "com.kobaken0029.android_appwidget.ACTION_CLICK";

    @Override
    public void onReceive(Context context, Intent intent) {
        super.onReceive(context, intent);

        Intent navigationActivityIntent = new Intent(context, NavigationActivity.class);
        switch (intent.getAction()) {
            case ACTION_CLICK:
                navigationActivityIntent.putExtra(MemoFragment.TAG, true);
                navigationActivityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                context.startActivity(navigationActivityIntent);
                break;
            case ACTION_ITEM_CLICK:
                navigationActivityIntent.putExtra(Memo.ID, intent.getLongExtra(Memo.ID, 0L));
                navigationActivityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                context.startActivity(navigationActivityIntent);
                break;
            default:
                break;
        }
    }
}

AppWidgetProviderInfoを定義

次にres/xml/AppWidgetProviderInfoを定義していきます。
xml形式での記述になります。

<!-- widget_provider.xml -->
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:initialLayout="@layout/widget_layout"
    android:updatePeriodMillis="86400000"  
    android:minHeight="110dp"
    android:minWidth="180dp"
    android:previewImage="@drawable/memomiya"
    android:resizeMode="horizontal|vertical" />

それぞれの属性の意味は下記の通りです。

android:initialLayout

Widgetの初期状態のレイアウトを指定します。

android:updatePeriodMillis

Widgetの更新タイミングをミリ秒で指定します。
指定時間は1800000(30分)以上でなければなりません。

android:minHeight, android:minWidth

Widgetの最小幅、高さを指定します。
この値はDetermining a size for your widgetで提示されているように以下の値を使用するべきです。

セルの数 サイズ(dp)
1 40dp
2 110dp
3 180dp
4 250dp
n 70 × n − 30
android:previewImage

ユーザがWidgetを選ぶ際に確認できるように画像等を指定します。
未指定の場合はアプリのアイコンが表示されます。

android:resizeMode

Widgetがリサイズ出来るかどうかを指定します。

RemoteViewsServiceのサブクラスからWidgetを描画

Widgetにリストを使用するので、その要素たちを描画するためにServiceを使用したいと思います。

リストのItemごとのLayoutを定義

まず、リストの各Item(row)のLayoutを定義していきます。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:paddingBottom="4dp"
    android:paddingEnd="8dp"
    android:paddingStart="8dp"
    android:paddingTop="4dp">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <TextView
            android:id="@+id/subject_text"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:ellipsize="end"
            android:maxLines="1"
            android:paddingEnd="8dp"
            android:paddingStart="8dp"
            android:paddingTop="8dp"
            android:textColor="@color/text"
            android:textSize="@dimen/text_widget_subject"
            tools:text="Subject" />

        <TextView
            android:id="@+id/last_update_text"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:gravity="end"
            android:paddingEnd="12dp"
            android:paddingStart="12dp"
            android:paddingTop="8dp"
            android:textColor="@color/sub_text"
            android:textSize="@dimen/text_widget_update"
            tools:text="Subject" />

    </LinearLayout>

    <TextView
        android:id="@+id/memo_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:maxLines="2"
        android:paddingBottom="8dp"
        android:paddingEnd="8dp"
        android:paddingStart="8dp"
        android:textColor="@color/sub_text"
        android:textSize="@dimen/text_widget_memo"
        tools:text="Main Contents" />

</LinearLayout>
RemoteViewsFactoryを実装

次に、RemoteViewsFactoryを実装します。

private class OneMemoWidgetFactory implements RemoteViewsFactory {
        private MemoHelper memoHelper;
        private List<Memo> memos;

        @Override
        public void onCreate() {
            memoHelper = new MemoHelperImpl();
            // DBからメモを全て取得する
            memos = memoHelper.findAll();
        }

        @Override
        public void onDataSetChanged() {
            // DBからメモを全て取得する
            memos = memoHelper.findAll();
        }

        @Override
        public void onDestroy() {

        }

        @Override
        public RemoteViews getViewAt(int position) {
            if (memos.size() <= 0) {
                return null;
            }

            Memo memo = memos.get(position);

            RemoteViews remoteViews = new RemoteViews(getPackageName(), R.layout.row_widget_memo_list);
            remoteViews.setTextViewText(R.id.subject_text, memo.getSubject());
            remoteViews.setTextViewText(
                    R.id.last_update_text,
                    DateUtil.convertToString(
                            DateUtil.MONTH_DAY,
                            DateUtil.convertStringToDate(DateUtil.YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, memo.getUpdateAt())
                    )
            );
            remoteViews.setTextViewText(R.id.memo_text, memo.getMemo());

            Intent intent = new Intent();
            intent.putExtra(Memo.ID, memo.getId());
            remoteViews.setOnClickFillInIntent(R.id.container, intent);

            return remoteViews;
        }

        @Override
        public int getCount() {
            return memos.size();
        }

        @Override
        public RemoteViews getLoadingView() {
            return null;
        }

        @Override
        public long getItemId(int position) {
            return position;
        }

        @Override
        public int getViewTypeCount() {
            return 1;
        }

        @Override
        public boolean hasStableIds() {
            return true;
        }
    }
onGetViewFactoryでRemoteViewsFactory実装クラスを返却

先ほど作成したRemoteViewsFactoryの実装クラスをonGetViewFactoryでReturnします。

public class OneMemoWidgetService extends RemoteViewsService {

    @Override
    public RemoteViewsFactory onGetViewFactory(Intent intent) {
        return new OneMemoWidgetFactory();
    }
}

AndroidManifestにAppWidget関連を宣言

AppWidgetに関する記述をAndroidManifestへ。

<receiver
    android:name=".views.widget.OneMemoWidgetProvider"
    android:label="@string/app_name">
    <intent-filter>
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
        <action android:name="com.kobaken0029.android_appwidget.ACTION_UPDATE" />
    </intent-filter>

    <meta-data
        android:name="android.appwidget.provider"
        android:resource="@xml/widget_provider" />
</receiver>

<service
    android:name=".services.OneMemoWidgetService"
    android:permission="android.permission.BIND_REMOTEVIEWS" />

アプリ内での変更をWidgetに反映させる為に

最後に、アプリ内でメモを新規作成・編集した内容をWidgetへ伝えるために
冒頭部分で定義していたAppWidgetProviderのサブクラスのonReceiveでAppWidgetManager#notifyAppWidgetViewDataChangedを呼びます。

public class OneMemoWidgetProvider extends AppWidgetProvider {
    public static final String ACTION_UPDATE = "com.kobaken0029.android_appwidget.ACTION_UPDATE";

    // 中略

    @Override
    public void onReceive(Context context, Intent intent) {
        super.onReceive(context, intent);

        switch (intent.getAction()) {
            // 中略
            case ACTION_UPDATE:
                // AppWidgetManagerのインスタンスを取得
                AppWidgetManager manager = AppWidgetManager.getInstance(context);

                // 対象Widgetのコンポーネント名を取得
                ComponentName myWidget = new ComponentName(context, OneMemoWidgetProvider.class);
                int[] appWidgetIds = manager.getAppWidgetIds(myWidget);

                // Managerにデータ変更の通知をする
                manager.notifyAppWidgetViewDataChanged(appWidgetIds, R.id.memo_list);
                break;
            default:
                break;
        }
    }
}

そしてActivity or Fragment等からIntent経由でProviderを呼び出せばOK。

Intent intent = new Intent(getApplicationContext(), OneMemoWidgetProvider.class);
intent.setAction(OneMemoWidgetProvider.ACTION_UPDATE);
try {
    PendingIntent.getBroadcast(getApplicationContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT).} catch (PendingIntent.CanceledException e) {
    e.printStackTrace();
}

最後に

以上、AndroidWidgetを作る方法でした。
詳しいことは公式リファレンスをご覧になることをおすすめします(投げやり)
それでは、楽しい楽しいAndroid Lifeをノシ