Jetpack Compose 基本概念

透過集合功能整理內容 你可以依據偏好儲存及分類內容。

1. 事前準備

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

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

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

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

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

必要條件

  • 熟悉 Kotlin 語法,包括 lambda

要執行的步驟

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

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

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

8d24a786bfe1a8f2.gif

軟硬體需求

2. 開啟新的 Compose 專案

如果想建立新的 Compose 專案,請開啟 Android Studio 並選擇「Start a new Android Studio project」(開啟新的 Android Studio 專案),如下所示:

5028990f5d3e7464.png

如果應用程式並未顯示以上畫面,請至「File」(檔案) >「New」(新增) >「New Project」(新專案)。

建立新專案時,請從系統提供的範本中選取「Empty Compose Activity (Material3)」(空白 Compose 活動 (質感設計 3))

bff1ec24e30656ef.png

按一下「Next」(繼續),然後設定專案並命名為「Basics Codelab」。確定「minimumSdkVersion」選擇至少 API 級別 21,這是 Compose 支援的最低 API 版本。

選取「Empty Compose Activity (Material3)」(空白 Compose 活動 (質感設計 3)) 範本時,專案會為您產生以下程式碼:

  • 專案已設為使用 Compose。
  • 建立 AndroidManifest.xml 檔案。
  • build.gradleapp/build.gradle 檔案,內含 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) {
    Text(text = "Hello $name!")
}

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

您將可在下一章節中看到每個方法的功能,以及您如何改善這些方法,以便建立容易調整且可以重複利用的版面配置。

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

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

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

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

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

3. 開始使用 Compose

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

可組合函式

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

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

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 DefaultPreview() {
    BasicsCodelabTheme {
        Greeting(name = "Android")
    }
}

faac3de6d3136846.png

如果您有選擇「Code」(程式碼) f66a8adcef249de5.png,則可能不會出現預覽畫面。按一下「Split」(分割) f3c0e2f3221dadcb.png 即可預覽。

4. 調整 UI

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

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

Surface 內部的元件會繪製在背景顏色上面。

將這段程式碼加入專案後,您就可以在 Android Studio 右上角看到「Build & Refresh」(建構並重新整理) 按鈕。輕觸這個按鈕,或建構專案,就能在預覽畫面看到新的變更內容。

9632f3ca76cbe115.png

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

87683b388990527b.png

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

答案是從來沒有!androidx.compose.material3.Surface 這類質感元件的設計本身就是藉由採用您應該會想要的應用程式常見功能,藉此讓您有更好的體驗,例如為文字選擇合適的顏色。我們會說質感設計非常「堅持己見」,可以提供多數應用程式都有的優秀預設值和模式。Compose 的質感元件正是用這些基礎元件建構而成 (位於 androidx.compose.foundation),您也可以用應用程式元件存取,讓您可以更靈活地控管。

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

修飾詞

大部分的 Compose UI 元素,例如 SurfaceText,都會接受選用的 modifier 參數。修飾詞可以指示 UI 元素如何在其上層布局內部安排版面配置、顯示或行為。

舉例來說,padding 修飾詞可以在裝飾的元素周圍套用一定數量的空間。您可以用 Modifier.padding() 建立邊框間距修飾詞。

然後,您可以為畫面上的 Text 加入邊框間距:

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

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

按一下「Build & Refresh」(建構並重新整理),就能看到新的變更內容。

cb03fa884333d93f.png

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

5. 重複利用可組合項

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

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

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

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

這樣做之後,您就能清理 onCreate 回呼和預覽,因為現在可以重新利用 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
private fun MyApp(modifier: Modifier = Modifier) {
    Surface(
        modifier = modifier,
        color = MaterialTheme.colorScheme.background
    ) {
        Greeting("Android")
    }
}

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

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

6. 建立欄和列

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

518dbfad23ee1b05.png

這些是可撰寫的函式,可用於撰寫可撰寫的內容,因此您可以將這些項目加入其中。舉例來說,Column 內的每個子項都會垂直擺放。

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

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

1d5a033c2f0ceba3.png

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

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

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

@Composable
private fun Greeting(name: String) {
    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)
        }
    }
}

a402e4d13555f8cf.png

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

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

9cfa1f9e3a408f6.png

Compose 很常使用修飾詞,不妨讓我們練習更進階的內容:嘗試用 fillMaxWidthpadding 修飾詞再現以下這個版面配置。

5f11ac80e19830b4.png

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

@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
private fun Greeting(name: String) {
    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,所以我們需要先加入這個按鈕。這裡的目標是建立以下的版面配置:

eaf45a8dc0271a6f.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
private fun Greeting(name: String) {

    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 中的狀態

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

783e161e8bb1b2d5.gif

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

// Don't copy over
@Composable
private 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")
}

如果您用模擬器執行應用程式,就可以看到當按下按鈕時,expanded 會切換觸發按鈕裡的文字進行重新組成。每個 Greeting 都能維持自己的展開狀態,因為分別屬於不同的 UI 元素。

f0edd5dc6d108de.gif

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

@Composable
private fun Greeting(name: String) {
    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
private fun Greeting(name: String) {

    val expanded = remember { mutableStateOf(false) }

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

您不必針對重組而記住 extraPadding,因為其只是執行簡單的計算。

現在,我們可以幫欄套用新的邊框間距修飾詞:

@Composable
private fun Greeting(name: String) {

    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")
            }
        }
    }
}

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

783e161e8bb1b2d5.gif

8. 狀態提升

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

藉由讓狀態可以提昇,您可以避免重複使用狀態和出現錯誤,讓您更能重複利用可組合項,也能讓可組合項更容易進行測試。而相反地,如果有狀態不需要由可組合項的父系控管,則您不應該提昇這類狀態。可靠資料來源屬於建立和控管該狀態的項目。

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

bb6d7e193c5aa1ab.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
private 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 運算式表示「什麼都不做」,十分適合預覽畫面使用。

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

5075d7320c78b356.gif

目前為止的完整程式碼:

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
private fun Greeting(name: String) {

    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")
            }
        }
    }
}

@Preview(showBackground = true, widthDp = 320)
@Composable
fun DefaultPreview() {
    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)
        }
    }
}

2e29949d9d9b8690.gif

10. 保留狀態

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

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

現在,請將 shouldShowOnboarding 中的 remember 替換為 rememberSaveable

    var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

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

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

11. 建立清單動畫

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

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

a5267f0b704bf355.gif

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

建立可以根據展開狀態顯示動畫效果的 extraPadding。然後,我們可以使用屬性委派 (by 關鍵字):

@Composable
private fun Greeting(name: String) {

    var expanded by remember { 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 參數,您可以用這個參數自訂動畫內容。我們可以加上一個彈跳動畫,讓效果更有趣:

@Composable
private fun Greeting(name: String) {

    var expanded by remember { 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 規格不使用任何和時間有關的參數,而是仰賴實際的屬性 (阻尼和硬度),讓動畫更生動自然。請執行應用程式並看看新的動畫:

a5267f0b704bf355.gif

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

e7d244875b3b6b6f.gif

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

本章節的完整程式碼

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.remember
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) {

    var expanded by remember { 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 DefaultPreview() {
    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 擷取主題定義的樣式,我們比較建議您採用後面的做法。這個結構可以讓您存取質感定義的文字樣式,例如 displayLarge, headlineMedium, titleSmall, bodyLarge, labelMedium 等等。在您的範例中,您會使用主題所定義的 headlineMedium 樣式。

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

d52c17be34999464.png

一般來說,建議您把色彩、形狀和字型等樣式保留在 MaterialTheme 裡面。舉例來說,如果您硬式編碼色彩,就會很難實作深色模式,需要使用非常多容易發生錯誤的步驟才能修復這個問題。

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

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

                Text(
                    text = name,
                    style = MaterialTheme.typography.headlineMedium.copy(
                        fontWeight = FontWeight.ExtraBold
                    )
                )

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

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

fc2839dd49627cd1.png

設定深色模式預覽

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

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

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

3b6e25f5ee00a2e2.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 檔案的依附元件裡加上以下內容。
implementation "androidx.compose.material:material-icons-extended:$compose_version"
  • 修改邊框間距,以便修正對齊方式。
  • 新增內容說明,以便提供無障礙功能 (請看下文的「使用字串資源」)。

使用字串資源

應用程式應該要顯示「顯示更多內容」和「顯示更少內容」的內容說明,而您可以簡單利用 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 修飾詞營造出卡片的效果。不過,其實有專門可以達到這個效果的質感可組合項:Card。如要變更 Card 的顏色,請呼叫 CardDefaults.cardColors,並覆寫要變更的顏色。

最終程式碼

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.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.remember
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.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) {
    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 remember { 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 = "DefaultPreviewDark"
)
@Preview(showBackground = true, widthDp = 320)
@Composable
fun DefaultPreview() {
    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/googlecodelabs/android-compose-codelabs

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

後續步驟

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

其他資訊