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

Sử dụng bộ sưu tập để sắp xếp ngăn nắp các trang Lưu và phân loại nội dung dựa trên lựa chọn ưu tiên của bạn.

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 hoàn tất, bạn có thể cải thiện ứng dụng của mình để hỗ trợ các thiết bị như Microsoft Surface Duo và Samsung Galaxy Z Fold3.

Đ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 có:

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:

7203779994e5c01d.png

  • Nếu muốn sử dụng một 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à 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 hiển thị trên đó lại có logic khác nhau.

36ac8e233762dc14.png

Giống như máy tính bảng và các 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ế. Bạn có thể dùng các tư thế để trình bày nội dung theo nhiều cách.

143cfdd54a81c18d.png

Thiết bị có thể gập lại sở hữu 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 hay một bản lề giữa hai bảng màn hình thực. API tương ứng 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):

42ea544d85824f6e.png

Bạn có thể để nguyên giá trị mặc định cho tất cả tham số.

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

Để sử dụng Jetpack WindowManager, bạn phải thêm phần phụ thuộc vào tệp build.gradle cho ứng dụng hoặc mô-đun của mình:

app/build.gradle

dependencies {
    ext.windowmanager_version = "1.0.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.4.0"
}

Sử dụng WindowManager

Bạn có thể xem 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 động 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)
}

Sau khi có thực thể WindowInfoTracker, bạn sẽ nhận được 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, chúng ta có thể 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ổ. Hãy thể hiện điều này trong bố cục hoạt động chính, sử dụng TextView cho mỗi yếu tố.

Để làm điều này, chúng ta cần có một ConstraintLayout gồm ba TextView nằm ở giữa màn hình.

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

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 khung nhìn. Để 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
   }
}

Giờ đây, chúng ta có thể đồng bộ hoá dự án gradle mà Android Studio đề xuất và sử dụng tính năng liên kết khung nhìn 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, chúng ta sẽ gọi một hàm sẽ được triển khai trong các bước tiếp theo. Hàm này được dùng để lấy và hiện thông tin WindowMetrics. Chúng ta sẽ bắt đầu bằng cách thêm một 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()
}

Bây giờ, chúng ta sẽ 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ư có thể thấy ở trên, chúng ta nhận được một thực thể WindowMetricsCalculator thông qua chức năng đồng hành getOrCreate().

Bằng cách sử dụng thực thể WindowMetricsCalculator đó, chúng ta đặt thông tin thành windowMetrics TextView. Chúng ta sử dụng nhữ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. Tuỳ thuộc vào thiết bị có thể gập lại mà bạn sử dụng, bạn sẽ nhận được các kết quả riêng. Ví dụ: trong trình mô phỏng màn hình đôi (như hình dưới đây), bạn có thể lấy CurrentWindowMetrics phù hợp với kích thước của thiết bị mà trình mô phỏng phản chiếu. 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:

b032c729d6dce292.png

Khi ứng dụng trải rộng trên các màn hình, chỉ số cửa sổ thay đổi như trong hình ả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:

882fc97252d1483b.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 của thiết bị có thể gập lại với nếp gập ngang, các 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ổ:

8f3db697d9d76415.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ị (tức 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ờ, chúng ta sẽ đă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(), chúng ta sẽ 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 bị huỷ khi Vòng đời bị phá huỷ. 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, chúng ta sẽ gọi một hàm sẽ được triển khai trong các bước tiếp theo. Hàm này được dùng để lấy và hiện thông tin WindowInfoTracker. Chúng ta sẽ bắt đầu bằng cách thêm một 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()
}

Chúng ta sẽ sử dụng quy trình triển khai chức năng đó để lấy thông tin mỗi khi cấu hình bố cục mới thay đổi.

Hãy xem cách thực hiện:

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), chúng ta có thể lấy dữ liệu WindowLayoutInfo của hàm. WindowLayoutInfo chứa danh sách 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, chúng ta có thể nhận được flow của WindowLayoutInfo (như đã đề cập) chứa danh sách về mọi tính năng hiển thị. Bây giờ, chúng ta có thể thêm phần nội dung (body) 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)
                }
        }
    }
}

Như bạn đã thấy ở bước trước, bên trong collect, chúng ta đã gọi hàm updateUI. Bây giờ, chúng ta sẽ triển khai hàm này để hiện và in thông tin mà chúng ta nhận được qua flow của WindowLayoutInfo. Như bạn thấy, logic ở đây rất cơ bản: chúng ta chỉ kiểm tra xem dữ liệu WindowLayoutInfo có cá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ì chúng ta sẽ chạy trong một 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"
    }
}

Hãy xem giờ đây chúng ta có gì sau khi chạy toàn bộ mã mới. Trong trình mô phỏng màn hình đôi, bạn sẽ thấy:

49a85b4d10245a9d.png

Như bạn có thể thấy, WindowLayoutInfo đang trống. Tệ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 sẽ trống.

Khi có thông tin đó, bạn có thể 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), cá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 hoạt động của ứng dụng chiếm mọi màn hình logic, thì ứng dụng sẽ hoạt động như khi được mở rộng. Bạn có thể thấy thông tin này trong hình tiếp theo:

eacdd758eefb6c3c.png

Như bạn thấy, 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 về WindowLayoutInfo ngay khi hoạt động của ứng dụng chiếm toàn bộ 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 mọi 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).

faab87600a42a484.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 dùng tuỳ chọn 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.

bbfbab436850fb4e.png

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

Như bạn thấy 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. Bạn có thể xem lại ở đây:

422aa9714bdb2892.png

Đây không phải là trải nghiệm tốt nhất mà bạn có thể đem đến cho người dùng. 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 khung nhìn phản ánh tính năng hiển thị của thiết bị. Khung nhìn 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.

Để thuận tiện tìm hiểu, bạn sẽ tô màu khung nhìn mới này để có thể dễ dàng nhận thấy khung nhìn này được đặt ở chính nơi đặt 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 khung nhìn 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 khung 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 chúng ta sẽ phải truyền nó tới FoldingFeature để có quyền truy cập vào tất cả thông tin cần thiết:

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
}

Chúng ta sẽ xác định một hàm getFeatureBoundsInWindow() để biến các giới hạn tính năng thành không gian toạ độ và vị trí hiện tại của khung nhìn 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
}

Giờ đây, khi đã có thông tin về ranh giới của tính năng hiển thị, chúng ta có thể sử dụng thông tin này để đặt kích thước chiều cao chính xác cho khung nhìn tham chiếu rồi di chuyể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 bỏ hoặc ẩn đi:

5f671f3a33054970.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 thắc mắc 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 tế là khung nhìn thể hiện tính năng này sẽ bị bản lề ẩn đi. Tuy nhiên, nếu chúng ta di chuyển ứng dụng 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.

5318e7a182ee9281.png

8. Cấu 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, chúng ta sẽ cập nhật lớp kiểm thử 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)
    }
}

Như bạn có thể thấy ở trên, chúng ta cũng đã tạo (cùng với quy tắc đã đề cập) một ActvityScenarioRule rồi nối chúng lại với nhau.

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

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

Tạo một mã 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

}

Như bạn có thể thấy, chúng ta đang tạo một mã kiểm thử FoldingFeature sẽ 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ì chúng ta muốn FoldingFeature giả mạo xuất hiện trong giao diện người dùng trong các lượt kiểm thử để chúng ta 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!

Như chúng ta đã thấy trong lớp học lập trình này, Jetpack WindowManager giúp chúng ta về các vấn đề liên quan đến các 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 cho phù hợp với thiết bị có thể gập lại, nhờ vậy chúng ta có thể đem đến 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