Trạng thái nâng cao và hiệu ứng phụ trong Jetpack Compose

1. Giới thiệu

Trong lớp học lập trình này, bạn sẽ tìm hiểu những khái niệm nâng cao liên quan đến các API Trạng thái và API Hiệu ứng phụ trong Jetpack Compose. Bạn sẽ tìm hiểu cách tạo phần tử giữ trạng thái cho thành phần kết hợp có tính trạng thái với logic đáng kể, cách tạo coroutine và gọi các hàm có thể tạm ngưng (suspend function) bằng đoạn mã trong Compose cũng như cách kích hoạt hiệu ứng phụ để thực hiện các trường hợp sử dụng.

Để được hỗ trợ thêm khi tham gia lớp học lập trình này, hãy xem nội dung tập lập trình dưới đây:

Kiến thức bạn sẽ học được

Bạn cần có

Sản phẩm bạn sẽ tạo ra

Trong lớp học lập trình này, bạn sẽ bắt đầu với một ứng dụng chưa hoàn thiện, đó là ứng dụng Crane – Nghiên cứu của Material và thêm các tính năng để cải thiện ứng dụng đó.

b2c6b8989f4332bb.gif

2. Thiết lập

Lấy mã

Bạn có thể tìm thấy đoạn mã dành cho lớp học lập trình này trong android-compose-codelabs trên kho lưu trữ GitHub. Để sao chép, hãy chạy:

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

Ngoài ra, bạn có thể tải kho lưu trữ ở dạng định dạng tệp zip:

Xem xét ứng dụng mẫu

Mã bạn vừa tải xuống có chứa mã dành cho tất cả lớp học lập trình Compose hiện có. Để hoàn tất lớp học lập trình này, hãy mở dự án AdvancedStateAndSideEffectsCodelab trong Android Studio.

Bạn bên bắt đầu với các đoạn mã trong nhánh main và làm theo hướng dẫn từng bước của lớp học lập trình theo tốc độ của bản thân.

Xuyên suốt lớp học lập trình, bạn sẽ thấy các đoạn mã bạn cần thêm vào dự án. Có lúc, bạn cũng sẽ cần phải xoá những dòng mã được đề cập rõ ràng trong các nhận xét trong đoạn mã.

Làm quen với các đoạn mã và chạy ứng dụng mẫu

Hãy dành chút thời gian tìm hiểu cấu trúc dự án và chạy ứng dụng.

162c42b19dafa701.png

Khi chạy ứng dụng từ nhánh main (chính), bạn sẽ thấy rằng một số chức năng như ngăn hoặc tải điểm đến của chuyến bay không hoạt động! Đó là những gì bạn sẽ thực hiện trong các bước tiếp theo của lớp học lập trình này.

b2c6b8989f4332bb.gif

Kiểm thử giao diện người dùng

Ứng dụng này sử dụng các bài kiểm thử giao diện người dùng rất cơ bản có trong thư mục androidTest. Cả 2 nhánh mainend sẽ luôn vượt qua các bài kiểm tra này.

[Không bắt buộc] Hiển thị bản đồ trên màn hình chi tiết

Bạn không cần phải làm theo các bước để hiển thị bản đồ thành phố trên màn hình chi tiết. Tuy nhiên, nếu muốn làm như vậy, bạn cần phải có khoá API cá nhân như được nêu trong tài liệu Maps (Bản đồ). Thêm khoá đó vào tệp local.properties như sau:

// local.properties file
google.maps.key={insert_your_api_key_here}

Giải pháp cho lớp học lập trình

Để nhận nhánh end bằng git, hãy dùng lệnh sau:

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

Hoặc bạn có thể tải đoạn mã chứa giải pháp từ đây:

Câu hỏi thường gặp

3. Quy trình tạo trạng thái giao diện người dùng

Như bạn thấy khi chạy ứng dụng từ nhánh main, danh sách các điểm đến của chuyến bay bị trống!

Để khắc phục vấn đề này, bạn phải hoàn tất 2 bước sau đây:

  • Thêm logic trong ViewModel để tạo trạng thái giao diện người dùng. Trong trường hợp của bạn, đây là danh sách điểm đến đề xuất.
  • Sử dụng trạng thái giao diện người dùng từ giao diện người dùng. Trạng thái này sẽ hiển thị giao diện người dùng trên màn hình.

Trong phần này, bạn sẽ hoàn tất bước đầu tiên.

Một cấu trúc tối ưu của ứng dụng được sắp xếp theo lớp để tuân theo các phương pháp cơ bản về thiết kế một hệ thống tối ưu, chẳng hạn như phân tách các mối quan ngại và khả năng kiểm thử.

Phần Tạo trạng thái giao diện người dùng đề cập đến quá trình ứng dụng truy cập vào lớp dữ liệu, áp dụng quy tắc kinh doanh nếu cần và hiển thị trạng thái giao diện người dùng được sử dụng từ giao diện người dùng.

Lớp dữ liệu trong ứng dụng này đã được triển khai. Bây giờ, bạn sẽ tạo trạng thái (danh sách điểm đến được đề xuất) để giao diện người dùng có thể sử dụng trạng thái đó.

Bạn có thể dùng một số API để tạo trạng thái giao diện người dùng. Các phương án thay thế được tóm tắt trong tài liệu về Các loại đầu ra trong quy trình tạo trạng thái. Nhìn chung, bạn nên sử dụng StateFlow của Kotlin để tạo trạng thái giao diện người dùng.

Để tạo trạng thái giao diện người dùng, hãy làm theo các bước sau đây:

  1. Mở home/MainViewModel.kt.
  2. Xác định biến _suggestedDestinations riêng tư thuộc loại MutableStateFlow để biểu thị danh sách điểm đến được đề xuất và đặt một danh sách trống làm giá trị bắt đầu.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
  1. Xác định biến không thể thay đổi thứ hai suggestedDestinations thuộc loại StateFlow. Đây là biến chỉ đọc công khai có thể được sử dụng từ giao diện người dùng. Việc hiển thị biến chỉ đọc trong khi sử dụng biến có thể thay đổi trong nội bộ là một phương pháp hay. Bằng cách này, bạn đảm bảo rằng chỉ có thể sửa đổi trạng thái giao diện người dùng thông qua ViewModel, giúp lớp này trở thành nguồn đáng tin cậy duy nhất. Hàm mở rộng asStateFlow chuyển đổi luồng từ có thể thay đổi thành không thể thay đổi.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())

val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
  1. Trong khối init của ViewModel, hãy thêm một lệnh gọi từ destinationsRepository để nhận các điểm đến từ lớp dữ liệu.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())

val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()

init {
    _suggestedDestinations.value = destinationsRepository.destinations
}
  1. Cuối cùng, hãy huỷ nhận xét về việc sử dụng biến nội bộ _suggestedDestinations mà bạn thấy trong lớp này để có thể cập nhật biến này đúng cách với các sự kiện đến từ giao diện người dùng.

Vậy là xong! Bạn đã hoàn thành bước đầu tiên! ViewModel hiện có thể tạo trạng thái giao diện người dùng. Trong bước tiếp theo, bạn sẽ sử dụng trạng thái này từ giao diện người dùng.

4. Sử dụng luồng dữ liệu từ ViewModel một cách an toàn

Danh sách điểm đến của chuyến bay vẫn trống. Ở bước trước, bạn đã tạo trạng thái giao diện người dùng trong MainViewModel. Giờ đây, bạn sẽ sử dụng trạng thái giao diện người dùng có trong MainViewModel để hiển thị trong giao diện người dùng.

Mở lại tệp home/CraneHome.kt và xem thành phần kết hợp CraneHomeContent.

Có một ghi chú trong mục TODO (Việc cần làm) phía trên định nghĩa về suggestedDestinations được gán cho danh sách trống được ghi nhớ. Đây là nội dung sẽ xuất hiện trên màn hình: một danh sách trống! Trong bước này, bạn sẽ khắc phục vấn đề đó và trình bày các đích đến đề xuất mà MainViewModel hiển thị.

66ae2543faaf2e91.png

Mở home/MainViewModel.kt để xem StateFlow suggestedDestinations, vốn được khởi tạo cho destinationsRepository.destinations và sẽ cập nhật khi các hàm updatePeople hoặc toDestinationChanged được gọi.

Bạn muốn giao diện người dùng trong thành phần kết hợp CraneHomeContent có thể cập nhật bất cứ khi nào có một mục mới được xuất vào luồng dữ liệu suggestedDestinations. Bạn có thể sử dụng hàm collectAsStateWithLifecycle(). collectAsStateWithLifecycle() thu thập các giá trị từ StateFlow và thể hiện giá trị mới nhất thông qua API State (Trạng thái) của Compose theo cách nhận biết vòng đời. Điều này sẽ khiến mã Compose đọc giá trị trạng thái đó kết hợp lại khi có một mục mới được xuất vào luồng dữ liệu.

Để bắt đầu sử dụng API collectAsStateWithLifecycle, trước tiên hãy thêm phần phụ thuộc sau đây vào app/build.gradle. Biến lifecycle_version đã được xác định trong dự án bằng phiên bản phù hợp.

dependencies {
    implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
}

Quay lại thành phần kết hợp CraneHomeContent và thay thế dòng chỉ định suggestedDestinations bằng lệnh gọi tới collectAsStateWithLifecycle trên thuộc tính suggestedDestinations của ViewModel:

import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun CraneHomeContent(
    onExploreItemClicked: OnExploreItemClicked,
    openDrawer: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: MainViewModel = viewModel(),
) {
    val suggestedDestinations by viewModel.suggestedDestinations.collectAsStateWithLifecycle()
    // ...
}

Nếu chạy ứng dụng, bạn sẽ thấy danh sách các điểm đến được điền sẵn và danh sách này sẽ thay đổi bất cứ khi nào bạn nhấn vào số hành khách.

d656748c7c583eb8.gif

5. LaunchedEffect và rememberUpdatedState

Trong dự án, hiện có một tệp home/LandingScreen.kt chưa được dùng. Bạn phải thêm màn hình đích vào ứng dụng. Màn hình này có thể được dùng để tải ngầm tất cả dữ liệu cần thiết.

Màn hình đích sẽ chiếm toàn bộ màn hình và hiện biểu trưng của ứng dụng ở giữa màn hình. Tốt nhất là bạn nên hiện màn hình đích và sau khi toàn bộ dữ liệu đã được tải xong, bạn sẽ thông báo cho phương thức gọi rằng có thể bỏ qua màn hình đích bằng cách sử dụng lệnh gọi lại onTimeout.

Bạn nên sử dụng coroutine Kotlin để thực hiện các thao tác không đồng bộ trên Android. Một ứng dụng thường sẽ sử dụng coroutine để tải ngầm mọi thứ khi ứng dụng khởi động. Jetpack Compose cung cấp các API giúp sử dụng coroutine an toàn trong lớp giao diện người dùng. Do ứng dụng này không giao tiếp với một phần phụ trợ (backend), bạn sẽ dùng hàm delay của coroutine để mô phỏng việc tải ngầm các nội dung.

Hiệu ứng phụ trong Compose là sự thay đổi về trạng thái của ứng dụng bên ngoài phạm vi một hàm có khả năng kết hợp. Việc thay đổi trạng thái để hiện/ẩn màn hình đích sẽ xảy ra trong lệnh gọi lại onTimeout và vì trước khi gọi onTimeout, bạn cần tải mọi thứ bằng coroutine, nên việc thay đổi trạng thái cần diễn ra trong ngữ cảnh coroutine!

Để gọi các chức năng có thể tạm ngưng một cách an toàn từ bên trong thành phần kết hợp, hãy sử dụng API LaunchedEffect để kích hoạt hiệu ứng phụ ở phạm vi coroutine trong Compose.

Khi LaunchedEffect nhập một Thành phần, công cụ này sẽ khởi chạy một coroutine với khối mã được truyền dưới dạng tham số. Coroutine sẽ bị huỷ nếu LaunchedEffect thoát khỏi thành phần đó.

Mặc dù mã tiếp theo không chính xác, nhưng hãy tìm hiểu cách sử dụng API này và thảo luận về lý do khiến mã sau không đúng. Bạn sẽ gọi thành phần kết hợp LandingScreen sau trong bước này.

// home/LandingScreen.kt file

import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // Start a side effect to load things in the background
        // and call onTimeout() when finished.
        // Passing onTimeout as a parameter to LaunchedEffect
        // is wrong! Don't do this. We'll improve this code in a sec.
        LaunchedEffect(onTimeout) {
            delay(SplashWaitTime) // Simulates loading things
            onTimeout()
        }
        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

Một số API có hiệu ứng phụ như LaunchedEffect sẽ lấy một số lượng biến khoá làm tham số dùng để khởi động lại hiệu ứng mỗi khi một trong các khoá đó thay đổi. Bạn đã phát hiện thấy lỗi? Chúng tôi không muốn khởi động lại LaunchedEffect nếu phương thức gọi đến hàm có khả năng kết hợp này chuyển giá trị lambda onTimeout khác. Điều đó sẽ khiến delay bắt đầu lại và bạn sẽ không đáp ứng được các yêu cầu.

Hãy khắc phục vấn đề này. Để chỉ kích hoạt hiệu ứng phụ một lần trong vòng đời của thành phần kết hợp này, hãy dùng hằng số làm khoá, ví dụ như LaunchedEffect(Unit) { ... }. Tuy nhiên, hiện có một vấn đề khác.

Nếu onTimeout thay đổi trong khi hiệu ứng phụ đang diễn ra, không có gì đảm bảo rằng onTimeout gần đây nhất sẽ được gọi khi hiệu ứng đó kết thúc. Để đảm bảo rằng onTimeout gần đây nhất được gọi, hãy ghi nhớ onTimeout bằng cách sử dụng API rememberUpdatedState. API này sẽ thu thập và cập nhật giá trị mới nhất:

// home/LandingScreen.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // This will always refer to the latest onTimeout function that
        // LandingScreen was recomposed with
        val currentOnTimeout by rememberUpdatedState(onTimeout)

        // Create an effect that matches the lifecycle of LandingScreen.
        // If LandingScreen recomposes or onTimeout changes,
        // the delay shouldn't start again.
        LaunchedEffect(Unit) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

Bạn nên sử dụng rememberUpdatedState khi một biểu thức đối tượng hoặc biểu thức lambda dài hạn tham chiếu đến các tham số/giá trị được tính toán trong quá trình kết hợp. Trường hợp này thường xảy ra khi làm việc với LaunchedEffect.

Hiện màn hình đích

Bây giờ, bạn cần hiện màn hình đích khi ứng dụng được mở. Mở tệp home/MainActivity.kt và xem thành phần kết hợp MainScreen được gọi đầu tiên.

Trong thành phần kết hợp MainScreen, bạn chỉ cần thêm trạng thái nội bộ theo dõi liệu trang đích có xuất hiện hay không:

// home/MainActivity.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
private fun MainScreen(onExploreItemClicked: OnExploreItemClicked) {
    Surface(color = MaterialTheme.colors.primary) {
        var showLandingScreen by remember { mutableStateOf(true) }
        if (showLandingScreen) {
            LandingScreen(onTimeout = { showLandingScreen = false })
        } else {
            CraneHome(onExploreItemClicked = onExploreItemClicked)
        }
    }
}

Nếu chạy ứng dụng này ngay bây giờ, bạn sẽ thấy LandingScreen xuất hiện và biến mất sau 2 giây.

e3fd932a5b95faa0.gif

6. rememberCoroutineScope

Ở bước này, bạn sẽ khiến ngăn điều hướng hoạt động. Hiện tại, sẽ không có gì xảy ra nếu bạn nhấn vào trình đơn ba đường kẻ.

Mở tệp home/CraneHome.kt và xem thành phần kết hợp CraneHome để biết bạn cần mở ngăn điều hướng ở đâu: trong lệnh gọi lại openDrawer!

Trong CraneHome, bạn có một scaffoldState chứa DrawerState. DrawerState có các phương thức để mở và đóng ngăn điều hướng theo phương thức lập trình. Tuy nhiên, nếu bạn muốn ghi scaffoldState.drawerState.open() trong lệnh gọi lại openDrawer, bạn sẽ thấy một lỗi xuất hiện! Nguyên nhân vì hàm open là hàm tạm ngưng. Chúng ta một lần nữa ở trong coroutine.

Ngoài API để giúp coroutine gọi an toàn từ lớp giao diện người dùng, một số API Compose là chức năng tạm ngưng. Một ví dụ cho trường hợp này là API để mở ngăn điều hướng. Các hàm tạm ngưng, ngoài việc có thể chạy mã không đồng bộ còn giúp trình bày các ý tưởng xảy ra theo thời gian. Do việc mở ngăn đòi hỏi thời gian, nên chuyển động và ảnh động tiềm năng cần được phản ánh hoàn hảo bằng chức năng tạm ngưng. Thao tác này sẽ tạm ngừng việc thực thi coroutine ở vị trí nó được gọi cho đến khi kết thúc và tiếp tục thực thi.

Phải gọi scaffoldState.drawerState.open() trong coroutine. Bạn có thể làm gì? openDrawer là một hàm callback đơn giản, do đó:

  • Bạn không thể gọi các hàm có thể tạm ngưng trong đó bởi openDrawer không được thực thi trong bối cảnh một coroutine.
  • Bạn không thể sử dụng LaunchedEffect như trước vì chúng ta không thể gọi các thành phần kết hợp trong openDrawer. Chúng ta không ở trong Cấu trúc (Composition).

Nếu muốn chạy một coroutine, bạn nên dùng phạm vi nào? Lý tưởng nhất là bạn nên có CoroutineScope tuân theo vòng đời của vị trí gọi. Việc sử dụng API rememberCoroutineScope sẽ trả về một CoroutineScope được liên kết với điểm trong Composition mà bạn gọi. Phạm vi sẽ tự động bị huỷ sau khi thoát khỏi Composition. Trong phạm vi đó, bạn có thể khởi động coroutine khi không ở trong Composition, chẳng hạn như trong lệnh gọi lại openDrawer.

// home/CraneHome.kt file

import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch

@Composable
fun CraneHome(
    onExploreItemClicked: OnExploreItemClicked,
    modifier: Modifier = Modifier,
) {
    val scaffoldState = rememberScaffoldState()
    Scaffold(
        scaffoldState = scaffoldState,
        modifier = Modifier.statusBarsPadding(),
        drawerContent = {
            CraneDrawer()
        }
    ) {
        val scope = rememberCoroutineScope()
        CraneHomeContent(
            modifier = modifier,
            onExploreItemClicked = onExploreItemClicked,
            openDrawer = {
                scope.launch {
                    scaffoldState.drawerState.open()
                }
            }
        )
    }
}

Nếu chạy ứng dụng này, bạn sẽ thấy ngăn điều hướng mở ra khi nhấn vào biểu tượng trình đơn ba đường kẻ.

92957c04a35e91e3.gif

LaunchedEffect so với rememberCoroutineScope

Bạn không thể sử dụng LaunchedEffect trong trường hợp này vì bạn cần kích hoạt lệnh gọi để tạo một coroutine trong lệnh gọi lại thông thường nằm ngoài Composition.

Xem lại bước trên màn hình đích đã dùng LaunchedEffect, bạn có thể sử dụng rememberCoroutineScope và gọi scope.launch { delay(); onTimeout(); } thay vì sử dụng LaunchedEffect không?

Bạn có thể làm vậy và dường như cách này có thể hiệu quả nhưng nó không đúng. Như đã giải thích trong tài liệu về Thinking in Compose (Tư duy trong Compose), bạn có thể gọi thành phần kết hợp (composable) bất cứ lúc nào. LaunchedEffect đảm bảo rằng hiệu ứng phụ này sẽ được thực thi khi lệnh gọi thành phần kết hợp (composable) khiến nó nằm trong Composition. Nếu bạn sử dụng rememberCoroutineScopescope.launch trong phần thân của LandingScreen, thì coroutine sẽ được thực thi mỗi khi LandingScreen được gọi bằng Compose bất kể lệnh gọi đó có khiến nó nằm trong Composition hay không. Do đó, bạn sẽ lãng phí tài nguyên và sẽ không thực thi hiệu ứng phụ này trong môi trường được kiểm soát.

7. Tạo phần tử giữ trạng thái

Bạn có thấy rằng nếu nhấn vào Choose Destination (Chọn điểm đến), bạn có thể chỉnh sửa trường này và lọc các thành phố dựa trên nội dung tìm kiếm đã nhập không? Bạn cũng có thể nhận thấy bất cứ khi nào bạn sửa đổi Choose Destination (Chọn điểm đến), kiểu văn bản sẽ thay đổi.

dde9ef06ca4e5191.gif

Mở tệp base/EditableUserInput.kt. Thành phần kết hợp trạng thái CraneEditableUserInput nhận được một vài tham số như hintcaption tương ứng với văn bản không bắt buộc bên cạnh biểu tượng. Ví dụ: caption To (Tới) xuất hiện khi bạn tìm kiếm một điểm đến.

// base/EditableUserInput.kt file - code in the main branch

@Composable
fun CraneEditableUserInput(
    hint: String,
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null,
    onInputChanged: (String) -> Unit
) {
    // TODO Codelab: Encapsulate this state in a state holder
    var textState by remember { mutableStateOf(hint) }
    val isHint = { textState == hint }

    ...
}

Tại sao?

Logic để cập nhật textState và xác định xem nội dung đã hiển thị có tương ứng với gợi ý hay không đều nằm ở phần thân của thành phần kết hợp (composable) CraneEditableUserInput. Điều này cũng gây ra một số nhược điểm:

  • Giá trị của TextField không được nâng nên không thể bị kiểm soát từ bên ngoài, điều này khiến việc kiểm thử khó khăn hơn.
  • Logic của thành phần kết hợp (composable) này có thể phức tạp hơn và trạng thái nội bộ có thể thoát đồng bộ dễ dàng hơn.

Bằng cách tạo trình giữ trạng thái chịu trách nhiệm về trạng thái nội bộ của thành phần kết hợp (composable) này, bạn có thể tập trung tất cả các thay đổi về trạng thái ở cùng một nơi. Với cách này, trạng thái khó bị thoát đồng bộ hơn và logic liên quan sẽ được nhóm lại với nhau trong một lớp duy nhất. Hơn nữa, trạng thái này có thể dễ dàng được nâng lên và có thể được sử dụng từ người gọi của thành phần kết hợp (composable) này.

Trong trường hợp đó, bạn nên nâng trạng thái này do đây là thành phần giao diện người dùng cấp thấp có thể được sử dụng lại trong các phần khác của ứng dụng. Do đó, càng linh hoạt và có thể kiểm soát nhiều thì càng tốt.

Tạo phần tử giữ trạng thái

Do CraneEditableUserInput là thành phần có thể sử dụng lại, hãy tạo một lớp thông thường làm phần tử giữ trạng thái có tên là EditableUserInputState trong cùng tệp như sau:

// base/EditableUserInput.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

class EditableUserInputState(private val hint: String, initialText: String) {

    var text by mutableStateOf(initialText)
       private set

    fun updateText(newText: String) {
       text = newText
    }

    val isHint: Boolean
        get() = text == hint
}

Lớp phải có các đặc điểm sau:

  • text là trạng thái khả biến thuộc loại String, tương tự như trạng thái bạn có trong CraneEditableUserInput. Quan trọng là bạn phải sử dụng mutableStateOf để Compose theo dõi các thay đổi đối với giá trị và kết hợp lại khi có thay đổi.
  • text là một var, có một set riêng tư để không thể thay đổi trực tiếp từ bên ngoài lớp. Thay vì đặt biến này ở chế độ công khai, bạn có thể hiển thị một sự kiện updateText để sửa đổi biến này, khiến lớp trở thành nguồn tin cậy duy nhất.
  • Lớp này lấy initialText làm phần phụ thuộc dùng để khởi động text.
  • Logic để biết liệu text có phải là gợi ý hay không nằm trong thuộc tính isHint thực hiện việc kiểm tra theo yêu cầu.

Nếu logic ngày một phức tạp hơn, bạn chỉ cần thực hiện các thay đổi cho một lớp: EditableUserInputState.

Ghi nhớ phần tử giữ trạng thái

Các trình giữ trạng thái luôn cần được ghi nhớ để có thể giữ chúng trong Composition và không phải lúc nào cũng tạo kênh mới. Nên tạo phương thức trong cùng một tệp để thực hiện việc này nhằm xoá bản mẫu và tránh mọi lỗi có thể xảy ra. Trong tệp base/EditableUserInput.kt, hãy thêm mã sau:

// base/EditableUserInput.kt file

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    remember(hint) {
        EditableUserInputState(hint, hint)
    }

Nếu bạn chỉ remember trạng thái này, thì trạng thái sẽ không tồn tại khi tạo lại hoạt động. Để trạng thái này tồn tại, bạn có thể dùng API rememberSaveable thay vì API hoạt động tương tự như remember. Tuy nhiên, giá trị được lưu trữ cũng vẫn tồn tại trong quá trình tạo lại hoạt động và quy trình. Về phía nội bộ, trạng thái này sẽ sử dụng cơ chế trạng thái của thực thể đã lưu.

rememberSaveable thực hiện tất cả thao tác này mà không cần phải thực hiện thêm thao tác nào cho các đối tượng có thể được lưu trữ trong Bundle. Đó không phải là trường hợp của lớp EditableUserInputState mà bạn đã tạo trong dự án. Do đó, bạn cần cho rememberSaveable biết cách lưu và khôi phục một thực thể của lớp này bằng cách sử dụng Saver.

Tạo một trình lưu tuỳ chỉnh

Saver mô tả cách chuyển đổi một đối tượng thành Saveable. Triển khai Saver cần phải ghi đè hai hàm:

  • save để chuyển đổi giá trị ban đầu thành một giá trị có thể lưu.
  • restore để chuyển đổi giá trị được khôi phục sang một thực thể của lớp ban đầu.

Đối với trường hợp này, thay vì tạo phương thức triển khai tuỳ chỉnh Saver cho lớp EditableUserInputState, bạn có thể dùng một vài API hiện có trong Compose, chẳng hạn như listSaver hoặc mapSaver (lưu trữ các giá trị để lưu vào List hoặc Map) nhằm giảm số lượng mã cần viết.

Nên đặt các định nghĩa Saver gần với lớp mà chúng tương tác. Vì cần truy cập tĩnh, nên hãy thêm Saver cho EditableUserInputState trong companion object. Trong tệp base/EditableUserInput.kt, hãy thêm phương thức triển khai Saver:

// base/EditableUserInput.kt file

import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver

class EditableUserInputState(private val hint: String, initialText: String) {
    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint

    companion object {
        val Saver: Saver<EditableUserInputState, *> = listSaver(
            save = { listOf(it.hint, it.text) },
            restore = {
                EditableUserInputState(
                    hint = it[0],
                    initialText = it[1],
                )
            }
        )
    }
}

Trong trường hợp này, bạn sẽ dùng listSaver làm chi tiết triển khai để lưu trữ và khôi phục một thực thể của EditableUserInputState trong trình lưu.

Bây giờ, bạn có thể sử dụng trình lưu này trong rememberSaveable (thay vì remember) trong phương thức rememberEditableUserInputState mà bạn tạo trước đây:

// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.rememberSaveable

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    rememberSaveable(hint, saver = EditableUserInputState.Saver) {
        EditableUserInputState(hint, hint)
    }

Bằng cách này, trạng thái đã ghi nhớ EditableUserInput sẽ vẫn tồn tại khi tạo lại quy trình và hoạt động.

Sử dụng phần tử giữ trạng thái

Bạn sẽ sử dụng EditableUserInputState thay vì textisHint, nhưng bạn không muốn chỉ sử dụng nó làm trạng thái nội bộ trong CraneEditableUserInput do thành phần kết hợp của phương thức gọi không thể kiểm soát trạng thái. Thay vào đó, bạn muốn chuyển EditableUserInputState lên trên để các phương thức gọi có thể kiểm soát trạng thái của CraneEditableUserInput. Nếu chuyển trạng thái lên trên thì thành phần kết hợp có thể được sử dụng ở chế độ xem trước và sẽ được kiểm thử dễ dàng hơn vì bạn có thể sửa đổi trạng thái từ phương thức gọi.

Để thực hiện việc này, bạn cần thay đổi các tham số của hàm có khả năng kết hợp và cung cấp giá trị mặc định nếu cần. Do có thể bạn sẽ muốn để cho CraneEditableUserInput có gợi ý trống, nên hãy thêm một đối số mặc định:

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) { /* ... */ }

Có thể bạn đã nhận thấy rằng tham số onInputChanged không còn tồn tại! Do trạng thái có thể được chuyển lên, nên nếu phương thức gọi muốn biết liệu đầu vào có thay đổi hay không, chúng có thể kiểm soát trạng thái và chuyển trạng thái đó vào hàm này.

Tiếp theo, bạn cần tinh chỉnh phần nội dung hàm để sử dụng trạng thái đã nâng thay vì trạng thái nội bộ đã dùng trước đó. Sau khi được cải tiến cấu trúc, hàm sẽ có dạng như sau:

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) {
    CraneBaseUserInput(
        caption = caption,
        tintIcon = { !state.isHint },
        showCaption = { !state.isHint },
        vectorImageId = vectorImageId
    ) {
        BasicTextField(
            value = state.text,
            onValueChange = { state.updateText(it) },
            textStyle = if (state.isHint) {
                captionTextStyle.copy(color = LocalContentColor.current)
            } else {
                MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
            },
            cursorBrush = SolidColor(LocalContentColor.current)
        )
    }
}

Phương thức gọi của phần tử giữ trạng thái

Vì bạn đã thay đổi API của CraneEditableUserInput, nên bạn cần kiểm tra tất cả các vị trí mà API đó được gọi để đảm bảo truyền đúng tham số.

Vị trí duy nhất trong dự án mà bạn gọi API này là trong tệp home/SearchUserInput.kt. Mở tệp đó và chuyển đến hàm có khả năng kết hợp ToDestinationUserInput, bạn sẽ thấy lỗi bản dựng ở đó. Do gợi ý hiện là một phần của phần tử giữ trạng thái và bạn muốn gợi ý tuỳ chỉnh cho thực thể này của CraneEditableUserInput trong Composition, nên bạn sẽ cần ghi nhớ trạng thái ở cấp độ ToDestinationUserInput và chuyển trạng thái đó vào CraneEditableUserInput:

// home/SearchUserInput.kt file

import androidx.compose.samples.crane.base.rememberEditableUserInputState

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )
}

snapshotFlow

Mã ở trên thiếu chức năng thông báo cho phương thức gọi của ToDestinationUserInput khi đầu vào thay đổi. Do cấu trúc của ứng dụng nên bạn không muốn chuyển EditableUserInputState lên cấp cao hơn trong hệ phân cấp. Bạn không muốn ghép các thành phần kết hợp khác như FlySearchContent với trạng thái này. Bạn có thể gọi hàm lambda onToDestinationChanged từ ToDestinationUserInput như thế nào để vẫn có thể sử dụng lại thành phần kết hợp này?

Bạn có thể kích hoạt hiệu ứng phụ bằng LaunchedEffect mỗi khi thay đổi dữ liệu đầu vào và gọi hàm lambda onToDestinationChanged:

// home/SearchUserInput.kt file

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )

    val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
    LaunchedEffect(editableUserInputState) {
        snapshotFlow { editableUserInputState.text }
            .filter { !editableUserInputState.isHint }
            .collect {
                currentOnDestinationChanged(editableUserInputState.text)
            }
    }
}

Bạn đã sử dụng LaunchedEffectrememberUpdatedState trước đó, nhưng mã ở trên cũng sử dụng API mới! API snapshotFlow chuyển đổi các đối tượng State<T> trong Compose thành Flow (Luồng). Khi thông số trạng thái bên trong snapshotFlow thay đổi, Flow (Luồng) sẽ tạo ra giá trị mới cho trình thu thập. Trong trường hợp này, bạn chuyển đổi trạng thái thành một luồng để sử dụng sức mạnh của các toán tử luồng. Bằng cách đó, bạn filter khi text không phải là hintcollect các mục được tạo ra để thông báo cho mục chính rằng điểm đến hiện tại đã thay đổi.

Không có sự thay đổi nào về hình ảnh trong bước này của lớp học lập trình, nhưng bạn đã cải thiện chất lượng của phần mã này. Nếu chạy ứng dụng ngay bây giờ, bạn sẽ thấy mọi thứ hoạt động như trước đây.

8. DisposableEffect

Khi nhấn vào một điểm đến, màn hình chi tiết sẽ mở ra và bạn có thể thấy vị trí của thành phố trên bản đồ. Mã đó nằm trong tệp details/DetailsActivity.kt. Trong thành phần kết hợp CityMapView, bạn đang gọi hàm rememberMapViewWithLifecycle. Nếu mở hàm này (vốn nằm trong tệp details/MapViewUtils.kt), thì bạn sẽ thấy hàm không được kết nối với bất kỳ vòng đời nào! Chrome chỉ ghi nhớ MapView và gọi onCreate trên đó:

// details/MapViewUtils.kt file - code in the main branch

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    // TODO Codelab: DisposableEffect step. Make MapView follow the lifecycle
    return remember {
        MapView(context).apply {
            id = R.id.map
            onCreate(Bundle())
        }
    }
}

Mặc dù ứng dụng chạy bình thường, nhưng đây là một vấn đề do MapView không tuân theo đúng vòng đời. Do đó, bạn sẽ không biết khi nào ứng dụng chuyển sang chạy ngầm, khi nào nên tạm dừng View (Chế độ xem), v.v. Hãy cùng khắc phục lỗi này!

MapView là một Khung hiển thị chứ không phải thành phần kết hợp nên bạn muốn nó tuân theo vòng đời của Hoạt động mà theo đó nó được sử dụng thay vì vòng đời của Composition. Điều đó nghĩa là bạn cần tạo LifecycleEventObserver để theo dõi các sự kiện trong vòng đời và gọi các phương thức phù hợp trên MapView. Sau đó, bạn cần thêm trình quan sát này vào vòng đời của hoạt động hiện tại.

Bắt đầu bằng cách tạo một hàm trả về LifecycleEventObserver để gọi các phương thức tương ứng trong một MapView dựa trên một sự kiện nhất định:

// details/MapViewUtils.kt file

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver

private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
    LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
            Lifecycle.Event.ON_START -> mapView.onStart()
            Lifecycle.Event.ON_RESUME -> mapView.onResume()
            Lifecycle.Event.ON_PAUSE -> mapView.onPause()
            Lifecycle.Event.ON_STOP -> mapView.onStop()
            Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
            else -> throw IllegalStateException()
        }
    }

Bây giờ, bạn cần thêm trình quan sát này vào vòng đời hiện tại để có thể sử dụng LifecycleOwner hiện tại với cấu trúc (composition) LocalLifecycleOwner cục bộ. Tuy nhiên, việc thêm trình quan sát là vẫn chưa đủ, bạn còn cần phải xoá nó! Bạn cần có một hiệu ứng phụ cho biết thời điểm hiệu ứng đó rời khỏi Composition này để bạn có thể thực thi mã dọn dẹp nào đó. API hiệu ứng phụ mà bạn đang tìm kiếm là DisposableEffect.

DisposableEffect dành cho các hiệu ứng phụ cần được dọn dẹp sau khi các khoá thay đổi hoặc thành phần kết hợp rời khỏi Composition. Mã rememberMapViewWithLifecycle cuối cùng sẽ hoạt động chính xác như vậy. Triển khai các dòng sau trong dự án của bạn:

// details/MapViewUtils.kt file

import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    val mapView = remember {
        MapView(context).apply {
            id = R.id.map
        }
    }

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(key1 = lifecycle, key2 = mapView) {
        // Make MapView follow the current lifecycle
        val lifecycleObserver = getMapLifecycleObserver(mapView)
        lifecycle.addObserver(lifecycleObserver)
        onDispose {
            lifecycle.removeObserver(lifecycleObserver)
        }
    }

    return mapView
}

Trình quan sát được thêm vào lifecycle hiện tại và sẽ bị xoá khi vòng đời hiện tại thay đổi hoặc thành phần kết hợp này rời khỏi Composition (Cấu trúc). Với key trong DisposableEffect, nếu lifecycle hoặc mapView thay đổi, trình quan sát sẽ bị xoá rồi thêm lại vào lifecycle bên phải.

Với những thay đổi bạn vừa thực hiện, MapView sẽ luôn tuân theo lifecycle của LifecycleOwner hiện tại và hành vi của nó sẽ giống như khi được sử dụng trong môi trường Khung hiển thị này.

Hãy thoải mái chạy ứng dụng này và mở màn hình chi tiết để đảm bảo MapView vẫn hiển thị chính xác. Không có thay đổi nào về hình ảnh trong bước này.

9. produceState

Trong phần này, bạn sẽ cải thiện cách màn hình chi tiết khởi động. Thành phần kết hợp DetailsScreen trong tệp details/DetailsActivity.kt sẽ nhận cityDetails một cách đồng bộ từ ViewModel và gọi DetailsContent nếu kết quả thành công.

Tuy nhiên, cityDetails có thể sẽ tốn nhiều phí tải hơn trên luồng giao diện người dùng và có thể sử dụng coroutine để chuyển dữ liệu sang một luồng khác. Bạn sẽ cải thiện mã này để thêm một màn hình tải và hiển thị DetailsContent khi dữ liệu đã sẵn sàng.

Một cách để mô hình hoá trạng thái màn hình là sử dụng lớp sau đây, trong đó bao hàm mọi khả năng: dữ liệu cần hiển thị trên màn hình cũng như tín hiệu tải và tín hiệu lỗi. Thêm lớp DetailsUiState vào tệp DetailsActivity.kt:

// details/DetailsActivity.kt file

data class DetailsUiState(
    val cityDetails: ExploreModel? = null,
    val isLoading: Boolean = false,
    val throwError: Boolean = false
)

Bạn có thể liên kết những nội dung mà màn hình cần hiển thị và UiState trong lớp ViewModel bằng cách sử dụng luồng dữ liệu, StateFlow thuộc loại DetailsUiState mà ViewModel cập nhật khi thông tin đã sẵn sàng và nội dung mà Compose thu thập bằng API collectAsStateWithLifecycle() bạn đã biết.

Tuy nhiên, để thực hiện bài tập này, bạn sẽ triển khai một phương án thay thế. Nếu muốn di chuyển logic liên kết uiState sang môi trường Compose, bạn có thể sử dụng API produceState.

produceState cho phép bạn chuyển đổi trạng thái không phải Compose thành Trạng thái Compose. Tệp này sẽ khởi chạy một coroutine trong phạm vi Composition, coroutine này có thể đẩy các giá trị vào State được trả về bằng thuộc tính value. Tương tự như LaunchedEffect, produceState cũng lấy các khoá để huỷ và khởi động lại việc tính toán.

Trong trường hợp sử dụng của bạn, bạn có thể sử dụng produceState để phát hành các bản cập nhật uiState có giá trị ban đầu là DetailsUiState(isLoading = true) như sau:

// details/DetailsActivity.kt file

import androidx.compose.runtime.produceState

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {

    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        // In a coroutine, this can call suspend functions or move
        // the computation to different Dispatchers
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    // TODO: ...
}

Tiếp theo, tuỳ thuộc vào uiState, bạn sẽ cho hiện dữ liệu, màn hình tải hay báo cáo lỗi. Dưới đây là mã hoàn chỉnh cho thành phần kết hợp DetailsScreen:

// details/DetailsActivity.kt file

import androidx.compose.foundation.layout.Box
import androidx.compose.material.CircularProgressIndicator

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {
    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    when {
        uiState.cityDetails != null -> {
            DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
        }
        uiState.isLoading -> {
            Box(modifier.fillMaxSize()) {
                CircularProgressIndicator(
                    color = MaterialTheme.colors.onSurface,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
        else -> { onErrorLoading() }
    }
}

Nếu chạy ứng dụng thì bạn sẽ thấy cách vòng quay đang tải xuất hiện trước khi cho thấy thông tin chi tiết về thành phố.

aa8fd1ac660266e9.gif

10. derivedStateOf

Cải tiến cuối cùng bạn sẽ thực hiện với ứng dụng Crane là hiện nút Scroll to top (Di chuyển lên đầu) bất cứ khi nào bạn di chuyển trong danh sách điểm đến của chuyến bay sau khi truyền phần tử đầu tiên của màn hình. Khi nhấn vào nút đó, bạn sẽ được chuyển đến phần tử đầu tiên trong danh sách.

2c112d73f48335e0.gif

Mở tệp base/ExploreSection.kt chứa mã này. Thành phần kết hợp ExploreSection tương ứng với những gì bạn thấy trong nền của scaffold (giàn giáo).

Để tính toán xem liệu người dùng đã truyền mục đầu tiên hay chưa, hãy sử dụng LazyListState của LazyColumn và kiểm tra xem có phải là listState.firstVisibleItemIndex > 0 hay không.

Cách triển khai đơn thuần sẽ có dạng như sau:

// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0

Giải pháp này không hiệu quả như mọi khi vì hàm có khả năng kết hợp đọc showButton sẽ kết hợp lại mỗi khi firstVisibleItemIndex thay đổi – điều này xảy ra thường xuyên khi cuộn. Thay vào đó, bạn muốn hàm này chỉ kết hợp lại khi điều kiện thay đổi giữa truefalse.

Có một API cho phép bạn làm việc này, đó là: API derivedStateOf.

listState là một State Compose có thể quan sát. Theo tính toán của bạn, showButton cũng cần phải là State Compose vì bạn muốn giao diện người dùng kết hợp lại khi giá trị của giao diện đó thay đổi và hiện hoặc ẩn nút này.

Hãy sử dụng derivedStateOf khi bạn muốn State Compose được lấy từ State khác. Khối tính toán derivedStateOf sẽ được thực thi mỗi khi trạng thái nội bộ thay đổi, nhưng hàm có khả năng kết hợp chỉ kết hợp lại khi kết quả tính toán khác với kết quả sau cùng. Việc này giúp giảm thiểu số lần các hàm đọc showButton kết hợp lại.

Trong trường hợp này, việc sử dụng API derivedStateOf là giải pháp thay thế tốt hơn và hiệu quả hơn. Bạn cũng gói lệnh gọi bằng API remember, vì vậy, giá trị đã tính vẫn tồn tại sau khi cấu trúc lại.

// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary recompositions
val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

Mã mới cho thành phần kết hợp ExploreSection chắc hẳn quen thuộc với bạn. Bạn sẽ dùng Box để đặt Button được hiện một cách có điều kiện lên đầu ExploreList. Và bạn dùng rememberCoroutineScope để gọi hàm có thể tạm ngưng listState.scrollToItem trong lệnh gọi onClick của Button.

// base/ExploreSection.kt file

import androidx.compose.material.FloatingActionButton
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.foundation.layout.navigationBarsPadding
import kotlinx.coroutines.launch

@Composable
fun ExploreSection(
    modifier: Modifier = Modifier,
    title: String,
    exploreList: List<ExploreModel>,
    onItemClicked: OnExploreItemClicked
) {
    Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) {
        Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) {
            Text(
                text = title,
                style = MaterialTheme.typography.caption.copy(color = crane_caption)
            )
            Spacer(Modifier.height(8.dp))
            Box(Modifier.weight(1f)) {
                val listState = rememberLazyListState()
                ExploreList(exploreList, onItemClicked, listState = listState)

                // Show the button if the first visible item is past
                // the first item. We use a remembered derived state to
                // minimize unnecessary compositions
                val showButton by remember {
                    derivedStateOf {
                        listState.firstVisibleItemIndex > 0
                    }
                }
                if (showButton) {
                    val coroutineScope = rememberCoroutineScope()
                    FloatingActionButton(
                        backgroundColor = MaterialTheme.colors.primary,
                        modifier = Modifier
                            .align(Alignment.BottomEnd)
                            .navigationBarsPadding()
                            .padding(bottom = 8.dp),
                        onClick = {
                            coroutineScope.launch {
                                listState.scrollToItem(0)
                            }
                        }
                    ) {
                        Text("Up!")
                    }
                }
            }
        }
    }
}

Nếu chạy ứng dụng, bạn sẽ thấy nút xuất hiện ở dưới cùng sau khi bạn di chuyển và truyền phần tử đầu tiên của màn hình.

11. Xin chúc mừng!

Xin chúc mừng, bạn đã hoàn tất thành công lớp học lập trình này và tìm hiểu các khái niệm nâng cao về API trạng thái và hiệu ứng phụ trong ứng dụng Jetpack Compose!

Bạn đã tìm hiểu cách tạo trình giữ trạng thái, API hiệu ứng phụ như LaunchedEffect, rememberUpdatedState, DisposableEffect, produceStatederivedStateOf cũng như cách sử dụng coroutine trong Jetpack Compose.

Tiếp theo là gì?

Hãy xem các lớp học lập trình khác trên Lộ trình Compose và các mã mẫu khác bao gồm Crane.

Tài liệu

Để biết thêm thông tin và hướng dẫn về những chủ đề này, vui lòng xem các tài liệu sau: