建立一個 Android App Widget

Photo by Adrien Olichon on Unsplash
Photo by Adrien Olichon on Unsplash
Android App Widget 是 app 的一個擴充功能。使用者可以在 home screen 上擺放 app 提供的 app widgets。App 可以透過 app widgets 提供一個資訊或簡單的功能,而不需要使用者執行 app。

Android App Widget 是 app 的一個擴充功能。使用者可以在 home screen 上擺放 app 提供的 app widgets。App 可以透過 app widgets 提供一個資訊或簡單的功能,而不需要使用者執行 app。例如,時鐘 app 提供一個顯示當前時間的 app widget。那使用者在 home screen 就 app widget 就可以看到時間,而不需要開啟時鐘 app 來看時間。本文章將介紹如何開發一個簡單的 app widget。

完整程式碼可以在 下載。

建立一個 App Widget

在專案中建立一個 app widget 是很容易的事。如下圖,在專案中,選擇 New -> Widget -> App Widget。

New -> Widget -> App Widget
New -> Widget -> App Widget

接下來,輸入 app widget 的 class 名稱。在 Resizable 欄位中,我們可以選擇可 resize 的方向。在 Minimum WidgetMinimum Height 欄位中,輸入 app widget 最小的大小。

Creates a new App Widget.
Creates a new App Widget.

然後,Android Studio 會幫我們產生出所有需要的程式碼。當然,你也可以手動自己一個一個地新增這些程式碼。現在我們已經有一個可用的 app widget。你可以在 emulator 中安裝此 app,並且在 home screen 上,顯示 PersonInfoAppWidget。

App Widget project structure.
App Widget project structure.

接下來,我們要修改 PersonInfoAppWidget 成可以顯示個人資訊的 app widget。

將 person_info_app_widget.xml 修改成如下。從此 layout 可看出,PersonInfoAppWidget 會顯示個人的名字與工作。

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    style="@style/Widget.AppWidgetExample.AppWidget.Container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:theme="@style/Theme.AppWidgetExample.AppWidgetContainer">

    <LinearLayout
        style="@style/Widget.AppWidgetExample.AppWidget.InnerView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">

        <TextView
            android:id="@+id/name_text_view"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:textStyle="bold|italic" />

        <TextView
            android:id="@+id/job_text_view"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:textAlignment="textEnd"
            android:textStyle="bold|italic" />

    </LinearLayout>
</RelativeLayout>

將 PersonInfoAppWidget.kt 修改成如下。再執行一次此 app,我們可以看到 PersonInfoAppWidget 會顯示個人的名字與工作。

package com.waynestalk.appwidget

import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.widget.RemoteViews

val wayne = Person(
    name = "Wayne",
    job = "Software programmer",
    website = "https://waynestalk.com/",
    github = "https://github.com/xhhuango",
)

/**
 * Implementation of App Widget functionality.
 */
class PersonInfoAppWidget : AppWidgetProvider() {
    override fun onUpdate(
        context: Context,
        appWidgetManager: AppWidgetManager,
        appWidgetIds: IntArray,
    ) {
        // There may be multiple widgets active, so update all of them
        for (appWidgetId in appWidgetIds) {
            updateAppWidget(context, appWidgetManager, appWidgetId)
        }
    }

    override fun onEnabled(context: Context) {
        // Enter relevant functionality for when the first widget is created
    }

    override fun onDisabled(context: Context) {
        // Enter relevant functionality for when the last widget is disabled
    }
}

internal fun updateAppWidget(
    context: Context,
    appWidgetManager: AppWidgetManager,
    appWidgetId: Int,
) {
    // Construct the RemoteViews object
    val view = RemoteViews(context.packageName, R.layout.person_info_app_widget)
    view.setTextViewText(R.id.name_text_view, wayne.name)
    view.setTextViewText(R.id.job_text_view, wayne.job)

    // Instruct the widget manager to update the widget
    appWidgetManager.updateAppWidget(appWidgetId, view)
}

AppWidgetProvider

PersonInfoAppWidget 繼承 AppWidgetProvider,而 AppWidgetProvider 繼承 BroadcastReceiver。所以當 app widget 有任何更動時,AppWidgetProvider 都會接收到 broadcasts。

以下是常用的 AppWidgetProvider 的 methods:

  • onUpdate:當 app widget 被要求提供 RemoteViews 時。我們至少要實作此 method 來提供 app widget 的畫面。
  • onDeleted:當一個或多個 app widget 被刪除時,如從 home screen 上被刪除。
  • onEnabled:當一個 app widget 被新增時。
  • onDisabled:當最後一個 app widget 被刪除時。
  • onAppWidgetOptionsChanged:當一個 app widget 被 resize,或是它的 options 被改變時。

每一個 app widget 可以在 home screen 上有多的 instances。因此,每一個 instance 都有一個 appWidgetId。所以,AppWidgetProvider 的 methods 大多有 appWidgetId 參數,這樣 AppWidgetProvider 就可以知道哪一個 instance 被更動。

因為 AppWidgetProvider 是 BroadcastReceiver,所以我們必須要將它宣告在 AndroidManifest.xml 裡。如果你是用 Android Studio 來新增 app widget 的話,那 Android Studio 會自動幫我們在 AndriodManifest.xml 中宣告好。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.AppWidgetExample"
        tools:targetApi="31">
        <receiver
            android:name=".PersonInfoAppWidget"
            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/person_info_app_widget_info" />
        </receiver>
    </application>
</manifest>

RemoteViews

AppWidget 只能提供 RemoteViews。RemoteViews 的內容可以從一個 layout 來 inflated。不過,RemoteViews 只有支援某些 layouts。這裡列出它支援的所有 layouts。我們可以看出,RemoteViews 只有支援 android 的 layouts,而不支援 androidx 的 layouts。

當我們要變更 layout 中的 views 時,我們不能直接存取某個 view,而是必須要透過 RemoteViews 的方法。如在 PersonInfoAppWidget 中,我們要將名字設定給 TextView 時,我們不能直接存取該 TextView,而必須要透過 RemoteViews.setTextViewText()

val view = RemoteViews(context.packageName, R.layout.person_info_app_widget)
view.setTextViewText(R.id.name_text_view, wayne.name)
view.setTextViewText(R.id.job_text_view, wayne.job)

AppWidgetProviderInfo XML

在 AndroidManifest.xml 中宣告 AppWidgetProvider 時,我們要在 <receiver/> 裡用 <meta-data/> 宣告 AppWidgetProviderInfo

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <application >
        <receiver
            android:name=".PersonInfoAppWidget"
            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/person_info_app_widget_info" />
        </receiver>
    </application>
</manifest>

如果你是用 Android Studio 來新增 app widget 時,它會自動幫我們產生一個 AppWidgetProviderInfo。在此專案中,Android Studio 產生的是 person_info_widget_info.xml。

<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
    android:description="@string/app_widget_description"
    android:initialKeyguardLayout="@layout/person_info_app_widget"
    android:initialLayout="@layout/person_info_app_widget"
    android:minWidth="110dp"
    android:minHeight="40dp"
    android:previewImage="@drawable/example_appwidget_preview"
    android:previewLayout="@layout/person_info_app_widget"
    android:resizeMode="horizontal|vertical"
    android:targetCellWidth="2"
    android:targetCellHeight="1"
    android:updatePeriodMillis="86400000"
    android:widgetCategory="home_screen" />

以下我們大概說明一下 <appwidget-provider/> 的各個屬性,詳情請參考官網

AttributesDescription
targetCellWidth
targetCellHeight
minWidth
minHeight
Android 12 開始,targetCellWidth 和 targetCellHeight 指定 app widget 的預設大小。如果 home screen 不支援 grid-based layout 的話,就會改用 minWidth 和 minHeight。
Android 11 與 lower,minWidth 和 minHeight 指定 app widget 的預設大小。當 minWidth 和 minHeight 無法對應 cells 的大小,其值會被 rounded up 到最近的 cell 大小。
minResizeWidth
minResizeHeight
指定 app widget 絕對的最小大小。
maxResizeWidth
maxResizeHeight
指定 app widget 推薦的最大大小。
resizeMode指定 app widget 可 resize 的方向。
horizontalverticalnonehorizontal|vertical
initialLayoutapp widget 的 layout resource。
description在 widget picker 中顯示此 app widget 的說明。
previewImage
previewLayout
Android 12 開始,previewLayout 指定 scalable 預覽。
Android 11 與 lower,previewImage 指定預覽圖。
Widget picker 會顯示此預覽。
updatePeriodMillis指定多久 widget framework 會呼叫 AppWidgetProvider.onUpdate() 來更新 app widget 的 view。
widgetCategory指定此 app widget 可顯示在 home scree(home_screen)或 lock screen(keyguard)。
The attributes of <appwidget-provider/>.

提供 Responsive Layout

當 app widget 被改變大小時,我們可以根據不同的大小,提供不同的 layout。

當 PersonInfoAppWidget 被拉大時,我們將提供以下的 layout。以下的 layout 除了顯示個人的名字與工作之外,還會顯示 Website 和 GitHub。

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    style="@style/Widget.AppWidgetExample.AppWidget.Container"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:theme="@style/Theme.AppWidgetExample.AppWidgetContainer">

    <LinearLayout
        style="@style/Widget.AppWidgetExample.AppWidget.InnerView"
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:orientation="vertical">

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

            <TextView
                android:id="@+id/name_text_view"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:textStyle="bold|italic" />

            <TextView
                android:id="@+id/job_text_view"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:textAlignment="textEnd"
                android:textStyle="bold|italic" />

        </LinearLayout>

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

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="Website:"
                android:textStyle="bold|italic" />

            <TextView
                android:id="@+id/website_text_view"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:textAlignment="textEnd"
                android:textStyle="bold|italic" />

        </LinearLayout>

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

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="GitHub:"
                android:textStyle="bold|italic" />

            <TextView
                android:id="@+id/github_text_view"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_weight="1"
                android:textAlignment="textEnd"
                android:textStyle="bold|italic" />

        </LinearLayout>

    </LinearLayout>

</RelativeLayout>

我們可以用將 view 與其相對應的大小放到一個 Map 裡,再將 map 設定給 AppWidgetManager.updateAppWidget()。當 PersonInfoAppWidget 被顯示時,會依據它的大小,選擇最接近的 layout 來顯示。

internal fun updateAppWidget(
    context: Context,
    appWidgetManager: AppWidgetManager,
    appWidgetId: Int,
) {
    // Construct the RemoteViews object
    val simpleView = RemoteViews(context.packageName, R.layout.person_info_app_widget)
    simpleView.setTextViewText(R.id.name_text_view, wayne.name)
    simpleView.setTextViewText(R.id.job_text_view, wayne.job)

    val detailView = RemoteViews(context.packageName, R.layout.person_info_detail_app_widget)
    detailView.setTextViewText(R.id.name_text_view, wayne.name)
    detailView.setTextViewText(R.id.job_text_view, wayne.job)
    detailView.setTextViewText(R.id.website_text_view, wayne.website)
    detailView.setTextViewText(R.id.github_text_view, wayne.github)

    val viewMapping = mapOf(
        SizeF(60f,  100f) to simpleView,
        SizeF(100f, 200f) to detailView,
    )

    val views = RemoteViews(viewMapping)

    // Instruct the widget manager to update the widget
    appWidgetManager.updateAppWidget(appWidgetId, views)
}

結語

在專案中建立一個 app widget 時,需要不少的步驟。但是,如果使用 Android Studio 來建立一個 app widget 的話,那是非常輕鬆的事了。它會自動幫我們產生所有必要的程式碼。我們可以基於這些程式碼再去修改,這讓我們省去很多時間。

參考

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

You May Also Like
Photo by Hans-Jurgen Mager on Unsplash
Read More

Kotlin Coroutine 教學

Kotlin 的 coroutine 是用來取代 thread。它不會阻塞 thread,而且還可以被取消。Coroutine core 會幫你管理 thread 的數量,讓你不需要自行管理,這也可以避免不小心建立過多的 thread。
Read More