Hỗ trợ thiết bị có thể gập lại và thiết bị màn hình đôi nhờ Jetpack WindowManager

1. Trước khi bắt đầu

Lớp học thực hành lập trình này sẽ hướng dẫn bạn các kiến thức cơ bản về cách phát triển cho thiết bị màn hình đôi và thiết bị có thể gập lại. Khi bạn hoàn tất, ứng dụng có thể hỗ trợ các thiết bị có thể gập lại như Pixel Fold, Microsoft Surface Duo, Samsung Galaxy Z Fold 5, v.v.

Điều kiện tiên quyết

Để hoàn tất lớp học lập trình này, bạn cần:

Bạn sẽ thực hiện

Tạo một ứng dụng đơn giản có các đặc điểm sau:

  • Hiện các tính năng của thiết bị
  • Phát hiện khi ứng dụng đang chạy trên thiết bị có thể gập lại hoặc thiết bị màn hình đôi
  • Xác định trạng thái thiết bị
  • Sử dụng Jetpack WindowManager để xử lý trên các thiết bị có kiểu dáng mới.

Bạn cần có

Trình mô phỏng Android v30.0.6 trở lên có hỗ trợ thiết bị có thể gập lại có cảm biến bản lề ảo và chế độ xem 3D. Bạn có thể sử dụng một số trình mô phỏng thiết bị có thể gập lại nêu trong hình dưới đây:

ca76200cc00b6ce6.png

  • Nếu muốn sử dụng trình mô phỏng thiết bị màn hình đôi, bạn có thể tải trình mô phỏng Microsoft Surface Duo xuống nền tảng của mình (Windows, MacOS hoặc GNU/Linux).

2. So sánh thiết bị màn hình đơn với thiết bị có thể gập lại

Thiết bị có thể gập lại mang đến cho người dùng màn hình lớn hơn và giao diện người dùng linh hoạt hơn trên thiết bị di động so với trước đây. Khi được gập lại, các thiết bị này thường nhỏ hơn một máy tính bảng kích thước thông thường, giúp thiết bị dễ mang đi và hoạt động tốt hơn.

Tại thời điểm chúng tôi viết bài viết này, có 2 loại thiết bị có thể gập lại:

  • Thiết bị có thể gập lại màn hình đơn, với một màn hình gập được. Người dùng có thể chạy đồng thời nhiều ứng dụng trên cùng một màn hình bằng chế độ multi-window.
  • Thiết bị có thể gập lại màn hình đôi, với hai màn hình được nối bằng bản lề. Các thiết bị này cũng gập lại được, nhưng 2 khu vực màn hình đó lại có logic khác nhau.

9ff347a7c8483fed.png

Giống như máy tính bảng và thiết bị di động màn hình đơn khác, thiết bị có thể gập lại có khả năng:

  • Chạy một ứng dụng ở một trong các khu vực hiển thị.
  • Chạy hai ứng dụng cạnh nhau, mỗi ứng dụng trên một khu vực hiển thị riêng (sử dụng chế độ multi-window).

Không giống như các thiết bị màn hình đơn, thiết bị có thể gập lại hỗ trợ nhiều tư thế. Nội dung sẽ được trình bày theo nhiều cách khác nhau tuỳ vào tư thế mà bạn dùng.

bac1d8089687c0c2.png

Thiết bị có thể gập lại có khả năng hỗ trợ nhiều kiểu tư thế trải rộng khi ứng dụng trải rộng (hiển thị) trên toàn bộ khu vực hiển thị (sử dụng mọi khu vực hiển thị trên thiết bị có thể gập lại màn hình đôi).

Thiết bị có thể gập lại cũng có thể hỗ trợ các tư thế gập, chẳng hạn như chế độ trên mặt bàn để bạn có thể có một bố cục hợp lý giữa phần màn hình phẳng và phần màn hình nghiêng về phía mình hay chế độ lều để bạn có thể xem nội dung như đang dùng một phụ kiện chân đế cho thiết bị.

3. Jetpack WindowManager

Thư viện Jetpack WindowManager giúp các nhà phát triển ứng dụng hỗ trợ các kiểu dáng thiết bị mới, đồng thời cung cấp một giao diện API phổ biến cho nhiều tính năng của WindowManager trên cả phiên bản nền tảng cũ và mới.

Tính năng chính

Jetpack WindowManager phiên bản 1.0.0 chứa lớp FoldingFeature mô tả nếp gập trong một màn hình linh hoạt hoặc là bản lề giữa hai bảng màn hình thực. API tương ứng sẽ cấp quyền truy cập vào các thông tin quan trọng liên quan đến thiết bị:

  • state(): Cho biết tư thế hiện tại của thiết bị trong danh sách các tư thế đã xác định ( FLATHALF_OPENED)
  • isSeparating(): Tính toán xem có nên coi FoldingFeature là hình thức chia cửa sổ thành nhiều khu vực thực tế để người dùng xem theo các logic riêng biệt hay không
  • occlusionType(): Tính toán chế độ che kín để xác định xem FoldingFeature có chiếm một phần của cửa sổ hay không.
  • orientation(): Trả về FoldingFeature.Orientation.HORIZONTAL nếu chiều rộng FoldingFeature lớn hơn chiều cao; nếu không thì trả về FoldingFeature.Orientation.VERTICAL.
  • bounds(): Cung cấp một phiên bản Rect chứa các ranh giới của đặc điểm thiết bị, ví dụ như ranh giới của bản lề thực.

Khi sử dụng giao diện WindowInfoTracker, bạn có thể truy cập windowLayoutInfo() để thu thập Flow về WindowLayoutInfo có chứa tất cả DisplayFeature hiện có.

4. Thiết lập

Tạo một dự án mới rồi chọn mẫu "Empty Activity" (Chưa có hoạt động):

a5ce5c7fb033ec4c.png

Giữ nguyên tất cả tham số theo mặc định.

Khai báo phần phụ thuộc

Để sử dụng Jetpack WindowManager, hãy thêm phần phụ thuộc vào tệp build.gradle cho ứng dụng hoặc mô-đun:

app/build.gradle

dependencies {
    ext.windowmanager_version = "1.1.0"

    implementation "androidx.window:window:$windowmanager_version"
    androidTestImplementation "androidx.window:window-testing:$windowmanager_version"

    // Needed to use lifecycleScope to collect the WindowLayoutInfo flow
    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2"
}

Sử dụng WindowManager

Bạn sẽ sử dụng được các tính năng liên quan đến cửa sổ thông qua giao diện WindowInfoTracker của WindowManager.

Mở tệp nguồn MainActivity.kt rồi gọi WindowInfoTracker.getOrCreate(this@MainActivity) để khởi tạo thực thể WindowInfoTracker liên kết với hoạt động hiện tại:

MainActivity.kt

import androidx.window.layout.WindowInfoTracker

private lateinit var windowInfoTracker: WindowInfoTracker

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

        windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)
}

Nhờ thực thể WindowInfoTracker, bạn có thể lấy thông tin về trạng thái cửa sổ hiện tại của thiết bị.

5. Thiết lập giao diện người dùng của ứng dụng

Trên Jetpack WindowManager, nhận thông tin về các chỉ số, bố cục, và cấu hình hiển thị của cửa sổ. Để thể hiện điều này trong bố cục hoạt động chính, hãy sử dụng TextView cho mỗi yếu tố.

Tạo một ConstraintLayout có 3 TextView, nằm ở giữa màn hình.

Mở tệp activity_main.xml rồi dán nội dung sau vào:

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:id="@+id/constraint_layout"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

    <TextView
        android:id="@+id/window_metrics"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="20dp"
        tools:text="Window metrics"
        android:textSize="20sp"
        app:layout_constraintBottom_toTopOf="@+id/layout_change"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_chainStyle="packed" />

    <TextView
        android:id="@+id/layout_change"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="20dp"
        tools:text="Layout change"
        android:textSize="20sp"
        app:layout_constrainedWidth="true"
        app:layout_constraintBottom_toTopOf="@+id/configuration_changed"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/window_metrics" />

    <TextView
        android:id="@+id/configuration_changed"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="20dp"
        tools:text="Using one logic/physical display - unspanned"
        android:textSize="20sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/layout_change" />

</androidx.constraintlayout.widget.ConstraintLayout>

Bây giờ, chúng ta sẽ kết nối những thành phần này trên giao diện người dùng trong mã bằng cách sử dụng tính năng liên kết thành phần hiển thị. Để làm việc này, chúng ta bắt đầu bật tuỳ chọn này trong tệp build.gradle của ứng dụng:

app/build.gradle

android {
   // Other configurations

   buildFeatures {
      viewBinding true
   }
}

Đồng bộ hoá dự án gradle mà Android Studio đề xuất rồi dùng tính năng liên kết thành phần hiển thị trong MainActivity.kt bằng mã sau:

MainActivity.kt

class MainActivity : AppCompatActivity() {
    private lateinit var windowInfoTracker: WindowInfoTracker
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)
    }
}

6. Trực quan hoá thông tin WindowMetrics

Trong phương thức onCreate của MainActivity, hãy gọi một hàm để nhận và hiện thông tin WindowMetrics. Thêm lệnh gọi obtainWindowMetrics() trong phương thức onCreate:

MainActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   binding = ActivityMainBinding.inflate(layoutInflater)
   setContentView(binding.root)

   windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)

   obtainWindowMetrics()
}

Triển khai phương thức obtainWindowMetrics:

MainActivity.kt

import androidx.window.layout.WindowMetricsCalculator

private fun obtainWindowMetrics() {
   val wmc = WindowMetricsCalculator.getOrCreate()
   val currentWM = wmc.computeCurrentWindowMetrics(this).bounds.flattenToString()
   val maximumWM = wmc.computeMaximumWindowMetrics(this).bounds.flattenToString()
   binding.windowMetrics.text =
       "CurrentWindowMetrics: ${currentWM}\nMaximumWindowMetrics: ${maximumWM}"
}

Nhận một thực thể của WindowMetricsCalculator thông qua hàm đồng hành getOrCreate().

Bằng cách dùng thực thể WindowMetricsCalculator đó, thiết lập thông tin thành windowMetrics TextView. Sử dụng giá trị mà các hàm computeCurrentWindowMetrics.boundscomputeMaximumWindowMetrics.bounds trả về.

Những giá trị này cung cấp thông tin hữu ích về các chỉ số của khu vực nơi cửa sổ hiện diện.

Chạy ứng dụng. Trong trình mô phỏng màn hình đôi (như hình dưới đây), bạn sẽ nhận được CurrentWindowMetrics phù hợp với kích thước của thiết bị mà trình mô phỏng tái hiện lại. Bạn cũng có thể xem các chỉ số này khi ứng dụng chạy ở chế độ màn hình đơn:

f6f0deff678fd722.png

Khi ứng dụng trải rộng trên các màn hình, chỉ số cửa sổ sẽ thay đổi như trong hình dưới đây, vì vậy giờ đây các chỉ số sẽ phản ánh khu vực cửa sổ lớn hơn mà ứng dụng sử dụng:

f1ce73d7198b4990.png

Cả chỉ số cửa sổ hiện tại và tối đa đều có cùng giá trị, vì ứng dụng luôn chạy và chiếm toàn bộ khu vực hiển thị hiện có, trên cả màn hình đơn và màn hình đôi.

Trong trình mô phỏng thiết bị có thể gập lại theo chiều ngang, giá trị sẽ khác nhau khi ứng dụng trải rộng ra toàn bộ màn hình thực, so với khi ứng dụng chạy ở chế độ nhiều cửa sổ:

d00e53154f32d7df.png

Như bạn có thể thấy trong hình ảnh bên trái, cả hai chỉ số đều có cùng một giá trị vì ứng dụng đang chạy và sử dụng toàn bộ khu vực hiển thị (là khu vực hiện tại và tối đa hiện có)

Tuy nhiên, trong hình ảnh bên phải, khi ứng dụng chạy ở chế độ nhiều cửa sổ, bạn có thể xem cách các chỉ số hiện tại cho biết kích thước của khu vực cụ thể mà ứng dụng đang chạy tại đó (trên cùng) trong chế độ chia đôi màn hình. Bạn cũng có thể xem cách các chỉ số tối đa cho biết khu vực hiển thị tối đa mà ứng dụng có.

Các chỉ số do WindowMetricsCalculator cung cấp rất hữu ích trong việc xác định khu vực cửa sổ mà ứng dụng đang sử dụng hoặc có thể sử dụng.

7. Trực quan hoá thông tin FoldingFeature

Bây giờ, hãy đăng ký (register) để nhận thông tin về các thay đổi đối với bố cục cửa sổ cũng như các tính năng và ranh giới của DisplayFeatures trong trình mô phỏng hoặc thiết bị.

Để thu thập thông tin từ WindowInfoTracker#windowLayoutInfo(), sử dụng lifecycleScope được xác định cho từng đối tượng Lifecycle. Mọi coroutine khởi chạy trong phạm vi này đều sẽ bị huỷ bỏ khi Vòng đời bị huỷ bỏ. Bạn có thể truy cập vào phạm vi coroutine của vòng đời thông qua thuộc tính lifecycle.coroutineScope hoặc lifecycleOwner.lifecycleScope.

Trong phương thức onCreate của MainActivity, hãy gọi một hàm để nhận và hiện thông tin WindowInfoTracker. Bắt đầu bằng cách thêm lệnh gọi onWindowLayoutInfoChange() vào phương thức onCreate:

MainActivity.kt

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   binding = ActivityMainBinding.inflate(layoutInflater)
   setContentView(binding.root)

   windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)

   obtainWindowMetrics()
   onWindowLayoutInfoChange()
}

Sử dụng phương thức triển khai hàm đó để lấy thông tin mỗi khi có thay đổi trong cấu hình bố cục mới.

Xác định cơ cấu và chữ ký của hàm.

MainActivity.kt

private fun onWindowLayoutInfoChange() {
}

Với tham số mà hàm nhận được (WindowInfoTracker), hãy lấy dữ liệu WindowLayoutInfo của hàm. WindowLayoutInfo chứa danh sách của DisplayFeature nằm bên trong cửa sổ. Ví dụ: bản lề hoặc nếp gập màn hình có thể đi xuyên qua cửa sổ. Trong trường hợp này, bạn nên tách nội dung hình ảnh và các thành phần tương tác thành hai nhóm (ví dụ: chi tiết danh sách hoặc chế độ xem).

Hệ thống chỉ báo cáo những tính năng được thể hiện trong giới hạn cửa sổ hiện tại. Vị trí và kích thước có thể thay đổi nếu cửa sổ bị di chuyển hoặc đổi kích thước trên màn hình.

Thông qua lifecycleScope được xác định trong phần phụ thuộc lifecycle-runtime-ktx, lấy flow của WindowLayoutInfo mà chứa danh sách mọi tính năng hiển thị. Thêm nội dung của onWindowLayoutInfoChange:

MainActivity.kt

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch

private fun onWindowLayoutInfoChange() {
    lifecycleScope.launch(Dispatchers.Main) {
        lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
            windowInfoTracker.windowLayoutInfo(this@MainActivity)
                .collect { value ->
                    updateUI(value)
                }
        }
    }
}

Hàm updateUI sẽ được gọi từ collect. Triển khai hàm này để hiện và in thông tin nhận được từ flow của WindowLayoutInfo. Kiểm tra xem dữ liệu WindowLayoutInfo có tính năng hiển thị hay không. Nếu có, thì tính năng hiển thị sẽ tương tác theo cách nào đó với giao diện người dùng của ứng dụng. Nếu dữ liệu WindowLayoutInfo không có tính năng hiển thị nào thì ứng dụng đang chạy ở chế độ/thiết bị màn hình đơn, hoặc ở chế độ nhiều cửa sổ.

MainActivity.kt

import androidx.window.layout.WindowLayoutInfo

private fun updateUI(newLayoutInfo: WindowLayoutInfo) {
    binding.layoutChange.text = newLayoutInfo.toString()
    if (newLayoutInfo.displayFeatures.isNotEmpty()) {
        binding.configurationChanged.text = "Spanned across displays"
    } else {
        binding.configurationChanged.text = "One logic/physical display - unspanned"
    }
}

Chạy ứng dụng. Trong trình mô phỏng màn hình đôi, bạn sẽ thấy:

a6f6452155742925.png

WindowLayoutInfo là trống Lớp này có List<DisplayFeature> trống. Nhưng nếu bạn có một trình mô phỏng với bản lề ở giữa thì tại sao bạn không nhận được thông tin của WindowManager?

WindowManager (thông qua WindowInfoTracker) sẽ cung cấp dữ liệu WindowLayoutInfo (loại tính năng thiết bị, ranh giới tính năng thiết bị và tư thế thiết bị) ngay khi ứng dụng trải dài trên nhiều màn hình (trên thực tế hoặc không). Trong hình trước, khi ứng dụng chạy ở chế độ màn hình đơn, WindowLayoutInfo là trống.

Khi có thông tin đó, bạn sẽ biết ứng dụng đang chạy ở chế độ nào (chế độ màn hình đơn hoặc được trải rộng trên các màn hình) để bạn có thể thay đổi giao diện/trải nghiệm người dùng, mang lại trải nghiệm người dùng tốt hơn, được điều chỉnh phù hợp với những cấu hình cụ thể này.

Trên những thiết bị không có 2 màn hình thực (thường không có bản lề thực), ứng dụng có thể chạy cạnh nhau ở chế độ nhiều cửa sổ. Trên những thiết bị như vậy, khi ứng dụng chạy ở chế độ nhiều cửa sổ, ứng dụng sẽ hoạt động như trên màn hình đơn giống như trong ví dụ trước. Và khi ứng dụng chạy và chiếm mọi màn hình logic, thì ứng dụng sẽ hoạt động như khi được mở rộng. Xem hình tiếp theo:

eacdd758eefb6c3c.png

Khi ứng dụng chạy ở chế độ nhiều cửa sổ, WindowManager sẽ cung cấp một List<LayoutInfo> trống.

Tóm lại, bạn sẽ nhận được dữ liệu WindowLayoutInfo ngay khi ứng dụng chạy và chiếm mọi màn hình logic, giao với tính năng của thiết bị (gập hoặc bản lề). Trong tất cả trường hợp khác, bạn sẽ không nhận được thông tin nào. 32e4190913b452e4.png

Điều gì xảy ra khi bạn trải rộng ứng dụng trên nhiều màn hình? Trong trình mô phỏng màn hình đôi, WindowLayoutInfo sẽ có một đối tượng FoldingFeature cung cấp dữ liệu về tính năng thiết bị: HINGE, ranh giới của tính năng đó ( Rect (0, 0 - 1434, 1800)) và tư thế (trạng thái) của thiết bị (FLAT).

586f15def7d23ffd.png

Hãy xem ý nghĩa của từng trường:

  • type = TYPE_HINGE: Trình mô phỏng màn hình đôi này phản ánh một thiết bị Surface Duo thực có bản lề thực và đây là nội dung mà WindowManager báo cáo.
  • Bounds [0, 0 - 1434, 1800]: Biểu thị hình chữ nhật bao quanh chức năng trong cửa sổ ứng dụng trong không gian toạ độ cửa sổ. Nếu đọc thông số kích thước thiết bị Surface Duo, bạn sẽ thấy bản lề nằm ở vị trí chính xác theo báo cáo của các giới hạn này (trái, trên, phải, dưới).
  • State: Có hai giá trị riêng biệt thể hiện tư thế (trạng thái) của thiết bị.
  • HALF_OPENED: Bản lề của thiết bị có thể gập lại ở vị trí trung gian giữa trạng thái mở và đóng, đồng thời có một góc không phẳng giữa các phần của màn hình linh hoạt hoặc giữa các bảng màn hình thực.
  • FLAT: Thiết bị có thể gập lại đang mở hoàn toàn và không gian màn hình mà người dùng nhìn thấy có dạng phẳng.

Theo mặc định, trình mô phỏng mở ra ở 180 độ, vì vậy, tư thế mà WindowManager trả về là FLAT.

Nếu bạn chọn dùng Cảm biến ảo (Virtual sensor) để thay đổi tư thế của trình mô phỏng thành Mở một nửa (Half-Open), WindowManager sẽ thông báo cho bạn về vị trí mới: HALF_OPENED.

cba02ab39d6d346b.png

Sử dụng WindowManager để điều chỉnh giao diện/trải nghiệm người dùng

Như trong các hình minh hoạ thông tin bố cục cửa sổ, thông tin hiện ra đã bị tính năng hiển thị cắt bớt, điều tương tự cũng xảy ra ở đây:

ff2caf93916f1682.png

Đây không phải là trải nghiệm người dùng tối ưu. Bạn có thể sử dụng thông tin mà WindowManager cung cấp để điều chỉnh giao diện/trải nghiệm người dùng.

Như bạn đã thấy, thời điểm ứng dụng của bạn được trải rộng ra nhiều khu vực hiển thị cũng là khi ứng dụng giao với tính năng thiết bị, do đó WindowManager cung cấp thông tin về bố cục cửa sổ, bao gồm trạng thái hiển thị và ranh giới hiển thị. Ở đây, khi ứng dụng được trải rộng, bạn sẽ phải dùng thông tin đó để điều chỉnh giao diện/trải nghiệm người dùng.

Tiếp theo, bạn sẽ điều chỉnh giao diện/trải nghiệm người dùng mà mình hiện có trong thời gian chạy khi ứng dụng trải rộng ra sao cho không có thông tin quan trọng nào bị tính năng hiển thị cắt đi hay ẩn bớt. Bạn sẽ tạo một thành phần hiển thị phản ánh tính năng hiển thị của thiết bị. Thành phần hiển thị này sẽ được dùng làm tham chiếu ràng buộc TextView bị cắt hoặc ẩn, nhờ vậy bạn không còn bị mất thông tin nữa.

Để giúp bạn tìm hiểu, hãy tô màu thành phần hiển thị mới này để có thể dễ dàng nhận thấy thành phần hiển thị này được đặc biệt đặt vào cùng chỗ với tính năng hiển thị của thiết bị thực và có cùng kích thước.

Hãy thêm thành phần hiển thị mới mà bạn sẽ dùng làm tham chiếu cho tính năng thiết bị vào activity_main.xml:

activity_main.xml

<!-- It's not important where this view is placed by default, it will be positioned dynamically at runtime -->
<View
    android:id="@+id/folding_feature"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:background="@android:color/holo_red_dark"
    android:visibility="gone"
    tools:ignore="MissingConstraints" />

Trong MainActivity.kt, hãy chuyển đến hàm updateUI() mà bạn sử dụng để hiện thông tin từ một WindowLayoutInfo cụ thể rồi thêm một lệnh gọi hàm mới trong trường hợp if-else khi bạn đã có một tính năng hiển thị:

MainActivity.kt

private fun updateUI(newLayoutInfo: WindowLayoutInfo) {
   binding.layoutChange.text = newLayoutInfo.toString()
   if (newLayoutInfo.displayFeatures.isNotEmpty()) {
       binding.configurationChanged.text = "Spanned across displays"
       alignViewToFoldingFeatureBounds(newLayoutInfo)
   } else {
       binding.configurationChanged.text = "One logic/physical display - unspanned"
   }
}

Bạn đã thêm hàm alignViewToFoldingFeatureBounds nhận được dưới dạng thông số WindowLayoutInfo.

Hãy tạo hàm đó. Bên trong hàm này, hãy tạo ConstraintSet để áp dụng các quy tắc ràng buộc mới cho thành phần hiển thị của bạn. Sau đó, hãy xem ranh giới của tính năng hiển thị bằng cách sử dụng WindowLayoutInfo. Vì WindowLayoutInfo trả về danh sách DisplayFeature chỉ là một giao diện, nên hãy truyền giao diện đó tới FoldingFeature để có quyền truy cập vào tất cả thông tin:

MainActivity.kt

import androidx.constraintlayout.widget.ConstraintSet
import androidx.window.layout.FoldingFeature

private fun alignViewToFoldingFeatureBounds(newLayoutInfo: WindowLayoutInfo) {
   val constraintLayout = binding.constraintLayout
   val set = ConstraintSet()
   set.clone(constraintLayout)

   // Get and translate the feature bounds to the View's coordinate space and current
   // position in the window.
   val foldingFeature = newLayoutInfo.displayFeatures[0] as FoldingFeature
   val bounds = getFeatureBoundsInWindow(foldingFeature, binding.root)

   // Rest of the code to be added in the following steps
}

Xác định hàm getFeatureBoundsInWindow() để biến đổi các ranh giới của tính năng sang không gian toạ độ và vị trí hiện tại của thành phần hiển thị trong cửa sổ.

MainActivity.kt

import android.graphics.Rect
import android.view.View
import androidx.window.layout.DisplayFeature

/**
 * Get the bounds of the display feature translated to the View's coordinate space and current
 * position in the window. This will also include view padding in the calculations.
 */
private fun getFeatureBoundsInWindow(
    displayFeature: DisplayFeature,
    view: View,
    includePadding: Boolean = true
): Rect? {
    // Adjust the location of the view in the window to be in the same coordinate space as the feature.
    val viewLocationInWindow = IntArray(2)
    view.getLocationInWindow(viewLocationInWindow)

    // Intersect the feature rectangle in window with view rectangle to clip the bounds.
    val viewRect = Rect(
        viewLocationInWindow[0], viewLocationInWindow[1],
        viewLocationInWindow[0] + view.width, viewLocationInWindow[1] + view.height
    )

    // Include padding if needed
    if (includePadding) {
        viewRect.left += view.paddingLeft
        viewRect.top += view.paddingTop
        viewRect.right -= view.paddingRight
        viewRect.bottom -= view.paddingBottom
    }

    val featureRectInView = Rect(displayFeature.bounds)
    val intersects = featureRectInView.intersect(viewRect)

    // Checks to see if the display feature overlaps with our view at all
    if ((featureRectInView.width() == 0 && featureRectInView.height() == 0) ||
        !intersects
    ) {
        return null
    }

    // Offset the feature coordinates to view coordinate space start point
    featureRectInView.offset(-viewLocationInWindow[0], -viewLocationInWindow[1])

    return featureRectInView
}

Với thông tin về ranh giới của tính năng hiển thị, bạn có thể sử dụng tính năng này để thiết lập kích thước chiều cao chính xác cho thành phần hiển thị tham chiếu và di chuyển nó tương ứng.

Mã hoàn chỉnh cho alignViewToFoldingFeatureBounds sẽ là:

MainActivity.kt - alignViewToFoldingFeatureBounds

private fun alignViewToFoldingFeatureBounds(newLayoutInfo: WindowLayoutInfo) {
    val constraintLayout = binding.constraintLayout
    val set = ConstraintSet()
    set.clone(constraintLayout)

    // Get and Translate the feature bounds to the View's coordinate space and current
    // position in the window.
    val foldingFeature = newLayoutInfo.displayFeatures[0] as FoldingFeature
    val bounds = getFeatureBoundsInWindow(foldingFeature, binding.root)

    bounds?.let { rect ->
        // Some devices have a 0px width folding feature. We set a minimum of 1px so we
        // can show the view that mirrors the folding feature in the UI and use it as reference.
        val horizontalFoldingFeatureHeight = (rect.bottom - rect.top).coerceAtLeast(1)
        val verticalFoldingFeatureWidth = (rect.right - rect.left).coerceAtLeast(1)

        // Sets the view to match the height and width of the folding feature
        set.constrainHeight(
            R.id.folding_feature,
            horizontalFoldingFeatureHeight
        )
        set.constrainWidth(
            R.id.folding_feature,
            verticalFoldingFeatureWidth
        )

        set.connect(
            R.id.folding_feature, ConstraintSet.START,
            ConstraintSet.PARENT_ID, ConstraintSet.START, 0
        )
        set.connect(
            R.id.folding_feature, ConstraintSet.TOP,
            ConstraintSet.PARENT_ID, ConstraintSet.TOP, 0
        )

        if (foldingFeature.orientation == FoldingFeature.Orientation.VERTICAL) {
            set.setMargin(R.id.folding_feature, ConstraintSet.START, rect.left)
            set.connect(
                R.id.layout_change, ConstraintSet.END,
                R.id.folding_feature, ConstraintSet.START, 0
            )
        } else {
            // FoldingFeature is Horizontal
            set.setMargin(
                R.id.folding_feature, ConstraintSet.TOP,
                rect.top
            )
            set.connect(
                R.id.layout_change, ConstraintSet.TOP,
                R.id.folding_feature, ConstraintSet.BOTTOM, 0
            )
        }

        // Set the view to visible and apply constraints
        set.setVisibility(R.id.folding_feature, View.VISIBLE)
        set.applyTo(constraintLayout)
    }
}

Bây giờ, TextView từng xung đột với tính năng hiển thị trên thiết bị sẽ được xem xét để xác định vị trí của tính năng, vì vậy nội dung tương ứng không bao giờ bị cắt hoặc ẩn đi:

67b41810704d0011.png

Trong trình mô phỏng màn hình đôi (phía trên, bên trái), bạn có thể xem cách TextView hiện nội dung trên các màn hình và phần nội dung từng bị bản lề cắt nay không còn bị cắt nữa, do đó không bị mất thông tin nào.

Trong trình mô phỏng màn hình có thể gập lại (ở trên, bên phải), bạn sẽ thấy một đường màu đỏ nhạt biểu thị vị trí đặt tính năng gập màn hình và TextView hiện đã được đặt bên dưới đối tượng này. Vì vậy, khi thiết bị được gập lại (chẳng hạn như 90 độ ở tư thế máy tính xách tay), tính năng này sẽ không ảnh hưởng đến thông tin.

Nếu bạn đang phân vân tính năng hiển thị nằm ở đâu trên trình mô phỏng màn hình đôi (vì đây là thiết bị loại bản lề), thì thực ra thành phần hiển thị thể hiện tính năng này bị bản lề ẩn đi. Tuy nhiên, nếu ứng dụng thay đổi từ chế độ trải rộng sang chế độ thu hẹp lại, bạn sẽ thấy tính năng này trong chính vị trí đó do tính năng này có chiều cao và chiều rộng chính xác.

1a309ab775c49a6a.png

8. Cấu phần phần mềm Jetpack WindowManager khác

Ngoài cấu phần phần mềm chính, WindowManager cũng có một số cấu phần phần mềm hữu ích khác giúp bạn tương tác với thành phần theo cách khác đi, trên cơ sở cân nhắc môi trường bạn sử dụng khi xây dựng ứng dụng của mình.

Cấu phần mềm Java

Nếu bạn đang sử dụng ngôn ngữ lập trình Java thay vì Kotlin, hoặc nếu việc nghe các sự kiện thông qua lệnh gọi lại là một cách thức tốt hơn cho kiến trúc của bạn, thì cấu phần phần mềm Java của WindowManager có thể hữu ích vì cấu phần này cung cấp một API thân thiện với Java để đăng ký và huỷ đăng ký trình nghe cho các sự kiện thông qua lệnh gọi lại.

Cấu phần mềm xJava

Nếu đã dùng RxJava (phiên bản 2 hoặc 3), bạn có thể sử dụng một số cấu phần phần mềm cụ thể để duy trì tính nhất quán trong mã của mình, bất kể là bạn sử dụng Observables hay Flowables.

9. Kiểm thử bằng Jetpack WindowManager

Việc kiểm thử các tư thế có thể gập lại trên trình mô phỏng hoặc thiết bị bất kỳ có thể rất hữu ích trong việc kiểm tra cách đặt các phần tử giao diện người dùng xung quanh FoldingFeature.

Để đạt được điều đó, WindowManager có các cấu phần mềm rất hữu ích cho các kiểm thử đo lường.

Hãy xem cách sử dụng.

Cùng với phần phụ thuộc chính của WindowManager, chúng ta đã thêm cấu phần mềm kiểm thử vào tệp build.gradle của ứng dụng: androidx.window:window-testing

Cấu phần mềm window-testing đi kèm với một TestRule mới hữu ích có tên là WindowLayoutInfoPublisherRule sẽ giúp kiểm thử việc tiêu thụ một luồng giá trị WindowLayoutInfo. WindowLayoutInfoPublisherRule cho phép bạn chuyển đến nhiều giá trị WindowLayoutInfo theo yêu cầu.

Để sử dụng cấu phần phần mềm này và từ đó tạo một mẫu có thể giúp bạn kiểm thử giao diện người dùng bằng cấu phần phần mềm mới này, hãy cập nhật lớp kiểm thử được tạo bằng mẫu của Android Studio. Hãy thay thế toàn bộ mã trong lớp ExampleInstrumentedTest bằng:

ExampleInstrumentedTest.kt

import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.window.testing.layout.WindowLayoutInfoPublisherRule
import org.junit.Rule
import org.junit.rules.RuleChain
import org.junit.rules.TestRule
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class MainActivityTest {
    private val activityRule = ActivityScenarioRule(MainActivity::class.java)
    private val publisherRule = WindowLayoutInfoPublisherRule()

    @get:Rule
    val testRule: TestRule

    init {
        testRule = RuleChain.outerRule(publisherRule).around(activityRule)
    }
}

Quy tắc đã đề cập được tạo chuỗi bằng một ActvityScenarioRule.

Để bắt chước FoldingFeature, cấu phần phần mềm mới sẽ có một vài chức năng rất hữu ích để triển khai. Đây là chức năng đơn giản nhất đưa ra một số giá trị mặc định.

Trong MainActivity, các TextView sẽ được căn chỉnh ở bên trái tính năng gập. Tạo một chương trình kiểm thử để kiểm tra xem liệu bạn đã triển khai đúng cách hay chưa.

Tạo một chương trình kiểm thử có tên testText_is_left_of_Vertical_FoldingFeature:

ExampleInstrumentedTest.kt

import androidx.window.layout.FoldingFeature.Orientation.Companion.VERTICAL
import androidx.window.layout.FoldingFeature.State.Companion.FLAT
import androidx.window.testing.layout.FoldingFeature
import androidx.window.testing.layout.TestWindowLayoutInfo
import org.junit.Test

@Test
fun testText_is_left_of_Vertical_FoldingFeature() {
   activityRule.scenario.onActivity { activity ->
       val hinge = FoldingFeature(
           activity = activity,
           state = FLAT,
           orientation = VERTICAL,
           size = 2
       )

       val expected = TestWindowLayoutInfo(listOf(hinge))
       publisherRule.overrideWindowLayoutInfo(expected)
   }

   // Add Assertion with EspressoMatcher here

}

Chương trình kiểm thử FoldingFeature có trạng thái FLAT và hướng VERTICAL. Chúng ta đã xác định một kích thước cụ thể vì muốn FoldingFeature giả xuất hiện trong giao diện người dùng trong các lượt kiểm thử để có thể xem vị trí tương ứng trên thiết bị.

Chúng ta sử dụng WindowLayoutInfoPublishRule mà chúng ta đã tạo bản sao trước đó để phát hành FoldingFeaure giả mạo, nhờ vậy chúng ta có thể nhận được dữ liệu giống như khi có dữ liệu WindowLayoutInfo thực tế:

Bước cuối cùng chỉ là kiểm thử để đảm bảo rằng các thành phần giao diện người dùng được đặt ở vị trí phù hợp và tránh FoldingFeature. Để làm được điều đó, chúng ta chỉ cần sử dụng EspressoMatchers và thêm câu nhận định vào cuối mã kiểm thử vừa tạo:

ExampleInstrumentedTest.kt

import androidx.test.espresso.assertion.PositionAssertions
import androidx.test.espresso.matcher.ViewMatchers.withId

onView(withId(R.id.layout_change)).check(
    PositionAssertions.isCompletelyLeftOf(withId(R.id.folding_feature))
)

Quá trình kiểm thử hoàn chỉnh sẽ là:

ExampleInstrumentedTest.kt

@Test
fun testText_is_left_of_Vertical_FoldingFeature() {
    activityRule.scenario.onActivity { activity ->
        val hinge = FoldingFeature(
            activity = activity,
            state = FoldingFeature.State.FLAT,
            orientation = FoldingFeature.Orientation.VERTICAL,
            size = 2
        )
        val expected = TestWindowLayoutInfo(listOf(hinge))
        publisherRule.overrideWindowLayoutInfo(expected)
    }
    onView(withId(R.id.layout_change)).check(
        PositionAssertions.isCompletelyLeftOf(withId(R.id.folding_feature))
    )
}
val horizontal_hinge = FoldingFeature(
   activity = activity,
   state = FLAT,
   orientation = HORIZONTAL,
   size = 2
)

Hiện bạn có thể chạy kiểm thử trên thiết bị hoặc trình mô phỏng để kiểm tra xem ứng dụng có hoạt động như mong đợi hay không. Xin lưu ý rằng bạn không cần thiết bị có thể gập lại hoặc trình mô phỏng để chạy được mã kiểm thử này.

10. Xin chúc mừng!

Jetpack WindowManager giúp chúng ta về các vấn đề liên quan đến thiết bị có kiểu dáng mới, chẳng hạn như thiết bị có thể gập lại.

Thông tin mà WindowManager cung cấp rất hữu ích trong việc điều chỉnh ứng dụng Android cho phù hợp với thiết bị có thể gập lại, để mang lại trải nghiệm tối ưu cho người dùng.

Tóm lại, trong lớp học lập trình này, bạn đã tìm hiểu:

  • Thiết bị có thể gập lại là gì
  • Sự khác biệt giữa các loại thiết bị có thể gập lại
  • Sự khác biệt giữa thiết bị có thể gập lại, thiết bị màn hình đơn và máy tính bảng
  • Jetpack WindowManager API
  • Cách sử dụng Jetpack WindowManager và điều chỉnh ứng dụng cho phù hợp với các kiểu dáng thiết bị mới
  • Kiểm thử bằng Jetpack WindowManager

Tìm hiểu thêm