全く無知な状態から、Android用の動的リストを使うウィジェットアプリを作った

# はじめに
業務では主にSalesforceばかりやっており、アプリ開発なんて1mmも触れたことのなかった僕です。
ただ、どうしても自分用にアプリが欲しくなり「やれば出来るやろ！」の精神で頑張った足跡を残しつつ、今後同じようなアプリを作りたい人の参考になればと思います。

ただ、作り終わった後に記憶をなぞりつつ今書いているので、ヌケモレはあるかも・・。

# 出来たもの
こんな感じの、Googleカレンダーの内容を表示するウィジェットです。
ただ、公式で提供されているものと違う点として、「業務時間外（早朝、定時後、休日、有給日）であれば業務用アカウントの予定を表示しない」という機能をつけています。

**【業務時間中】**
![Screenshot_2025-03-07-11-41-26-603_com.miui.home-edit.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2603116/25655fd5-3b5b-4c9c-b5b9-3f5f4d7e824b.jpeg)

**【業務時間外】**
![Screenshot_2025-03-07-12-33-38-424_com.miui.home.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2603116/26ce2197-365c-4824-b307-768bf8ed4421.jpeg)

これは、休日に「明日の会議の予定」やら「明日の朝会の予定」やらなんやらを見てテンションを下げたくないからです。

# 製造過程

ながくなったのでCalendarContractに関しての情報は別記事にしました。

https://qiita.com/kft12/items/bafbbc52346556317165

## ざっくり各種フォルダ、ファイルの説明
:::note
`ctrl+shift+n`でファイル検索が出来ます。
:::

### AndroidManifest.xml
アプリの大本の設定をするところ、みたいです。
あとでちょこちょこ出てきます。

### new_app_widget_info.xml
ウィジェット作成時に指定した名前によって、おそらく名前は変わるはず。
ウィジェットにまつわる設定をします。
再描画の間隔を指定したり、デフォルトサイズを指定したり。

### MainActivity.kt
特に名前の設定をいじってなければ、おそらくこの名前になっているはず。
アプリを開いたときに動くコントローラーです。
ただ、今回作ったアプリは完全にウィジェット機能のみしか使ってないため、何も改修しませんでした。

### NewAppWidget.kt
ウィジェット作成時に指定した名前によって、おそらく名前は変わるはず。
ウィジェットのコントローラーです。

### NewWidgetService.kt
途中、自分で追加します。
ListView（動的リスト）を利用するために必要なクラスです。
この中で「どんなデータを表示するか」「各データにどのような値を表示するか」などを決めます。

### layoutフォルダ
自分でデザインするようなレイアウトはここにxmlファイルを作成していくのがマナーのようです。
プロジェクト作成時にはありませんが、ウィジェット作成のタイミングで追加されました。

### valuesフォルダ
デフォルトで「colors.xml」「strings.xml」「themes.xml」が入っていると思います。
ここに入っている内容は定数としてレイアウト画面やプログラムから参照出来ます。

文字色だとか、定型文だとかをここに保存していきます。

編集は直接xmlファイルを書き換えても出来ますし、画面左に表示されている`Resource Manager`からでも出来ます。

## 1. Android Studioをインストール
ここはもう、他でさんざん記載があると思うので省略します。
インストールしました。

## 2. 枠組みを作る
初回だったらおそらく、新規プロジェクト作成のフローが自動的に表示される・・はず。

1. どういったテンプレを使うか聞かれるので`Empty Activity`を選択
1. 各種設定を行う
Nameだけ好みのものに変更して、他はデフォルト値のままとしました。

## 3. ウィジェットを追加する
画像の通り、画面左のエクスプローラー（Android Studioだと`Project`と呼ぶ？ややこし）の適当な箇所を左クリックし、Widgetを作成します。

ClassNameはわかりやすいものを適当に指定したら良いです。
今回はデフォルト値のまま`NewAppWidget`としました。

そうすると、色々なファイルが追加されたり、`AndroidManifest.xml`がいい感じに更新されたりしています。

## 4. ウィジェットに動的リストを追加する
ウィジェットはRemoteViewという仕組みを利用しているのですが、処理上の問題なのか、使えるレイアウトコンポーネントが限られています。
子アイテムをどのように表示したいかによって`GridView` `ListView` `StackView`の選択肢があるのですが、今回はシンプルに縦並びにしたいので **「ListView」** を使います。

3.で自動生成された`new_app_widget.xml`を開いて、`Palette`から`ListView`をドラッグ・アンド・ドロップで突っ込みます。
この時、デフォルトで生成されている **`TextView`は残しておきましょう。**
文字列やIDは変更しても良いですが、TextViewが後で一つ必要になります。

（上記はXMLを編集するやり方でも出来ます）

<details><summary>作成したウィジェットレイアウト用xml</summary>

色々いじってあるので、上の手順だけだと同じものにはならないです。
大事なのは
- ListViewが追加されている
- TextViewが存在している

こと！

```xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#99000000"
    android:orientation="vertical"
    android:theme="@style/Theme.CalendarWidget.AppWidgetContainer">

    <TextView
        android:id="@+id/updated_time"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginRight="7dp"
        android:text="TextView"
        android:textAlignment="viewEnd" />

    <ListView
        android:id="@+id/container"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:divider="@null">

    </ListView>

    <TextView
        android:id="@+id/container_empty"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="予定がありません。"
        android:textColor="@color/TextColor"
        android:textSize="20sp" />

</LinearLayout>
```

</details>



## 5. 動的リストを制御する仕組みを作成する
今のところ動的リスト自体はレイアウトに作れましたが、そこに値を入れ込む仕組みがありません。
**まずは制御するクラス**を作成します。

フォルダ分けは自由にして良いですが、`MainActivity.kt`があるあたりに新たに`NewWidgetService.kt`というクラスを作成します。

このクラスに必要なのは下記の２つ。
- RemoteViewsServiceを継承したAppWidgetServiceクラス
- RemoteViewsService.RemoteViewsFactoryを継承したAppRemoteViewsFactoryクラス（クラス名は自由）

です。

### AppWidgetService
こいつは正直、とくに書くことはありません。
`AppWidgetProvider`を継承したクラスと、`RemoteViewsFactory`を継承したクラスの橋渡しをしています。

```kotlin
class MyWidgetService : RemoteViewsService() {
    override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
        return MyRemoteViewsFactory(this.applicationContext, intent)
    }
}
```

### AppRemoteViewsFactory
こちらに動的リストのメインとなる処理を記載します。

<details><summary>WidgetItemクラス</summary>

```kotlin
class WidgetItem(
    public val day:String
    ,public val title:String?
    ,public val timeStr:String
    ,public val location:String
){

    public val description:String
        get() {
            var str = timeStr
            if (this.location != "") {
                str += "  ${this.location}"
            }

            return str
        }
}
```

</details>


```kotlin
class AppRemoteViewsFactory(
    private val context: Context
    ,private val intent: Intent
) : RemoteViewsService.RemoteViewsFactory {

    // 動的リストで表示するアイテム。別途WidgetItemクラスを作っています。
    private var widgetItems: MutableList<WidgetItem> = mutableListOf()

    // Widgetの初期読み込み時処理
    override fun onCreate() {
        // searchCalendarは別クラスにて用意した関数
        widgetItems.addAll(searchCalendar(context))
    }

    override fun onDestroy() {
        widgetItems.clear()
    }

    // ここの値分だけ、下のgetViewAtが呼び出される。
    override fun getCount(): Int {
        return widgetItems.size
    }

    // 動的リスト生成のメイン処理。
    // 動的リストのうち、position個目のアイテムの表示を作成している
    override fun getViewAt(position: Int): RemoteViews {

        val widgetItem = widgetItems[position]

        // 子アイテムに利用するレイアウト用xmlは分岐が出来る
        return if (widgetItem.day != "") {
            RemoteViews(context.packageName, R.layout.widget_item_head).apply {
                setTextViewText(R.id.item_day, widgetItem.day)
                setTextViewText(R.id.item_title, widgetItem.title)
                setTextViewText(R.id.item_description, widgetItem.description)
            }
        } else {
            RemoteViews(context.packageName, R.layout.widget_item).apply {
                setTextViewText(R.id.item_title, widgetItem.title)
                setTextViewText(R.id.item_description, widgetItem.description)
            }
        }
    }

    // 使ってないからわからん
    override fun getLoadingView(): RemoteViews? {
        // You can create a custom loading view (for instance when getViewAt() is slow.) If you
        // return null here, you will get the default loading view.
        return null
    }

    // 利用するレイアウト用xmlの数を返させる。
    // widget_item_headとwidget_itemの2つなので、2としている。
    override fun getViewTypeCount(): Int {
        return 2
    }

    // 使ってないからわからん
    override fun getItemId(position: Int): Long {
        return position.toLong()
    }

    // 使ってないからわからん
    override fun hasStableIds(): Boolean {
        return true
    }

    // 一定時間ごとの再読込時処理
    override fun onDataSetChanged() {
        widgetItems.clear()
        widgetItems.addAll(searchCalendar(context))
    }
}
```

## 6. 動的リスト制御の仕組みを、ウィジェットのコントローラーに組み込む
5.で作ったクラスを利用する仕組みを組み込みます。
```kotlin
class NewAppWidget : AppWidgetProvider() {
    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray
    ) {
        for (widgetId in appWidgetIds) {
            // ListViewのデータ更新呼び出し
            // これを入れないと、再読込時にonDataSetChanged()が動かない
            appWidgetManager.notifyAppWidgetViewDataChanged(widgetId, R.id.container)

            // RemoteViewsServiceを呼び出すIntentを作成
            // これを使って、5.で作った動的リストを制御する仕組みとコントローラーを紐づける
            val intent = Intent(context, AppWidgetService::class.java).apply {
                putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
                // Intentをユニークにするための処理
                data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
            }

            // この中でウィジェットに表示する値の設定やら、動的リストの設定をする
            val views = RemoteViews(context.packageName, R.layout.new_app_widget).apply {

                setTextViewText(R.id.updated_time, "最終更新：${DateTimeFormatter.ofPattern("HH:mm").format(LocalDateTime.now())}")

                // ListViewとRemoteViewsServiceを接続
                setRemoteAdapter(R.id.container, intent)

                // リストが空の場合のビューを設定
                setEmptyView(R.id.container, R.id.container_empty)
            }

            appWidgetManager.updateAppWidget(widgetId, views)
        }

        super.onUpdate(context, appWidgetManager, appWidgetIds)
    }
}
```

## 7. AndroidManifestを修正する
最後に、`RemoteViewsService`を`AndroidManifest.xml`に登録しないといけません。
こんな感じで`receiver`の次辺りに追加します。
```xml
<receiver
    android:name=".NewAppWidget"
    android:exported="false">
    <intent-filter>
        <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
    </intent-filter>

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

<!-- RemoteViewsServiceを追加 -->
<service
    android:name="AppWidgetService"
    android:permission="android.permission.BIND_REMOTEVIEWS"
    android:exported="false"
/>
```

これでめでたく動く・・はず。

# おわりに
ついに自分でアプリを作れるようになってしまった。
そこはかとない無敵感があります。