使用 Glance 建構 UI

本頁面說明如何透過 Glance 處理大小及提供靈活的回應式版面配置。

使用 BoxColumnRow

Glance 提供三種主要的可組合項版面配置:

  • Box:將元素置於另一個頂端的位置。會轉譯為 RelativeLayout

  • Column:將元素放在垂直軸上並排的位置。轉換為垂直方向的 LinearLayout

  • Row:將元素置於水平軸的相鄰位置。轉換為水平方向的 LinearLayout

資料欄、列和方塊版面配置的圖片。
圖 1.含有 Column、Row 和 Box 的版面配置範例。

每個可組合項都可讓您使用修飾符定義內容的垂直和水平對齊方式,以及寬度、高度、粗細或邊框間距限制條件。此外,每個子項都可以定義其修飾符,藉此變更父項中的空間和位置。

以下範例說明如何建立 Row,以便平均分配其子項,如圖 1 所示:

Row(modifier = GlanceModifier.fillMaxWidth().padding(16.dp)) {
    val modifier = GlanceModifier.defaultWeight()
    Text("first", modifier)
    Text("second", modifier)
    Text("third", modifier)
}

Row 會填滿可用寬度上限,而每個子項的權重相同,因此會平均共用可用的空間。您可以定義不同的粗細、大小、邊框間距或對齊方式,按照需求調整版面配置。

使用可捲動的版面配置

另一種提供回應式內容的方式,就是讓內容可捲動。LazyColumn 可組合項就能辦到。這個可組合項可讓您定義要在應用程式小工具中可捲動容器內顯示的一組項目。

下列程式碼片段說明在 LazyColumn 內定義項目的不同方式。

你可以提供項目數量:

// Remember to import Glance Composables
// import androidx.glance.appwidget.layout.LazyColumn

LazyColumn {
    items(10) { index: Int ->
        Text(
            text = "Item $index",
            modifier = GlanceModifier.fillMaxWidth()
        )
    }
}

提供個別項目:

LazyColumn {
    item {
        Text("First Item")
    }
    item {
        Text("Second Item")
    }
}

提供項目清單或項目陣列:

LazyColumn {
    items(peopleNameList) { name ->
        Text(name)
    }
}

您也可以使用上述範例的組合:

LazyColumn {
    item {
        Text("Names:")
    }
    items(peopleNameList) { name ->
        Text(name)
    }

    // or in case you need the index:
    itemsIndexed(peopleNameList) { index, person ->
        Text("$person at index $index")
    }
}

請注意,先前的程式碼片段不會指定 itemId。指定 itemId 有助提升效能並維持捲動位置,從 Android 12 以上版本更新 appWidget (例如新增或移除清單中項目時)。以下範例說明如何指定 itemId

items(items = peopleList, key = { person -> person.id }) { person ->
    Text(person.name)
}

定義 SizeMode

AppWidget 的大小可能會因裝置、使用者選擇或啟動器而不同,因此請務必提供彈性的版面配置,如「提供彈性小工具版面配置」頁面中所述。Glance 利用 SizeMode 定義和 LocalSize 值來簡化這項操作。以下各節將說明這三種模式。

SizeMode.Single

預設模式為 SizeMode.Single。它表示只提供一種內容類型;也就是說,即使 AppWidget 的可用大小變更,內容大小也不會變更。

class MyAppWidget : GlanceAppWidget() {

    override val sizeMode = SizeMode.Single

    override suspend fun provideGlance(context: Context, id: GlanceId) {
        // ...

        provideContent {
            MyContent()
        }
    }

    @Composable
    private fun MyContent() {
        // Size will be the minimum size or resizable
        // size defined in the App Widget metadata
        val size = LocalSize.current
        // ...
    }
}

使用這個模式時,請確認下列事項:

  • 系統會根據內容大小,正確定義中繼資料值的最大和最小尺寸。
  • 內容在預期大小範圍內,具有彈性。

一般來說,建議您在下列情況下使用這個模式:

a) AppWidget 有固定的大小,或 b) 調整大小時不會變更內容。

SizeMode.Responsive

這個模式相當於提供回應式版面配置,可讓 GlanceAppWidget 定義一組受特定大小限制的回應式版面配置。建立或更新 AppWidget 時,系統會根據每個已定義的大小,建立內容並對應到特定大小。接著,系統會根據可用的大小選取最適配尺寸

例如,在目的地 AppWidget 中,您可以定義三種大小及其內容:

class MyAppWidget : GlanceAppWidget() {

    companion object {
        private val SMALL_SQUARE = DpSize(100.dp, 100.dp)
        private val HORIZONTAL_RECTANGLE = DpSize(250.dp, 100.dp)
        private val BIG_SQUARE = DpSize(250.dp, 250.dp)
    }

    override val sizeMode = SizeMode.Responsive(
        setOf(
            SMALL_SQUARE,
            HORIZONTAL_RECTANGLE,
            BIG_SQUARE
        )
    )

    override suspend fun provideGlance(context: Context, id: GlanceId) {
        // ...

        provideContent {
            MyContent()
        }
    }

    @Composable
    private fun MyContent() {
        // Size will be one of the sizes defined above.
        val size = LocalSize.current
        Column {
            if (size.height >= BIG_SQUARE.height) {
                Text(text = "Where to?", modifier = GlanceModifier.padding(12.dp))
            }
            Row(horizontalAlignment = Alignment.CenterHorizontally) {
                Button()
                Button()
                if (size.width >= HORIZONTAL_RECTANGLE.width) {
                    Button("School")
                }
            }
            if (size.height >= BIG_SQUARE.height) {
                Text(text = "provided by X")
            }
        }
    }
}

在上述範例中,系統會呼叫 provideContent 方法三次,並對應至定義的大小。

  • 在第一個呼叫中,大小會評估為 100x100。內容不含額外按鈕,以及頂端和底部文字。
  • 在第二次呼叫中,大小會評估為 250x100。內容包含額外按鈕,但不包括頂端和底部文字。
  • 在第三個呼叫中,大小會評估為 250x250。內容包含額外按鈕和兩段文字。

SizeMode.Responsive 結合了其他兩種模式,可讓您在預先定義的邊界內定義回應式內容。一般來說,此模式的效能較佳,並在 AppWidget 調整大小時順暢轉換。

下表列出大小的值,視 SizeModeAppWidget 的可用大小而定:

可用尺寸 105 x 110 203 x 112 72 x 72 203 x 150
SizeMode.Single 110 x 110 110 x 110 110 x 110 110 x 110
SizeMode.Exact 105 x 110 203 x 112 72 x 72 203 x 150
SizeMode.Responsive 80 x 100 80 x 100 80 x 100 150 x 120
* 確切值僅供參考。

SizeMode.Exact

SizeMode.Exact 相當於提供確切的版面配置,它會在每次可用的 AppWidget 大小變更時要求 GlanceAppWidget 內容 (例如,當使用者調整主畫面上的 AppWidget 時)。

舉例來說,在目的地小工具中,如果可用寬度大於特定值,則可以加入額外的按鈕。

class MyAppWidget : GlanceAppWidget() {

    override val sizeMode = SizeMode.Exact

    override suspend fun provideGlance(context: Context, id: GlanceId) {
        // ...

        provideContent {
            MyContent()
        }
    }

    @Composable
    private fun MyContent() {
        // Size will be the size of the AppWidget
        val size = LocalSize.current
        Column {
            Text(text = "Where to?", modifier = GlanceModifier.padding(12.dp))
            Row(horizontalAlignment = Alignment.CenterHorizontally) {
                Button()
                Button()
                if (size.width > 250.dp) {
                    Button("School")
                }
            }
        }
    }
}

這個模式可提供比其他模式更多的彈性,但有一些注意事項:

  • 每次大小變更時,都必須徹底重新建立 AppWidget。這可能導致效能問題,且 UI 會在內容複雜時跳轉。
  • 可用的大小可能會因啟動器實作方式而異。舉例來說,如果啟動器未提供尺寸清單,系統會採用最小的尺寸。
  • 在 Android 12 之前的版本中,大小計算邏輯不一定適用於所有情況。

一般來說,如果無法使用 SizeMode.Responsive (也就是無法使用一小組回應式版面配置),就應該使用此模式。

取得實用資源

使用 LocalContext.current 存取任何 Android 資源,如以下範例所示:

LocalContext.current.getString(R.string.glance_title)

建議您直接提供資源 ID 來縮減最終 RemoteViews 物件的大小,並啟用動態資源,例如動態顏色

可組合項和方法會使用「提供者」等「供應商」接受資源,例如:ImageProvider,或使用如 GlanceModifier.background(R.color.blue) 這類超載方法。例如:

Column(
    modifier = GlanceModifier.background(R.color.default_widget_background)
) { /**...*/ }

Image(
    provider = ImageProvider(R.drawable.ic_logo),
    contentDescription = "My image",
)

新增複合按鈕

複合按鈕 已在 Android 12 中導入。Glance 支援下列類型的複合按鈕:回溯相容:

這些複合按鈕每個都會顯示代表「已勾選」狀態的可點選檢視畫面。

var isApplesChecked by remember { mutableStateOf(false) }
var isEnabledSwitched by remember { mutableStateOf(false) }
var isRadioChecked by remember { mutableStateOf(0) }

CheckBox(
    checked = isApplesChecked,
    onCheckedChange = { isApplesChecked = !isApplesChecked },
    text = "Apples"
)

Switch(
    checked = isEnabledSwitched,
    onCheckedChange = { isEnabledSwitched = !isEnabledSwitched },
    text = "Enabled"
)

RadioButton(
    checked = isRadioChecked == 1,
    onClick = { isRadioChecked = 1 },
    text = "Checked"
)

當狀態變更時,系統會觸發提供的 lambda。您可以儲存檢查狀態,如以下範例所示:

class MyAppWidget : GlanceAppWidget() {

    override suspend fun provideGlance(context: Context, id: GlanceId) {
        val myRepository = MyRepository.getInstance()

        provideContent {
            val scope = rememberCoroutineScope()

            val saveApple: (Boolean) -> Unit =
                { scope.launch { myRepository.saveApple(it) } }
            MyContent(saveApple)
        }
    }

    @Composable
    private fun MyContent(saveApple: (Boolean) -> Unit) {

        var isAppleChecked by remember { mutableStateOf(false) }

        Button(
            text = "Save",
            onClick = { saveApple(isAppleChecked) }
        )
    }
}

您也可以將 colors 屬性提供給 CheckBoxSwitchRadioButton 來自訂顏色:

CheckBox(
    // ...
    colors = CheckboxDefaults.colors(
        checkedColor = ColorProvider(day = colorAccentDay, night = colorAccentNight),
        uncheckedColor = ColorProvider(day = Color.DarkGray, night = Color.LightGray)
    ),
    checked = isChecked,
    onCheckedChange = { isChecked = !isChecked }
)

Switch(
    // ...
    colors = SwitchDefaults.colors(
        checkedThumbColor = ColorProvider(day = Color.Red, night = Color.Cyan),
        uncheckedThumbColor = ColorProvider(day = Color.Green, night = Color.Magenta),
        checkedTrackColor = ColorProvider(day = Color.Blue, night = Color.Yellow),
        uncheckedTrackColor = ColorProvider(day = Color.Magenta, night = Color.Green)
    ),
    checked = isChecked,
    onCheckedChange = { isChecked = !isChecked },
    text = "Enabled"
)

RadioButton(
    // ...
    colors = RadioButtonDefaults.colors(
        checkedColor = ColorProvider(day = Color.Cyan, night = Color.Yellow),
        uncheckedColor = ColorProvider(day = Color.Red, night = Color.Blue)
    ),

)