Jetpack Compose 基本概念

1. 事前準備

Jetpack Compose 是為了簡化 UI 開發程序而設計的新型工具包,採用回應式的程式設計模型,並具備 Kotlin 程式設計語言的簡潔和便利等特點。這個工具包全面支援宣告式機制,您可以呼叫一系列的函式,將資料轉換為 UI 階層,藉此描述您的 UI 設計。當基礎資料變動時,該架構會自動重新執行這些函式,並為您更新 UI 階層。

Compose 應用程式是由可組合函式所構成,只是標記 @Composable 的一般函式,可以用來呼叫其他可組合函式。您只需要使用一個函式就能建立新的 UI 元件。Compose 則會根據註解,為函式提供特別支援,以便持續更新及維護 UI。您可以利用 Compose 把程式碼分成幾個小區塊。可組合函式通常會簡稱為「可組合項」。

藉由重複利用小型的可組合項,您可以輕鬆建立應用程式要使用的 UI 元素程式庫。每一個項目都會負責螢幕的一部分,您也能各自編輯這些部分。

如果您在練習本程式碼研究室的過程中,需要進一步的協助,請觀看下面的「一起寫程式」示範影片:

注意:示範影片中使用的是 Material 2,而本程式碼研究室則所採用的版本已更新為 Material 3。請注意,某些步驟會因情況而異。

必要條件

  • 熟悉 Kotlin 語法,包括 lambda

執行步驟

在本程式碼研究室,您將學到:

  • 什麼是 Compose
  • 如何使用 Compose 建構使用者介面
  • 如何管理可組合函式中的狀態
  • 如何建立成效優良的清單
  • 如何新增動畫
  • 如何設定應用程式的樣式和主題

您將會建立一個應用程式,其中有新手上路畫面,還有一個以動畫效果展開項目的清單:

8d24a786bfe1a8f2.gif

軟硬體需求

2. 開啟新的 Compose 專案

如要建立新的 Compose 專案,請開啟 Android Studio。

如果您位於「Welcome to Android Studio」視窗,請按一下「Start a new Android Studio project」。如果您已開啟 Android Studio 專案,請從選單列中依序選取「File」>「New」>「New Project」

若是建立新專案,請從系統提供的範本中選取「Empty Activity」

d12472c6323de500.png

按一下「Next」,然後像往常一樣設定專案,並命名為「Basics Codelab」。確定選取的「minimumSdkVersion」至少是 API 級別 21,這是 Compose 支援的最低 API 版本。

選取「Empty Compose Activity」範本時,專案中會產生以下程式碼:

  • 專案已設為使用 Compose。
  • 建立 AndroidManifest.xml 檔案。
  • build.gradle.ktsapp/build.gradle.kts 檔案,內含 Compose 所需的選項和依附元件。

同步處理專案後,請開啟 MainActivity.kt 並檢查程式碼內容。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    Greeting("Android")
                }
            }
        }
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello $name!",
        modifier = modifier
    )
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greeting("Android")
    }
}

下一節將介紹每個方法的功能,以及您應如何改善這些方法,以便建立容易調整且可重複使用的版面配置。

本程式碼研究室的解決方案

您可以到 GitHub 取得本程式碼研究室的解決方案程式碼:

$ git clone https://github.com/android/codelab-android-compose

或者,您也可以將存放區下載為 ZIP 檔案:

您可以在 BasicsCodelab 專案內找到解決方案程式碼。建議您按照自己能夠接受的速度一步步按照程式碼研究室的說明操作,有必要的時候才參考解決方案。在本程式碼研究室的學習過程中,我們會為您提供要新增到專案的程式碼片段。

3. 開始使用 Compose

查看 Android Studio 為您產生,和 Compose 有關的各種類別和方法。

可組合函式

可組合函式是用 @Composable 註解的一般函式。您的函式可以藉此在內部呼叫其他 @Composable 函式。您可以看到 Greeting 函式標示為 @Composable。這個函式將產生 UI 階層,可顯示指定的輸入內容 StringText 是由程式庫提供的可組合函式。

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello $name!",
        modifier = modifier
    )
}

Android 應用程式內的 Compose

藉由 Compose,Android 應用程式可以繼續使用 Activity 當做進入點。在我們的專案裡,當使用者開啟應用程式時,會啟動 MainActivity (正如 AndroidManifest.xml 檔案中所述)。您可以使用 setContent 定義版面配置,但您不用和傳統檢視系統一樣使用 XML 檔案,而是在其中呼叫可組合函式。

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                // A surface container using the 'background' color from the theme
                Surface(
                  modifier = Modifier.fillMaxSize(),
                  color = MaterialTheme.colorScheme.background
                ) {
                    Greeting("Android")
                }
            }
        }
    }
}

您可以用 BasicsCodelabTheme 為可組合函式設定樣式。「設定應用程式主題」章節中有更詳細的說明。如想查看文字在畫面上的顯示效果,您可以在模擬器或裝置上執行應用程式,或使用 Android Studio 的預覽功能。

如果想使用 Android Studio 的預覽功能,您只需要將所有不含參數的可組合函式或函式標上預設參數,並加上 @Preview 備註來建構專案即可。在 MainActivity.kt 檔案中已可看到 Preview Composable 函式。您可以在同一個檔案裡進行多次預覽,並為這些預覽取名。

@Preview(showBackground = true, name = "Text preview")
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greeting(name = "Android")
    }
}

fb011e374b98ccff.png

如果選取「Codeeeacd000622ba9b.png,則可能不會出現預覽畫面。按一下「Split7093def1e32785b2.png 即可預覽。

4. 調整 UI

先為 Greeting 設定不同的背景顏色。您可以在 Text 可組合項前後加上 SurfaceSurface 會著色,所以請用 MaterialTheme.colorScheme.primary

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Text(
            text = "Hello $name!",
            modifier = modifier
        )
    }
}

以巢狀結構形式置於 Surface 內部的元件會在該背景顏色上繪製。

您可以在預覽畫面看到新的變更內容:

c88121ec49bde8c7.png

您可能錯過了一個重要細節:文字現在是白色的。我們什麼時候定義這個顏色了?

答案是從來沒有!androidx.compose.material3.Surface 這類質感元件的設計本身就是藉由採用您應該會想要的應用程式常見功能,藉此讓您有更好的體驗,例如為文字選擇合適的顏色。我們會說 Material Design「很有主見」,因為它能提供多數應用程式常見的完善預設值和模式。而 Compose 的 Material Design 元件是利用其他基礎元件建構而成 (位於 androidx.compose.foundation),如果需要更多彈性,您也可以透過應用程式元件存取這些內容。

在這個情況下,Surface 瞭解如果背景設定為 primary 顏色,那上面的文字就應該使用 onPrimary 顏色,主題裡也有這個設定。「設定應用程式主題」章節中有更詳細的說明。

修飾符

大部分的 Compose UI 元素,例如 SurfaceText,都會接受選用的 modifier 參數。修飾符可以指示 UI 元素如何在其上層布局內部安排版面配置、顯示或行為。您可能已經發現,Greeting 可組合函式已有預設的修飾符,這個修飾符會傳遞至 Text

舉例來說,padding 修飾符可以在裝飾的元素周圍套用一定數量的空間。您可以用 Modifier.padding() 建立邊框間距修飾符,也可以透過鏈結新增多個修飾符,因此以本例來說,我們可在預設修飾符中加入邊框間距修飾符:modifier.padding(24.dp)

現在,為畫面上的 Text 加上邊框間距:

import androidx.compose.foundation.layout.padding
import androidx.compose.ui.unit.dp
// ...

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Text(
            text = "Hello $name!",
            modifier = modifier.padding(24.dp)
        )
    }
}

ef14f7c54ae7edf.png

您可以用數十種修飾符對齊、製作動畫效果、展示、製作可以點擊或捲動的項目、變形等等。完整修飾符清單請參閱 Compose 修飾符清單。我們將在後續步驟用到部分修飾符。

5. 重複利用可組合項

隨著在 UI 裡加入越多元件,建立的巢狀結構層級也會越來越多。一旦某個函式變得太大,可能也會讓程式碼變得難以解讀。藉由重複利用小型的元件,您可以輕鬆建立應用程式要使用的 UI 元素程式庫。每一個項目都會負責螢幕的一小部分,您也能各自編輯這些部分。

最佳做法是在函式中加入修飾符參數,該參數預設為指派空白的修飾符。將此修飾符轉寄至您會在函式中呼叫的第一個可組合函式。如此一來,呼叫網站即可從可組合函式外部調整版面配置指示和行為。

建立名為 MyApp 的可組合函式,其中含有招呼語。

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    Surface(
        modifier = modifier,
        color = MaterialTheme.colorScheme.background
    ) {
        Greeting("Android")
    }
}

這樣做之後,您就能清理 onCreate 回呼和預覽,因為現在可以重新利用 MyApp 可組合函式,不用重複使用程式碼了。

在預覽畫面中,請呼叫 MyApp 並移除預覽畫面名稱。

您的 MainActivity.kt 檔案看起來會像這樣:

package com.example.basicscodelab

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.basicscodelab.ui.theme.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp(modifier = Modifier.fillMaxSize())
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    Surface(
        modifier = modifier,
        color = MaterialTheme.colorScheme.background
    ) {
        Greeting("Android")
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Text(
            text = "Hello $name!",
            modifier = modifier.padding(24.dp)
        )
    }
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        MyApp()
    }
}

6. 建立欄和列

Compose 中的三個基本標準版面配置元素為 ColumnRowBox

518dbfad23ee1b05.png

這些是可組合函式,可以接受可組合函式內容,您可在函式中加入需要的項目。舉例來說,Column 內的每個子項都會垂直擺放。

// Don't copy over
Column {
    Text("First row")
    Text("Second row")
}

現在請變更 Greeting,像範例一樣顯示有兩個文字元素的欄:

bf27ee688c3231df.png

請注意,您可能需要移動邊框間距。

請把您的成果和解決方案互相比較:

import androidx.compose.foundation.layout.Column
// ...

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(color = MaterialTheme.colorScheme.primary) {
        Column(modifier = modifier.padding(24.dp)) {
            Text(text = "Hello ")
            Text(text = name)
        }
    }
}

Compose 和 Kotlin

可組合函式的使用方式和 Kotlin 的其他函式相同。新增陳述式可以改變 UI 的顯示方式,用高效的方式建構 UI。

舉例來說,您可以使用 for 迴圈,為 Column 新增元素:

@Composable
fun MyApp(
    modifier: Modifier = Modifier,
    names: List<String> = listOf("World", "Compose")
) {
    Column(modifier) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

a7ba2a8cb7a7d79d.png

您尚未幫可組合函式設定大小或新增任何尺寸限制,因此每一列都會盡可能採用最小的空間,預覽畫面也是如此。我們可以變更預覽畫面,模擬小型手機的常見寬度 (320dp)。為 @Preview 註解新增 widthDp 參數,如下所示:

@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        MyApp()
    }
}

a5d5f6cdbdd918a2.png

Compose 很常使用修飾符,不妨來練習更進階的內容:用 fillMaxWidthpadding 修飾符再現以下這個版面配置。

a9599061cf49a214.png

現在請比較您的程式碼和解決方案:

import androidx.compose.foundation.layout.fillMaxWidth

@Composable
fun MyApp(
    modifier: Modifier = Modifier,
    names: List<String> = listOf("World", "Compose")
) {
    Column(modifier = modifier.padding(vertical = 4.dp)) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Column(modifier = Modifier.fillMaxWidth().padding(24.dp)) {
            Text(text = "Hello ")
            Text(text = name)
        }
    }
}

請注意:

  • 修飾符可以有超載,例如您可以指定幾種不同的邊框間距建立方式。
  • 如果想為元素新增多個修飾符,只要建立修飾符鏈結即可。

可以達到這個目標的方法有很多種,如果您的程式碼和這段程式碼片段不同,不代表您的程式碼有錯誤。不過,請直接複製貼上這段程式碼,以便繼續進行本程式碼研究室。

新增按鈕

接下來,您將新增可以點擊的元素,展開 Greeting,所以我們需要先加入這個按鈕。這裡的目標是建立以下的版面配置:

ff2d8c3c1349a891.png

Button 是 material3 套件提供的可組合函式,這個套件把可組合函式當做最後一項引數。既然結尾的 lambda 可以移到括弧外面,您就能為這個按鈕加入任何內容當做子項。例如 Text

// Don't copy yet
Button(
    onClick = { } // You'll learn about this callback later
) {
    Text("Show less")
}

因此,您需要瞭解如何在列末放置可組合項。由於沒有 alignEnd 修飾符,您應該在開頭為可組合項提供 weightweight 修飾符會讓元素填滿可用空間,使這些元素變得「有彈性」,有效推移其他沒有權重的元素,即「無彈性」的元素。此做法也會讓 fillMaxWidth 修飾符顯得多餘。

您現在可以試著加入按鈕,並將其放置在上圖所示的位置。

請看解決方案:

import androidx.compose.foundation.layout.Row
import androidx.compose.material3.ElevatedButton
// ...

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = "Hello ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { /* TODO */ }
            ) {
                Text("Show more")
            }
        }
    }
}

7. Compose 中的狀態

在本節中,您將為畫面增加互動內容。到目前為止,您已經建立了一個靜態的版面配置,不過現在您需要讓版面配置能夠回應使用者變更內容,以便達到這個效果:

6675d41779cac69.gif

在瞭解如何讓按鈕可供點擊及如何調整項目大小之前,您需要在系統裡儲存值,用來指出各項目是否已展開,也就是項目的「狀態」。因為每個招呼語都需要用到其中一個值,因此放置這些值最合理的地方就是 Greeting 可組合項了。請看這項 expanded 布林值以及程式碼裡的使用方式:

// Don't copy over
@Composable
fun Greeting(name: String) {
    var expanded = false // Don't do this!

    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }
        }
    }
}

您可以注意到,我們也加入了 onClick 操作,還有一個動態的按鈕文字。稍後會再詳細討論。

但是,這個項目無法順利運作。設定不同的 expanded 變數值不會讓 Compose 偵測到「狀態變更」,因此不會發生任何變化。

變更這個變數並不會觸發重新組成的原因是 Compose 並未追蹤這個變數。另外,每次呼叫 Greeting 的時候,這個變數都會重設為 false。

如果想在可組合函式裡加入內部狀態,您可以使用 mutableStateOf 函式,讓 Compose 重組讀取該 State 的函式。

import androidx.compose.runtime.mutableStateOf
// ...

// Don't copy over
@Composable
fun Greeting() {
    val expanded = mutableStateOf(false) // Don't do this!
}

不過,僅將 mutableStateOf 指派給可組合函式裡的變數是不夠的。如上文所說,重組隨時都有可能發生,且會再度呼叫可組合項,利用 false 的值將狀態重設成新的可變動狀態。

如果想在重組後保留狀態,請使用 remember「記住」可變動狀態。

import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
// ...

@Composable
fun Greeting(...) {
    val expanded = remember { mutableStateOf(false) }
    // ...
}

您可以使用 remember「保護」項目不受重組影響,讓系統不會重設狀態。

請注意,如果您從螢幕的其他地方呼叫同一個可組合項,將會建立不同的 UI 元素,每個元素都有自己的狀態版本。您可以把內部狀態當成類別裡的私人變數。

可組合函式會自動「訂閱」這個狀態。當狀態變更時,可組合項會讀取這些重新組成的欄位,以便顯示更新內容。

變動狀態和回應狀態變更

為了變更狀態,您可能會發現 Button 裡有個名為 onClick 的參數,但是這個參數卻不接受值,而是接受函式

您可以為操作指派 lambda 運算式,藉此定義「點選」時要執行的操作。舉例來說,我們可以切換展開狀態的值,並根據值顯示不同的文字。

ElevatedButton(
    onClick = { expanded.value = !expanded.value },
) {
   Text(if (expanded.value) "Show less" else "Show more")
}

在互動模式中執行應用程式,即可查看行為。

374998ad358bf8d6.png

當按鈕按下時,系統會切換 expanded,進而觸發按鈕裡的文字進行重組。每個 Greeting 分屬不同的 UI 元素,因此都能維持本身的展開狀態。

93d839b53b7d9bea.gif

到目前為止的程式碼長這樣:

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    val expanded = remember { mutableStateOf(false) }
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = "Hello ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded.value = !expanded.value }
            ) {
                Text(if (expanded.value) "Show less" else "Show more")
            }
        }
    }
}

展開項目

現在,讓我們在收到要求時實際展開項目。新增一個可以根據狀態的變數:

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {

    val expanded = remember { mutableStateOf(false) }

    val extraPadding = if (expanded.value) 48.dp else 0.dp
// ...

您不需要為了重組而記下 extraPadding,因為這個項目只是在執行簡單計算。

現在,我們可以將新的邊框間距修飾符套用至資料欄:

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    val expanded = remember { mutableStateOf(false) }
    val extraPadding = if (expanded.value) 48.dp else 0.dp
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(
                modifier = Modifier
                    .weight(1f)
                    .padding(bottom = extraPadding)
            ) {
                Text(text = "Hello ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded.value = !expanded.value }
            ) {
                Text(if (expanded.value) "Show less" else "Show more")
            }
        }
    }
}

如果您是用模擬器執行應用程式,或是使用互動模式,應該可以看到每個項目都可以獨立展開了:

6675d41779cac69.gif

8. 狀態提升

在可組合函式裡,如果有狀態會由多個函式讀取或修改,就應該放在共通的祖系裡面,這個過程稱為「狀態提升」。「提昇」的意思就是「抬高」或「提高」。

可提升的狀態有助避免重複使用狀態和出現錯誤,方便您重複利用可組合函式,並讓可組合函式更容易用於測試。相反地,如果狀態不需要由可組合項的父系控管,您就不該提昇這類狀態。可靠資料來源屬於建立和控管該狀態的項目。

舉個例子,我們可以在應用程式裡建立一個新手上路畫面。

5d5f44508fcfa779.png

MainActivity.kt 加入以下程式碼:

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.Button
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
// ...

@Composable
fun OnboardingScreen(modifier: Modifier = Modifier) {
    // TODO: This state should be hoisted
    var shouldShowOnboarding by remember { mutableStateOf(true) }

    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier.padding(vertical = 24.dp),
            onClick = { shouldShowOnboarding = false }
        ) {
            Text("Continue")
        }
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen()
    }
}

這個程式碼裡面有很多新的功能:

  • 您已經新增了名為 OnboardingScreen 的可組合項以及新的預覽畫面。如果您建構專案,就會發現同時會出現多個預覽畫面。我們也加入了固定的高度,以便檢查內容是否可以正確對齊。
  • 您可以設定 Column 在螢幕中央顯示內容。
  • shouldShowOnboarding 使用的是 by 關鍵字,而不是 =。這是屬性委派,讓您可以不用每次都輸入 .value
  • 當使用者點選按鈕時,shouldShowOnboarding 會設為 false,但是您還沒有從任何來源讀取狀態。

我們現在可以在應用程式裡加入新手上路畫面了。目標是在啟動應用程式時顯示此畫面,並在使用者按下「Continue」之後隱藏畫面。

在 Compose 當中,您不用隱藏 UI 元素,只須注意不要將這些元素加到組成裡即可,這樣 Compose 產生的 UI 樹狀結構中就不會加入這些元素了。這個效果只要使用簡單的條件式 Kotlin 邏輯就能達到,舉例來說,如果想顯示新手上路畫面,或招呼語清單,您可以這樣做:

// Don't copy yet
@Composable
fun MyApp(modifier: Modifier = Modifier) {
    Surface(modifier) {
        if (shouldShowOnboarding) { // Where does this come from?
            OnboardingScreen()
        } else {
            Greetings()
        }
    }
}

但是,我們無權存取 shouldShowOnboarding。顯然我們需要和 MyApp 可組合項分享我們在 OnboardingScreen 建立的狀態。

我們不用想辦法把狀態的值分享給父項,而是只要「提昇」狀態就好,把值移到需要存取值的共通祖系裡面。

首先,請將 MyApp 的內容移到名為 Greetings 的新可組合項裡。您也可以改為透過調整預覽呼叫 Greetings 方法:

@Composable
fun MyApp(modifier: Modifier = Modifier) {
     Greetings()
}

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = listOf("World", "Compose")
) {
    Column(modifier = modifier.padding(vertical = 4.dp)) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingsPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

為新的頂層 MyApp 可組合項新增預覽畫面,以便測試其行為:

@Preview
@Composable
fun MyAppPreview() {
    BasicsCodelabTheme {
        MyApp(Modifier.fillMaxSize())
    }
}

然後新增邏輯,在 MyApp 顯示不同畫面,並且提升這個狀態。

@Composable
fun MyApp(modifier: Modifier = Modifier) {

    var shouldShowOnboarding by remember { mutableStateOf(true) }

    Surface(modifier) {
        if (shouldShowOnboarding) {
            OnboardingScreen(/* TODO */)
        } else {
            Greetings()
        }
    }
}

我們還需要和新手上路畫面分享 shouldShowOnboarding,但是不必直接進行傳遞。與其讓 OnboardingScreen 變更狀態,不如讓這個項目通知我們使用者已按下「Continue」按鈕,這樣的處理方式會比較好。

如何向上傳遞事件?答案是向下傳遞回呼。回呼是傳遞給其他函式當做引數的函式,在事件發生時,系統就會執行這個函式。

請嘗試為新手上路畫面新增函式參數,定義為 onContinueClicked: () -> Unit,以便變更來自 MyApp 的狀態。

解決方法:

@Composable
fun MyApp(modifier: Modifier = Modifier) {

    var shouldShowOnboarding by remember { mutableStateOf(true) }

    Surface(modifier) {
        if (shouldShowOnboarding) {
            OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
        } else {
            Greetings()
        }
    }
}

@Composable
fun OnboardingScreen(
    onContinueClicked: () -> Unit,
    modifier: Modifier = Modifier
) {

    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier
                .padding(vertical = 24.dp),
            onClick = onContinueClicked
        ) {
            Text("Continue")
        }
    }

}

藉由傳遞函式 (而不是狀態) 給 OnboardingScreen,這個可組合項會變得更容易重複利用,我們也能確保狀態不會因其他可組合項而變動。總的來說,這是個化繁為簡的方法。您可以看看現在如何修改新手上路預覽畫面以便呼叫 OnboardingScreen

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {}) // Do nothing on click.
    }
}

onContinueClicked 指派給空白的 lambda 運算式表示「什麼都不做」,十分適合預覽畫面使用。

這個應用程式看起來越來越像一回事了,做得好!

25915eb273a7ef49.gif

MyApp 可組合函式中,我們首次使用了 by 屬性委派功能,避免每次都使用值。請同樣在 expanded 屬性的 Greeting 可組合函式中,使用 by 替代 =,確保將 expandedval 改為 var

目前為止的完整程式碼:

package com.example.basicscodelab

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.codelab.basics.ui.theme.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp(modifier = Modifier.fillMaxSize())
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {

    var shouldShowOnboarding by remember { mutableStateOf(true) }

    Surface(modifier) {
        if (shouldShowOnboarding) {
            OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
        } else {
            Greetings()
        }
    }
}

@Composable
fun OnboardingScreen(
    onContinueClicked: () -> Unit,
    modifier: Modifier = Modifier
) {

    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier.padding(vertical = 24.dp),
            onClick = onContinueClicked
        ) {
            Text("Continue")
        }
    }
}

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = listOf("World", "Compose")
) {
    Column(modifier = modifier.padding(vertical = 4.dp)) {
        for (name in names) {
            Greeting(name = name)
        }
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {})
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {

    var expanded by remember { mutableStateOf(false) }

    val extraPadding = if (expanded) 48.dp else 0.dp

    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(
                modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding)
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }
        }
    }
}

@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

@Preview
@Composable
fun MyAppPreview() {
    BasicsCodelabTheme {
        MyApp(Modifier.fillMaxSize())
    }
}

9. 建立成效優良的 Lazy 清單

現在,讓我們把名稱清單變得更像真的吧!您目前已經在 Column 裡顯示了兩組招呼語,但是這個項目可以處理數千組招呼語嗎?

請變更 Greetings 參數內的預設清單值,使其使用其他清單建構函式,以便設定清單大小,並用其 lambda 內的值填滿清單 (在這裡,$it 代表清單索引):

names: List<String> = List(1000) { "$it" }

這樣做將會建立 1000 種招呼語,包括螢幕無法完整顯示的招呼語。這樣顯然算不上成效優良。您可以嘗試用模擬器執行看看 (警告:這段程式碼可能會導致模擬器停止運作)。

我們會使用 LazyColumn 顯示捲動式的欄。LazyColumn 只會轉譯螢幕上看得到的項目,當轉譯龐大清單的時候能夠提昇效能。

以基本用法來說,LazyColumn API 可以在自身範圍內提供一個 items 元素,其中會寫入個別項目的轉譯邏輯:

import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
// ...

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = List(1000) { "$it" }
) {
    LazyColumn(modifier = modifier.padding(vertical = 4.dp)) {
        items(items = names) { name ->
            Greeting(name = name)
        }
    }
}

284f925eb984fb56.gif

10. 保留狀態

我們的應用程式有兩個問題:

保留新手上路畫面狀態

在裝置上執行應用程式時,如果按下按鈕並旋轉畫面,系統會再度顯示新手上路畫面。這是因為只有在可組合函式保留在組成裡的時候remember 函式才能正常運作。每次旋轉時,整個活動都會重新啟動,也因此喪失所有狀態。一旦變更設定,或是終止程序,也會發生同樣的情形。

您可以不使用 remember,而是 rememberSaveable。這樣即可在變更設定 (如旋轉) 和終止程序之後儲存每個狀態。

現在,請將 shouldShowOnboarding 中的 remember 取代為 rememberSaveable

    import androidx.compose.runtime.saveable.rememberSaveable
    // ...

    var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

您可以執行、旋轉畫面、變更為深色模式,或是終止程序。除非您之前曾經結束應用程式,否則系統不會顯示新手上路畫面。

保留清單項目的展開狀態

如果您展開清單項目,然後捲動清單直到項目移出檢視畫面,或旋轉裝置再返回展開的項目,會看到該項目現已恢復初始狀態。

此問題的解決方法,是同樣將 rememberSaveable 用於展開狀態:

   var expanded by rememberSaveable { mutableStateOf(false) }

我們目前已經用了約 120 行程式碼顯示一份長度可觀且效能優良的捲動式清單,清單內每個項目都有自己的狀態。同時如您所見,應用程式不靠額外的程式碼就能正確使用深色模式。我們之後會為您講解主題設定。

11. 建立清單動畫

Compose 裡有很多方式可以為 UI 製作動畫效果:您可以用高階的 API 製作簡單的動畫,也可以透過門檻較低的方法製作可以全面控制的複雜轉場效果。相關資訊請參閱說明文件

在本章節中,您將會使用低層級的 API,但是內容十分簡單,請勿擔心。讓我們為已經實作的大小變化製作動畫效果:

9efa14ce118d3835.gif

您將會使用 animateDpAsState 可組合函式達成此目的。這個可組合項會回傳狀態物件,物件的 value 會持續由動畫更新,直到動畫結束為止。該可組合函式會使用「指定值」,類型為 Dp

建立可以根據展開狀態顯示動畫效果的 extraPadding

import androidx.compose.animation.core.animateDpAsState

@Composable
private fun Greeting(name: String, modifier: Modifier = Modifier) {

    var expanded by rememberSaveable { mutableStateOf(false) }

    val extraPadding by animateDpAsState(
        if (expanded) 48.dp else 0.dp
    )
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding)
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }

        }
    }
}

執行應用程式,看看動畫效果如何。

animateDpAsState 使用非必要的 animationSpec 參數,您可以用這個參數自訂動畫內容。我們可以加上一個彈跳動畫,讓效果更有趣:

import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring

@Composable
private fun Greeting(name: String, modifier: Modifier = Modifier) {

    var expanded by rememberSaveable { mutableStateOf(false) }

    val extraPadding by animateDpAsState(
        if (expanded) 48.dp else 0.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        )
    )

    Surface(
    // ...
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding.coerceAtLeast(0.dp))

    // ...

    )
}

請注意,我們也要確保邊框間距永遠不會變成負值,否則很可能會讓應用程式當機。這個內容也產生了一個不太明顯的動畫錯誤,我們之後會在「最後修飾」一節修復此錯誤。

spring 規格不使用任何和時間有關的參數,而是仰賴實際的屬性 (阻尼和硬度),讓動畫更生動自然。請執行應用程式並看看新的動畫:

9efa14ce118d3835.gif

任何用 animate*AsState 建立的動畫都可以中斷。換句話說,如果動畫播放期間指定值有所改變,animate*AsState 就會重新播放動畫,並指向新的值。中斷在彈跳動畫裡顯得特別自然:

d5dbf92de69db775.gif

如果您想探索其他不同種類的動畫,請嘗試使用其他 spring 參數、其他規格 (tweenrepeatable) 和其他函式:animateColorAsState其他類型的動畫 API

本章節的完整程式碼

package com.example.basicscodelab

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.codelab.basics.ui.theme.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp(modifier = Modifier.fillMaxSize())
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {

    var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

    Surface(modifier) {
        if (shouldShowOnboarding) {
            OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
        } else {
            Greetings()
        }
    }
}

@Composable
fun OnboardingScreen(
    onContinueClicked: () -> Unit,
    modifier: Modifier = Modifier
) {

    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier.padding(vertical = 24.dp),
            onClick = onContinueClicked
        ) {
            Text("Continue")
        }
    }

}

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = List(1000) { "$it" }
) {
    LazyColumn(modifier = modifier.padding(vertical = 4.dp)) {
        items(items = names) { name ->
            Greeting(name = name)
        }
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {})
    }
}

@Composable
private fun Greeting(name: String, modifier: Modifier = Modifier) {

    var expanded by rememberSaveable { mutableStateOf(false) }

    val extraPadding by animateDpAsState(
        if (expanded) 48.dp else 0.dp,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        )
    )
    Surface(
        color = MaterialTheme.colorScheme.primary,
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        Row(modifier = Modifier.padding(24.dp)) {
            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding.coerceAtLeast(0.dp))
            ) {
                Text(text = "Hello, ")
                Text(text = name)
            }
            ElevatedButton(
                onClick = { expanded = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }
        }
    }
}

@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

@Preview
@Composable
fun MyAppPreview() {
    BasicsCodelabTheme {
        MyApp(Modifier.fillMaxSize())
    }
}

12. 設定應用程式樣式和主題

您到目前為止都還沒有為任何可組合項設定樣式,但是卻有很不錯的預設設定,包括支援深色模式!讓我們仔細看看 BasicsCodelabThemeMaterialTheme

如果您開啟 ui/theme/Theme.kt 檔案,就會看到 BasicsCodelabTheme 在實作裡使用 MaterialTheme

// Do not copy
@Composable
fun BasicsCodelabTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    // Dynamic color is available on Android 12+
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    // ...

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

MaterialTheme 是可以反映質感設計規格樣式原則的可組合函式。這項樣式資訊可以向下串聯 content 內的元件,以便讀取資訊並自行設定樣式。在您的 UI 裡,您已經在使用 BasicsCodelabTheme 了,如下所示:

    BasicsCodelabTheme {
        MyApp(modifier = Modifier.fillMaxSize())
    }

因為 BasicsCodelabTheme 會在內部納入 MaterialTheme,因此 MyApp 會用主題定義的屬性設定樣式。您可以從任何子系可組合項擷取 MaterialTheme 的三種屬性:colorSchemetypographyshapes。您可以用這些屬性為任一個 Text 設定標頭樣式:

            Column(modifier = Modifier
                .weight(1f)
                .padding(bottom = extraPadding.coerceAtLeast(0.dp))
            ) {
                Text(text = "Hello, ")
                Text(text = name, style = MaterialTheme.typography.headlineMedium)
            }

以上範例的 Text 可組合項將會設定新的 TextStyle。您可以自行建立 TextStyle,也可以使用 MaterialTheme.typography 擷取主題定義的樣式,我們比較建議您採用後面的做法。這個結構可以讓您存取 Material Design 定義的文字樣式,例如 displayLarge, headlineMedium, titleSmall, bodyLarge, labelMedium 等等。在您的範例中,您會使用主題所定義的 headlineMedium 樣式。

現在請建構應用程式,看看新的文字樣式:

673955c38b076f1c.png

一般來說,建議您把色彩、形狀和字型等樣式保留在 MaterialTheme 裡面。舉例來說,如果您對色彩進行硬式編碼,就會很難實作深色模式,此問題需要執行大量工作才能修復,但這些工作又很容易出錯。

不過,您有時候就是需要使用與所選色彩和字型樣式稍微不同的設定,在這種情況下,建議您按照現有的色彩和字型決定新的設定。

為了達到此效果,您可以使用 copy 函式修改已經定義好的樣式。把數字再加粗:

import androidx.compose.ui.text.font.FontWeight
// ...
Text(
    text = name,
    style = MaterialTheme.typography.headlineMedium.copy(
        fontWeight = FontWeight.ExtraBold
    )
)

這麼一來,如果您需要變更字型系列或其他 headlineMedium 的屬性,就不用擔心這些稍微變化的設定了。

預覽視窗現在應該會顯示這個成果:

b33493882bda9419.png

設定深色模式預覽

我們的預覽目前只會顯示應用程式在淺色模式下的呈現效果。使用 UI_MODE_NIGHT_YES,為 GreetingPreview 新增 @Preview 註解:

import android.content.res.Configuration.UI_MODE_NIGHT_YES

@Preview(
    showBackground = true,
    widthDp = 320,
    uiMode = UI_MODE_NIGHT_YES,
    name = "GreetingPreviewDark"
)
@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

這樣就能新增一個深色模式的預覽畫面。

2c94dc7775d80166.png

調整應用程式主題

您可以在 ui/theme 資料夾的檔案裡找到和目前主題相關的所有資訊。比方說,我們目前使用的預設色彩定義就位於 Color.kt 內。

讓我們先定義新的色彩。把這些內容加入 Color.kt

val Navy = Color(0xFF073042)
val Blue = Color(0xFF4285F4)
val LightBlue = Color(0xFFD7EFFE)
val Chartreuse = Color(0xFFEFF7CF)

現在請把這些內容指派給 Theme.kt 裡的 MaterialTheme 區塊面板:

private val LightColorScheme = lightColorScheme(
    surface = Blue,
    onSurface = Color.White,
    primary = LightBlue,
    onPrimary = Navy
)

返回 MainActivity.kt 並重新整理預覽畫面時,預覽色彩其實並未改變!這是因為您的預覽畫面預設使用動態色彩。您可以瞭解在 Theme.kt 中使用 dynamicColor 布林參數新增動態色彩的邏輯。

如要查看色彩配置的未自動調整版本,請在 API 級別低於 31 (對應於 Android S,其中已導入自動調整的色彩) 的裝置上執行您的應用程式。畫面上會顯示新的色彩:

493d754584574e91.png

Theme.kt 中,定義深色的區塊面板:

private val DarkColorScheme = darkColorScheme(
    surface = Blue,
    onSurface = Navy,
    primary = Navy,
    onPrimary = Chartreuse
)

現在執行應用程式時,即可看到深色色彩的實際運作情形:

84d2a903ffa6d8df.png

Theme.kt 最終的程式碼

import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.ViewCompat

private val DarkColorScheme = darkColorScheme(
    surface = Blue,
    onSurface = Navy,
    primary = Navy,
    onPrimary = Chartreuse
)

private val LightColorScheme = lightColorScheme(
    surface = Blue,
    onSurface = Color.White,
    primary = LightBlue,
    onPrimary = Navy
)

@Composable
fun BasicsCodelabTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    // Dynamic color is available on Android 12+
    dynamicColor: Boolean = true,
    content: @Composable () -> Unit
) {
    val colorScheme = when {
        dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
            val context = LocalContext.current
            if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
        }
        darkTheme -> DarkColorScheme
        else -> LightColorScheme
    }
    val view = LocalView.current
    if (!view.isInEditMode) {
        SideEffect {
            (view.context as Activity).window.statusBarColor = colorScheme.primary.toArgb()
            ViewCompat.getWindowInsetsController(view)?.isAppearanceLightStatusBars = darkTheme
        }
    }

    MaterialTheme(
        colorScheme = colorScheme,
        typography = Typography,
        content = content
    )
}

13. 最後修飾!

我們會在這個步驟中應用已經學過的知識,並依靠幾個提示學習新的概念。您將建立以下內容:

8d24a786bfe1a8f2.gif

以圖示取代按鈕

  • 和子項 Icon 一起使用 IconButton 可組合函式。
  • 使用 Icons.Filled.ExpandLessIcons.Filled.ExpandMorematerial-icons-extended 構件有提供這些內容。在 app/build.gradle.kts 檔案的依附元件裡加上以下內容。
implementation("androidx.compose.material:material-icons-extended")
  • 修改邊框間距,以便修正對齊方式。
  • 新增內容說明,以便提供無障礙功能 (請看下文的「使用字串資源」)。

使用字串資源

應用程式應該要顯示「顯示更多內容」和「顯示更少內容」的內容說明,而您可以利用簡單的 if 陳述式加入這項功能:

contentDescription = if (expanded) "Show less" else "Show more"

不過硬式編碼字串並不是好的做法,您應該從 strings.xml 檔案取得這些內容。

您可以為每個字串使用「擷取字串資源」,Android Studio 的「Context Actions」可以自動為您處理。

您也可以開啟 app/src/res/values/strings.xml 然後加入以下資源:

<string name="show_less">Show less</string>
<string name="show_more">Show more</string>

顯示更多內容

「Composem ipsum」文字會在顯示後消失,觸發每張卡片的大小變更作業。

  • 為展開項目時顯示的 Greeting 內的資料欄新增 Text
  • 移除 extraPadding,改為替 Row 套用 animateContentSize 修飾符。這樣做可以自動處理建立動畫的程序,此程序手動執行起來較不容易。這也讓您不需要使用 coerceAtLeast

新增高度和形狀

  • 您可以用 shadow 修飾符搭配 clip 修飾符,製作卡片的外觀。不過,其實有專門可以達到這個效果的 Material 可組合項:Card。如要變更 Card 的顏色,請呼叫 CardDefaults.cardColors,並覆寫要變更的顏色。

最終程式碼

package com.example.basicscodelab

import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons.Filled
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.basicscodelab.ui.theme.BasicsCodelabTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            BasicsCodelabTheme {
                MyApp(modifier = Modifier.fillMaxSize())
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

    Surface(modifier, color = MaterialTheme.colorScheme.background) {
        if (shouldShowOnboarding) {
            OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
        } else {
            Greetings()
        }
    }
}

@Composable
fun OnboardingScreen(
    onContinueClicked: () -> Unit,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Welcome to the Basics Codelab!")
        Button(
            modifier = Modifier.padding(vertical = 24.dp),
            onClick = onContinueClicked
        ) {
            Text("Continue")
        }
    }
}

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = List(1000) { "$it" }
) {
    LazyColumn(modifier = modifier.padding(vertical = 4.dp)) {
        items(items = names) { name ->
            Greeting(name = name)
        }
    }
}

@Composable
private fun Greeting(name: String, modifier: Modifier = Modifier) {
    Card(
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.primary
        ),
        modifier = modifier.padding(vertical = 4.dp, horizontal = 8.dp)
    ) {
        CardContent(name)
    }
}

@Composable
private fun CardContent(name: String) {
    var expanded by rememberSaveable { mutableStateOf(false) }

    Row(
        modifier = Modifier
            .padding(12.dp)
            .animateContentSize(
                animationSpec = spring(
                    dampingRatio = Spring.DampingRatioMediumBouncy,
                    stiffness = Spring.StiffnessLow
                )
            )
    ) {
        Column(
            modifier = Modifier
                .weight(1f)
                .padding(12.dp)
        ) {
            Text(text = "Hello, ")
            Text(
                text = name, style = MaterialTheme.typography.headlineMedium.copy(
                    fontWeight = FontWeight.ExtraBold
                )
            )
            if (expanded) {
                Text(
                    text = ("Composem ipsum color sit lazy, " +
                        "padding theme elit, sed do bouncy. ").repeat(4),
                )
            }
        }
        IconButton(onClick = { expanded = !expanded }) {
            Icon(
                imageVector = if (expanded) Filled.ExpandLess else Filled.ExpandMore,
                contentDescription = if (expanded) {
                    stringResource(R.string.show_less)
                } else {
                    stringResource(R.string.show_more)
                }
            )
        }
    }
}

@Preview(
    showBackground = true,
    widthDp = 320,
    uiMode = UI_MODE_NIGHT_YES,
    name = "GreetingPreviewDark"
)
@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    BasicsCodelabTheme {
        Greetings()
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    BasicsCodelabTheme {
        OnboardingScreen(onContinueClicked = {})
    }
}

@Preview
@Composable
fun MyAppPreview() {
    BasicsCodelabTheme {
        MyApp(Modifier.fillMaxSize())
    }
}

14. 恭喜

恭喜!您已經學會 Compose 的基本概念了!

本程式碼研究室的解決方案

您可以到 GitHub 取得本程式碼研究室的解決方案程式碼:

$ git clone https://github.com/android/codelab-android-compose

或者,您也可以將存放區下載為 ZIP 檔案:

後續步驟

請參閱 Compose 課程中的其他程式碼研究室:

其他資訊