使用 Jetpack Compose 建構自動調整式應用程式

1. 簡介

在本程式碼研究室中,您將瞭解如何建構適用於手機、平板電腦和摺疊式裝置的自動調整式應用程式,以及這些應用程式如何透過 Jetpack Compose 提高可連性。另外,您也會學到使用 Material 3 元件和主題設定的最佳做法。

在深入探討之前,請先瞭解「適應性」的意思。

適應性

應用程式的 UI 應配合不同的視窗大小、螢幕方向和板型規格做出回應。自動調整式版面配置會根據可用的畫面空間而變化。無論是為填滿空間而稍微調整版面配置,還是為運用額外空間而徹底變更版面配置,都屬於這類變化。

詳情請參閱自適應設計

在本程式碼研究室中,您將探索如何使用 Jetpack Compose 時的適應性。您將建構名為 Reply 的應用程式,瞭解如何為各種螢幕實作適應性,以及適應性和可及性如何搭配運作,為使用者提供最佳體驗。

課程內容

  • 如何使用 Jetpack Compose 設計應用程式,以便針對所有視窗大小進行設計。
  • 如何將應用程式指定給不同的折疊式裝置。
  • 如何使用不同類型的導覽功能,提升可及性和可及性。
  • 如何使用 Material 3 元件,為各種視窗大小提供最佳體驗。

軟硬體需求

  • 最新的 Android Studio 穩定版。
  • 可調整大小的 Android 13 虛擬裝置
  • 具備 Kotlin 相關知識。
  • 瞭解 Compose 的基本知識 (例如 @Composable 註解)。
  • 熟悉 Compose 版面配置的基本知識,例如 RowColumn
  • 熟悉修飾符的基本知識 (例如 Modifier.padding())。

您將在本程式碼研究室中使用可調整大小的模擬器,切換不同類型的裝置和視窗大小。

可調整大小的模擬器,提供手機、展開狀態、平板電腦和桌上型電腦的選項。

如果您不熟悉 Compose,建議您先學習 Jetpack Compose 的基本程式碼研究室,再完成本程式碼研究室。

建構項目

  • 名為 Reply 的互動式電子郵件用戶端應用程式,採用最佳做法,適用於適應性設計、不同的 Material Design 導覽,以及最佳的螢幕空間使用情況。

本程式碼研究室中展示的多裝置支援功能

2. 做好準備

如要取得本程式碼研究室的程式碼,請從指令列複製 GitHub 存放區:

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

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

建議您先從 main 分支版本的程式碼著手,依自己的步調逐步完成本程式碼研究室。

在 Android Studio 中開啟專案

  1. 在「Welcome to Android Studio」視窗中,選取 c01826594f360d94.png「Open an Existing Project」
  2. 選取資料夾 <Download Location>/AdaptiveUiCodelab (請務必選取含有「build.gradleAdaptiveUiCodelab 目錄)。
  3. Android Studio 匯入專案後,請測試是否能執行 main 分支。

探索範例程式碼

main 分支版本代碼包含 ui 套件。您將在該套件中使用下列檔案:

  • MainActivity.kt - 用於啟動應用程式的進入點活動。
  • ReplyApp.kt - 包含主要畫面 UI 可組合項。
  • ReplyHomeViewModel.kt:提供應用程式內容的資料和 UI 狀態。
  • ReplyListContent.kt - 包含用於提供清單和詳細資料畫面的可組合項。

如果您在可調整大小的模擬器上執行此應用程式,並嘗試使用不同的裝置類型 (例如手機或平板電腦),UI 只會擴展至指定空間,而不會利用螢幕空間或提供可及性人體工學。

手機上的初始畫面

平板電腦上的初始拉伸檢視畫面

更新後即可充分利用螢幕空間、提高可用性,並改善整體使用者體驗。

3. 讓應用程式可調整

本節將介紹讓應用程式具備適應性代表什麼,以及 Material 3 提供哪些元件,以便您輕鬆達成這項目標。此外,它還涵蓋您要指定的螢幕和狀態類型,包括手機、平板電腦、大型平板電腦和折疊式裝置。

您會先逐一瞭解視窗大小、折疊型態以及各種導覽選項的基本知識。接著,您可以在應用程式中使用這些 API,讓應用程式更具適應性。

視窗大小

Android 裝置有多種形狀和大小,包括手機、折疊式裝置、平板電腦和 ChromeOS 裝置。如要盡可能支援多種視窗大小,您的 UI 需要採用回應式及自動調整式設計。為了協助您找出適當的門檻,以便變更應用程式的 UI,我們已定義中斷點值,可將裝置分類為預先定義的大小類別 (精簡、中等和展開),稱為視窗大小類別。這些是一系列固定的可視區域中斷點,可協助您設計、開發及測試回應式與自動調整式應用程式版面配置。

這些類別經過特別挑選,可讓版面配置保持簡單又兼具彈性,進一步根據獨特的情境需求來最佳化應用程式。視窗大小類別一律取決於應用程式可用的螢幕空間,但這不一定是多工處理或其他區隔作業的整個實體螢幕。

精簡、中等和展開寬度的 WindowWidthSizeClass。

精簡、中等和展開高度的 WindowHeightSizeClass。

寬度和高度會分別分類,因此應用程式隨時都有兩種視窗大小類別:寬度類別和高度類別。由於垂直捲動操作比較常見,可用寬度通常比可用高度更重要,因此在這種情況下,您也應使用寬度大小類別。

折疊狀態

由於折疊式裝置的大小和轉軸的存在,應用程式可適應的情況更多。轉軸可能會遮蔽部分螢幕,導致該區域不適合顯示內容;轉軸也可能會分開,也就是說,裝置展開時會有兩個獨立的實體螢幕。

折疊型態、平鋪式和半開式

此外,使用者可能會在轉軸處部分打開的情況下觀看內螢幕,這會根據摺疊方向產生不同的物理姿勢:桌面姿勢 (水平摺疊,如上圖右側所示) 和書本姿勢 (垂直摺疊)。

進一步瞭解摺疊位置和鉸鏈

在實作支援摺疊式裝置的自動調整式版面配置時,請將這些事項納入考量。

取得適性資訊

Material3 adaptive 程式庫可讓您輕鬆存取應用程式執行所在視窗的相關資訊。

  1. 將這個構件及其版本的項目新增至版本目錄檔案:

gradle/libs.versions.toml

[versions]
material3Adaptive = "1.0.0"

[libraries]
androidx-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "material3Adaptive" }
  1. 在應用程式模組的建構檔案中,新增程式庫依附元件,然後執行 Gradle 同步處理作業:

app/build.gradle.kts

dependencies {

    implementation(libs.androidx.material3.adaptive)
}

如今,您可以在任何可組合範圍中使用 currentWindowAdaptiveInfo() 取得 WindowAdaptiveInfo 物件,其中包含目前視窗大小類別等資訊,以及裝置是否處於摺疊式型態 (例如桌面型態)。

您現在可以在 MainActivity 中試用這項功能。

  1. ReplyTheme 區塊內的 onCreate() 中,取得視窗自適應資訊,並在 Text 可組合函式中顯示大小類別。您可以在 ReplyApp() 元素後方加入以下內容:

MainActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContent {
        ReplyTheme {
            val uiState by viewModel.uiState.collectAsStateWithLifecycle()
            ReplyApp(
                replyHomeUIState = uiState,
                onEmailClick = viewModel::setSelectedEmail
            )

            val adaptiveInfo = currentWindowAdaptiveInfo()
            val sizeClassText =
                "${adaptiveInfo.windowSizeClass.windowWidthSizeClass}\n" +
                "${adaptiveInfo.windowSizeClass.windowHeightSizeClass}"
            Text(
                text = sizeClassText,
                color = Color.Magenta,
                modifier = Modifier.padding(
                    WindowInsets.safeDrawing.asPaddingValues()
                )
            )
        }
    }
}

現在執行應用程式,即可在應用程式內容上方顯示視窗大小類別。歡迎探索視窗自動調整資訊中還提供哪些項目。之後,您可以移除這個Text,因為這樣可以涵蓋應用程式內容,而且後續步驟並不需要。

4. 動態導覽

現在,請在裝置狀態和大小變更時調整應用程式的導覽功能,讓應用程式更容易使用。

使用者拿著手機時,手指通常會放在螢幕底部。使用者手持展開的摺疊式裝置或平板電腦時,手指通常會靠近兩側。使用者應可瀏覽或啟動與應用程式的互動,而不必將手指放在極端位置或改變手指位置。

設計應用程式並決定版面配置中的互動式 UI 元素放置位置時,請考量螢幕不同區域的人體工學意涵。

  • 請問在握住裝置時,哪些區域較容易觸及?
  • 哪些區域只能透過伸展手指才能觸及,因此可能不方便?
  • 哪些地方難以觸及,或距離使用者手持裝置距離太遠?

導覽是使用者最先互動的項目,其中包含與關鍵使用者歷程相關的重要動作,因此應放置在最容易存取的位置。Material 自適應程式庫提供多個元件,可根據裝置的視窗大小類別實作導覽功能。

底部導覽

底部導覽列非常適合精簡的大小,因為我們自然握住裝置時,大拇指就能輕鬆觸及所有底部導覽列的觸控點。假如裝置尺寸精簡,或是處於密集折疊狀態的折疊式裝置,則適用此模式。

底部導覽列 (含項目)

對於寬度的視窗大小,導覽邊欄是最適合觸及的選項,因為拇指會自然地落在裝置側邊。您也可以將導覽列與導覽匣結合,以便顯示更多資訊。

含有項目的導覽邊欄

導覽匣可讓您輕鬆查看導覽分頁的詳細資訊,並在使用平板電腦或大型裝置時輕鬆存取。您可以使用兩種導覽匣:模式導覽匣和永久導覽匣。

強制回應導覽匣

您可以為小型到中型手機和平板電腦使用模態導覽匣,因為它可以展開或隱藏在內容上方做為疊加層。有時可以與導覽軌道結合使用。

含有項目的強制回應導覽匣

永久性導覽匣

在大型平板電腦、Chromebook 和電腦上,你可以使用固定式導覽匣進行固定瀏覽。

固定式導覽匣,內含項目

導入動態導覽

如今,您可以根據裝置狀態和大小變更,切換不同類型的導覽。

目前,無論裝置狀態為何,應用程式一律會在螢幕內容下方顯示 NavigationBar。您可以改用 Material Design NavigationSuiteScaffold 元件,根據目前視窗大小類別等資訊自動切換不同的導覽元件。

  1. 新增 Gradle 依附元件以取得這個元件,方法是更新版本目錄和應用程式的建構指令碼,然後執行 Gradle 同步處理作業:

gradle/libs.versions.toml

[versions]
material3AdaptiveNavSuite = "1.3.0"

[libraries]
androidx-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "material3AdaptiveNavSuite" }

app/build.gradle.kts

dependencies {

    implementation(libs.androidx.material3.adaptive.navigation.suite)
}
  1. ReplyApp.kt 中找出 ReplyNavigationWrapper() 可組合函式,然後將 Column 及其內容替換為 NavigationSuiteScaffold

ReplyApp.kt

@Composable
private fun ReplyNavigationWrapperUI(
    content: @Composable () -> Unit = {}
) {
    var selectedDestination: ReplyDestination by remember {
        mutableStateOf(ReplyDestination.Inbox)
    }

    NavigationSuiteScaffold(
        navigationSuiteItems = {
            ReplyDestination.entries.forEach {
                item(
                    selected = it == selectedDestination,
                    onClick = { /*TODO update selection*/ },
                    icon = {
                        Icon(
                            imageVector = it.icon,
                            contentDescription = stringResource(it.labelRes)
                        )
                    },
                    label = {
                        Text(text = stringResource(it.labelRes))
                    },
                )
            }
        }
    ) {
        content()
    }
}

navigationSuiteItems 引數是可讓您使用 item() 函式新增項目的區塊,類似於在 LazyColumn 中新增項目。在結尾的 lambda 中,這段程式碼會呼叫 content(),並將其做為引數傳遞至 ReplyNavigationWrapperUI()

在模擬器上執行應用程式,然後嘗試在手機、折疊式手機和平板電腦之間變更大小,您會看到導覽列變更為導覽列,然後再變回。

在非常寬的視窗中 (例如平板電腦的橫向模式),您可能會想顯示永久導覽匣。NavigationSuiteScaffold 支援顯示永久式抽屜,但目前的任何 WindowWidthSizeClass 值都不會顯示。不過,您可以透過小幅變更來實現這項功能。

  1. 請在呼叫 NavigationSuiteScaffold 之前加入下列程式碼:

ReplyApp.kt

@Composable
private fun ReplyNavigationWrapperUI(
    content: @Composable () -> Unit = {}
) {
    var selectedDestination: ReplyDestination by remember {
        mutableStateOf(ReplyDestination.Inbox)
    }

    val windowSize = with(LocalDensity.current) {
        currentWindowSize().toSize().toDpSize()
    }
    val layoutType = if (windowSize.width >= 1200.dp) {
        NavigationSuiteType.NavigationDrawer
    } else {
        NavigationSuiteScaffoldDefaults.calculateFromAdaptiveInfo(
            currentWindowAdaptiveInfo()
        )
    }

    NavigationSuiteScaffold(
        layoutType = layoutType,
        ...
    ) {
        content()
    }
}

這段程式碼會先取得視窗大小,然後使用 currentWindowSize()LocalDensity.current 轉換為 DP 單位,接著比較視窗寬度,決定導覽 UI 的版面配置類型。如果視窗寬度至少為 1200.dp,則會使用 NavigationSuiteType.NavigationDrawer。否則會改為採用預設計算方式。

在可調整大小的模擬器上再次執行應用程式並嘗試不同類型時,請注意,每當螢幕設定變更或展開摺疊式裝置時,導覽會變更為該大小的適當類型。

顯示不同大小裝置的適應性變更。

恭喜!您已經瞭解不同類型的導覽,可以支援不同類型的視窗大小和狀態!

在下一節中,您將瞭解如何充分利用任何剩餘的螢幕區域,而非將相同的清單項目拉伸至螢幕邊緣。

5. 螢幕空間使用情形

無論您是在小型平板電腦、未摺疊的裝置或大型平板電腦上執行應用程式,螢幕都會拉長,以填滿剩餘空間。您想確實利用這個螢幕空間顯示更多資訊 (比如這個應用程式),使用者會在同一個頁面中看到電子郵件和討論串。

Material 3 定義了三種標準版面配置,每種版面配置都有精簡、中等和展開視窗大小類別的設定。清單詳細資料標準版面配置非常適合這個用途,且可在 Compose 中以 ListDetailPaneScaffold 的形式使用。

  1. 如要取得這個元件,請新增下列依附元件並執行 Gradle 同步作業:

gradle/libs.versions.toml

[libraries]
androidx-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "material3Adaptive" }
androidx-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "material3Adaptive" }

app/build.gradle.kts

dependencies {

    implementation(libs.androidx.material3.adaptive.layout)
    implementation(libs.androidx.material3.adaptive.navigation)
}
  1. ReplyApp.kt 中找出 ReplyAppContent() 可組合函式,目前這個函式只會透過呼叫 ReplyListPane() 顯示清單窗格。插入以下程式碼,將此實作項目替換為 ListDetailPaneScaffold。由於這是實驗性 API,您還必須在 ReplyAppContent() 函式上新增 @OptIn 註解:

ReplyApp.kt

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ReplyAppContent(
    replyHomeUIState: ReplyHomeUIState,
    onEmailClick: (Email) -> Unit,
) {
    val navigator = rememberListDetailPaneScaffoldNavigator<Long>()

    ListDetailPaneScaffold(
        directive = navigator.scaffoldDirective,
        value = navigator.scaffoldValue,
        listPane = {
            ReplyListPane(replyHomeUIState, onEmailClick)
        },
        detailPane = {
            ReplyDetailPane(replyHomeUIState.emails.first())
        }
    )
}

此程式碼會先使用 rememberListDetailPaneNavigator() 建立導覽器。導覽器提供一些控制項,讓您控管要顯示的窗格,以及該窗格應呈現的內容,稍後將進行示範。

展開視窗寬度大小類別時,ListDetailPaneScaffold 會顯示兩個窗格。否則,應用程式會根據為兩個參數提供的值 (scaffold 指令和 Scaffold 值) 顯示一個窗格或另一個窗格。為了取得預設行為,這段程式碼會使用結構定向指令和導覽器提供的結構定向值。

其餘必要參數是分頁的可組合 lambda。ReplyListPane()ReplyDetailPane() (位於 ReplyListContent.kt 中) 分別用於填入清單和詳細資料窗格的角色。ReplyDetailPane() 預期電子郵件引數,因此目前這個程式碼會使用 ReplyHomeUIState 電子郵件清單中的第一封電子郵件。

執行應用程式,並將模擬器檢視畫面切換為折疊式或平板電腦 (可能也需要變更螢幕方向),才能看到兩個窗格的版面配置。這看起來比之前好多了!

現在我們來看看此畫面的一些期望行為。使用者輕觸清單窗格中的電子郵件時,該郵件應會連同所有回覆顯示在詳細資料窗格中。目前,應用程式不會追蹤選取的電子郵件,輕觸項目也沒有任何作用。儲存這項資訊的最佳位置是在 ReplyHomeUIState 中的其他 UI 狀態。

  1. 開啟 ReplyHomeViewModel.kt,然後找出 ReplyHomeUIState 資料類別。為所選電子郵件新增屬性,預設值為 null

ReplyHomeViewModel.kt

data class ReplyHomeUIState(
    val emails : List<Email> = emptyList(),
    val selectedEmail: Email? = null,
    val loading: Boolean = false,
    val error: String? = null
)
  1. 在同一個檔案中,ReplyHomeViewModel 具有 setSelectedEmail() 函式,當使用者輕觸清單項目時,系統會呼叫該函式。修改這個函式,複製 UI 狀態並記錄所選的電子郵件:

ReplyHomeViewModel.kt

fun setSelectedEmail(email: Email) {
    _uiState.update {
        it.copy(selectedEmail = email)
    }
}

使用者輕觸任何項目且選取的電子郵件為 null 前的後果,請特別留意。詳細資料窗格應顯示哪些內容?您可以透過多種方式處理這種情況,例如預設顯示清單中的第一個項目。

  1. 在同一個檔案中,修改 observeEmails() 函式。載入電子郵件清單時,如果先前的 UI 狀態沒有選取的電子郵件,請將其設為第一個項目:

ReplyHomeViewModel.kt

private fun observeEmails() {
    viewModelScope.launch {
        emailsRepository.getAllEmails()
            .catch { ex ->
                _uiState.value = ReplyHomeUIState(error = ex.message)
            }
            .collect { emails ->
                val currentSelection = _uiState.value.selectedEmail
                _uiState.value = ReplyHomeUIState(
                    emails = emails,
                    selectedEmail = currentSelection ?: emails.first()
                )
            }
    }
}
  1. 返回 ReplyApp.kt,使用所選電子郵件 (如有) 填入詳細資料窗格內容:

ReplyApp.kt

ListDetailPaneScaffold(
    // ...
    detailPane = {
        if (replyHomeUIState.selectedEmail != null) {
            ReplyDetailPane(replyHomeUIState.selectedEmail)
        }
    }
)

再次執行應用程式,並將模擬器切換為平板電腦大小,確認輕觸清單項目後,詳細資料窗格會更新內容。

當兩個窗格都顯示時,這項功能就能正常運作,但當視窗空間只能顯示一個窗格時,使用者輕觸項目並不會發生任何變化。請嘗試將模擬器檢視畫面切換為手機或摺疊式裝置的直向模式,並注意即使輕觸項目後,也只會顯示清單窗格。這是因為即使選取的電子郵件已更新,ListDetailPaneScaffold 仍會專注於這些設定中的清單窗格。

  1. 如要修正這個問題,請將下列程式碼插入傳遞至 ReplyListPane 的 lambda:

ReplyApp.kt

ListDetailPaneScaffold(
    // ...
    listPane = {
        ReplyListPane(
            replyHomeUIState = replyHomeUIState,
            onEmailClick = { email ->
                onEmailClick(email)
                navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, email.id)
            }
        )
    },
    // ...
)

這個 lambda 會使用先前建立的導覽器,在點選項目時新增其他行為。它會呼叫傳遞至此函式的原始 lambda,然後也呼叫 navigator.navigateTo(),指定應顯示哪個窗格。Scaffold 中的每個窗格都有與其相關聯的角色,詳細資料窗格中則為 ListDetailPaneScaffoldRole.Detail。在較小的視窗中,這會讓應用程式看起來已前進。

應用程式也需要處理使用者在詳細資料窗格中按下返回按鈕時的情況,而這項行為會因顯示一個窗格或兩個窗格而有所不同。

  1. 新增下列程式碼即可支援返回導覽功能。

ReplyApp.kt

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun ReplyAppContent(
    replyHomeUIState: ReplyHomeUIState,
    onEmailClick: (Email) -> Unit,
) {
    val navigator = rememberListDetailPaneScaffoldNavigator<Long>()

    BackHandler(navigator.canNavigateBack()) {
        navigator.navigateBack()
    }

    ListDetailPaneScaffold(
        directive = navigator.scaffoldDirective,
        value = navigator.scaffoldValue,
        listPane = {
            AnimatedPane {
                ReplyListPane(
                    replyHomeUIState = replyHomeUIState,
                    onEmailClick = { email ->
                        onEmailClick(email)
                        navigator.navigateTo(ListDetailPaneScaffoldRole.Detail, email.id)
                    }
                )
            }
        },
        detailPane = {
            AnimatedPane {
                if (replyHomeUIState.selectedEmail != null) {
                    ReplyDetailPane(replyHomeUIState.selectedEmail)
                }
            }
        }
    )
}

導覽器會知道 ListDetailPaneScaffold 的完整狀態、是否可以返回導覽,以及在所有這些情況下該做什麼。這個程式碼會建立 BackHandler,只要導覽器可以返回,就會啟用,並在 lambda 中呼叫 navigateBack()。此外,為了讓各窗格之間的轉換更加順暢,每個窗格都會包裝在 AnimatedPane() 可組合函式中。

針對所有不同類型的裝置,在可調整大小的模擬器上再次執行應用程式,並注意每當螢幕設定變更,或您展開折疊式裝置時,導覽和螢幕內容會根據裝置狀態變更而動態變更。您也可以嘗試輕觸清單窗格中的電子郵件,看看版面配置在不同螢幕上的運作情形,並排顯示兩個窗格或流暢地在兩個窗格之間切換。

顯示不同大小裝置的適應性變更。

恭喜!您已成功讓應用程式可針對各種裝置狀態和大小自動調整。您可以盡情在折疊式裝置、平板電腦或其他行動裝置上執行應用程式。

6. 恭喜

恭喜!您已成功完成本程式碼研究室,並瞭解如何使用 Jetpack Compose 讓應用程式具備自動調整功能。

您已瞭解如何檢查裝置的大小和折疊狀態,並據此更新應用程式的使用者介面、導覽和其他功能。此外,您也學到適應性如何提升可連性並改善使用者體驗。

後續步驟

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

範例應用程式

  • Compose 範例是許多應用程式的集合,其中納入了程式碼研究室中說明的最佳做法。

參考文件