Xây dựng bố cục thích ứng

Giao diện người dùng cho ứng dụng phải thích ứng để phù hợp với nhiều kích thước màn hình, hướng và kiểu dáng. Bố cục thích ứng thay đổi dựa trên không gian màn hình hiện có. Những thay đổi này bao gồm từ những điều chỉnh bố cục đơn giản cho đến việc lấp đầy không gian, cũng như việc thay đổi hoàn toàn bố cục để tận dụng thêm chỗ trống.

Là một bộ công cụ giao diện người dùng mang tính khai báo, Jetpack Compose rất phù hợp để thiết kế và triển khai các bố cục tự điều chỉnh giúp hiển thị nội dung trên nhiều kích cỡ. Tài liệu này bao gồm một số nguyên tắc về cách bạn có thể sử dụng Compose để tạo tính thích ứng cho giao diện người dùng.

Thực hiện các thay đổi lớn về bố cục để thành phần kết hợp cấp màn hình trở nên rõ ràng

Khi sử dụng Compose để tạo bố cục cho toàn bộ ứng dụng, các thành phần kết hợp cấp ứng dụng và cấp màn hình sẽ chiếm toàn bộ không gian hiển thị dành cho ứng dụng. Ở cấp độ này trong thiết kế, bạn có thể thay đổi bố cục tổng thể của màn hình để tận dụng những màn hình lớn hơn.

Tránh sử dụng giá trị vật lý của phần cứng để đưa ra quyết định về bố cục. Có thể bạn sẽ muốn đưa ra các quyết định dựa trên một giá trị hữu hình cố định (Thiết bị đó có phải là máy tính bảng không? Màn hình thực có tỷ lệ khung hình nhất định không?), nhưng câu trả lời cho các câu hỏi này có thể không hữu ích trong việc xác định không gian giao diện người dùng khả thi.

Một sơ đồ cho thấy một số kiểu dáng thiết bị – điện thoại, thiết bị có thể gập lại, máy tính bảng và máy tính xách tay

Trên máy tính bảng, ứng dụng có thể chạy ở chế độ nhiều cửa sổ, nghĩa là ứng dụng đó có thể đang chia đôi màn hình với một ứng dụng khác. Trên ChromeOS, ứng dụng có thể ở trong một cửa sổ có thể đổi kích thước. Thậm chí có thể có nhiều hơn một màn hình vật lý, chẳng hạn như với thiết bị gập. Trong tất cả những trường hợp này, kích thước màn hình vật lý không liên quan đến việc quyết định cách hiển thị nội dung.

Thay vào đó, bạn nên quyết định dựa trên phần màn hình thực tế được phân bổ cho ứng dụng, chẳng hạn như các chỉ số về cửa sổ hiện tại do thư viện JetPack WindowManager cung cấp. Để xem cách sử dụng WindowManager trong ứng dụng Compose, hãy xem mẫu JetNews.

Phương pháp này giúp ứng dụng của bạn linh hoạt hơn vì ứng dụng sẽ hoạt động tốt trong tất cả trường hợp nêu trên. Việc làm cho bố cục thích ứng với không gian màn hình cũng giúp giảm bớt khối lượng xử lý đặc biệt để hỗ trợ các nền tảng như ChromeOS cũng như các kiểu dáng như máy tính bảng và thiết bị có thể gập lại.

Khi quan sát không gian phù hợp hiện có cho ứng dụng, bạn nên chuyển đổi kích thước thô sang lớp kích thước có ý nghĩa, như mô tả trong phần Lớp kích thước cửa sổ. Tại đây, kích thước được nhóm thành các nhóm kích thước chuẩn. Đây là các điểm ngắt được thiết kế để cân bằng giữa sự đơn giản và tính linh hoạt giúp tối ưu hoá ứng dụng trong hầu hết trường hợp. Các lớp kích thước này tham chiếu đến cửa sổ tổng thể của ứng dụng, vì vậy, hãy sử dụng chúng cho các quyết định bố cục ảnh hưởng đến bố cục màn hình tổng thể của bạn. Bạn có thể chuyển các lớp kích thước này xuống dưới dạng trạng thái hoặc thực hiện logic bổ sung để tạo trạng thái dẫn xuất nhằm chuyển xuống những thành phần kết hợp được lồng.

enum class WindowSizeClass { Compact, Medium, Expanded }

@Composable
fun MyApp(windowSizeClass: WindowSizeClass) {
    // Perform logic on the size class to decide whether to show
    // the top app bar.
    val showTopAppBar = windowSizeClass != WindowSizeClass.Compact

    // MyScreen knows nothing about window sizes, and performs logic
    // based on a Boolean flag.
    MyScreen(
        showTopAppBar = showTopAppBar,
        /* ... */
    )
}

Phương thức phân lớp này giới hạn logic kích thước màn hình ở một vị trí duy nhất, thay vì phân tán chúng trên ứng dụng ở những vị trí cần phải được đồng bộ hoá. Vị trí duy nhất này tạo ra trạng thái có thể được chuyển xuống rõ ràng cho các thành phần kết hợp khác, giống như cách bạn làm với bất kỳ trạng thái ứng dụng nào khác. Việc chuyển trạng thái một cách rõ ràng sẽ đơn giản hoá các thành phần kết hợp riêng lẻ, vì chúng sẽ chỉ là các hàm có khả năng kết hợp thông thường với lớp kích thước hoặc cấu hình được chỉ định cùng với dữ liệu khác.

Có thể sử dụng lại các thành phần kết hợp được lồng linh hoạt

Các thành phần kết hợp dễ tái sử dụng hơn khi chúng được đặt trong nhiều vị trí khác nhau. Giả định một thành phần kết hợp sẽ luôn được đặt ở một vị trí cụ thể với kích thước cụ thể, thì khó mà sử dụng lại ở nơi khác trong một vị trí khác hoặc với những không gian còn trống khác. Điều này cũng có nghĩa là các thành phần kết hợp riêng lẻ, có thể sử dụng lại nên tránh ngầm phụ thuộc vào thông tin kích thước "chung".

Hãy xem xét ví dụ sau: Giả sử một thành phần kết hợp được lồng chạy một bố cục chi tiết danh sách, vốn có thể hiển thị một ô hoặc hai ô cạnh nhau.

Ảnh chụp màn hình một ứng dụng hiển thị hai ô cạnh nhau

Ảnh chụp màn hình của ứng dụng hiển thị bố cục chi tiết/danh sách thông thường. 1 là vùng danh sách, 2 là vùng chi tiết.

Chúng ta muốn quyết định này trở thành một phần của bố cục tổng thể cho ứng dụng, nên chúng ta chuyển quyết định này xuống từ thành phần kết hợp cấp màn hình như đã thấy ở trên:

@Composable
fun AdaptivePane(
    showOnePane: Boolean,
    /* ... */
) {
    if (showOnePane) {
        OnePane(/* ... */)
    } else {
        TwoPane(/* ... */)
    }
}

Nếu chúng ta muốn một thành phần kết hợp độc lập thay đổi bố cục của nó dựa trên không gian có sẵn thì sao? Ví dụ: một thẻ muốn hiển thị thêm thông tin chi tiết nếu không gian cho phép. Chúng tôi muốn thực hiện một số logic dựa trên kích thước có sẵn, nhưng cụ thể là kích thước nào?

Ví dụ về 2 thẻ riêng biệt: một thẻ hẹp chỉ hiển thị biểu tượng và tiêu đề và một thẻ rộng hơn hiển thị biểu tượng, tiêu đề và mô tả ngắn

Như đã thấy ở trên, chúng ta nên tránh sử dụng kích thước màn hình thực tế của thiết bị. Chỉ số này sẽ không chính xác cho nhiều màn hình và cũng sẽ không chính xác nếu ứng dụng không ở chế độ toàn màn hình.

Vì thành phần kết hợp này không phải là thành phần kết hợp cấp màn hình, nên chúng ta cũng không nên sử dụng trực tiếp các chỉ số cửa sổ hiện tại để tối đa hoá khả năng sử dụng lại. Nếu thành phần này được đặt với khoảng đệm (chẳng hạn như phần lồng ghép) hoặc nếu có các thành phần như thanh điều hướng hoặc thanh ứng dụng, thì khoảng không gian còn trống cho thành phần kết hợp này có thể khác đáng kể so với tổng thể không gian còn trống cho ứng dụng.

Do đó, chúng ta nên sử dụng chiều rộng không gian được cấp cho thành phần kết hợp để hiển thị. Chúng ta có hai tuỳ chọn để lấy chiều rộng đó:

Nếu bạn muốn thay đổivị trí hoặccách thức nội dung được hiển thị, bạn có thể sử dụng bộ sưu tập công cụ sửa đổi hoặcbố cục tuỳ chỉnh để thích ứng hoá bố cục. Điều này có thể đơn giản như việc lấp đầy toàn bộ không gian còn trống bằng các tập con, hoặc sắp xếp chúng theo nhiều cột nếu có đủ chỗ.

Nếu muốn thay đổi nội dung hiển thị, bạn có thể sử dụng BoxWithConstraints như một giải pháp thay thế hiệu quả hơn. Thành phần kết hợp này cung cấp các giới hạn đo lường mà bạn có thể sử dụng để gọi nhiều thành phần kết hợp dựa trên không gian còn trống. Tuy nhiên, điều này cũng đi kèm một số phí tổn, do BoxWithConstraints trì hoãn sự kết hợp tới giai đoạn Bố cục (Layout), khi những điểm hạn chế này được biết đến, làm tăng khối lượng công việc cần thực hiện trong bố cục.

@Composable
fun Card(/* ... */) {
    BoxWithConstraints {
        if (maxWidth < 400.dp) {
            Column {
                Image(/* ... */)
                Title(/* ... */)
            }
        } else {
            Row {
                Column {
                    Title(/* ... */)
                    Description(/* ... */)
                }
                Image(/* ... */)
            }
        }
    }
}

Đảm bảo tất cả dữ liệu đều có sẵn cho nhiều kích thước

Khi tận dụng không gian màn hình bổ sung trên một màn hình lớn, bạn có thể hiển thị nhiều nội dung tới khách hàng hơn so với một màn hình nhỏ. Khi triển khai một thành phần kết hợp tương ứng với hành vi này, có thể muốn tải dữ liệu như là hiệu ứng phụ của kích thước hiện tại.

Tuy nhiên, điều này đi ngược lại nguyên tắc về luồng dữ liệu một chiều, trong đó dữ liệu có thể được chuyển lên trên và đưa tới các thành phần kết hợp để hiển thị một cách thích hợp. Bạn cần cung cấp đủ dữ liệu cho các thành phần kết hợp để các cấu trúc đó luôn có nội dung cần hiển thị trên mọi kích thước, ngay cả khi một số phần dữ liệu không phải lúc nào cũng được sử dụng.

@Composable
fun Card(
    imageUrl: String,
    title: String,
    description: String
) {
    BoxWithConstraints {
        if (maxWidth < 400.dp) {
            Column {
                Image(imageUrl)
                Title(title)
            }
        } else {
            Row {
                Column {
                    Title(title)
                    Description(description)
                }
                Image(imageUrl)
            }
        }
    }
}

Dựa trên ví dụ về Card, hãy lưu ý rằng chúng ta luôn chuyển description cho Card. Mặc dù description chỉ được sử dụng khi chiều rộng cho phép hiển thị, nhưng Card luôn yêu cầu có nó, bất kể chiều rộng có sẵn nào.

Việc luôn chuyển dữ liệu giúp bố cục thích ứng trở nên đơn giản hơn bằng cách hiển thị bố cục ít trạng thái hơn và tránh kích hoạt hiệu ứng phụ khi chuyển đổi giữa các kích thước (điều có thể xảy ra do thay đổi kích thước cửa sổ, thay đổi hướng hoặc gấp và mở màn hình thiết bị).

Nguyên tắc này cũng cho phép duy trì trạng thái trên các thay đổi của bố cục. Bằng cách chuyển lên trên các thông tin có thể không được sử dụng ở mọi kích thước, chúng ta có thể bảo toàn trạng thái của người dùng khi kích thước bố cục thay đổi. Ví dụ: chúng ta có thể chuyển một cờ Boolean showMore lên trên để trạng thái của người dùng được giữ nguyên khi việc thay đổi kích thước khiến bố cục bị chuyển đổi qua lại giữa ẩn và hiện phần mô tả:

@Composable
fun Card(
    imageUrl: String,
    title: String,
    description: String
) {
    var showMore by remember { mutableStateOf(false) }

    BoxWithConstraints {
        if (maxWidth < 400.dp) {
            Column {
                Image(imageUrl)
                Title(title)
            }
        } else {
            Row {
                Column {
                    Title(title)
                    Description(
                        description = description,
                        showMore = showMore,
                        onShowMoreToggled = { newValue ->
                            showMore = newValue
                        }
                    )
                }
                Image(imageUrl)
            }
        }
    }
}

Tìm hiểu thêm

Để tìm hiểu thêm về bố cục tuỳ chỉnh trong Compose, hãy tham khảo thêm các tài nguyên sau.

Ứng dụng mẫu

  • JetNews trình bày cách thiết kế một ứng dụng điều chỉnh giao diện người dùng cho phù hợp để tận dụng không gian có sẵn

Video