透過 Jetpack WindowManager 支援折疊式和雙螢幕裝置

1. 事前準備

本實作程式碼研究室會說明開發雙螢幕和折疊式裝置的基本知識。完成設定後,您就可以增強應用程式,支援 Microsoft Surface Duo 和 Samsung Galaxy Z Fold3 等裝置。

必要條件

如要完成這個程式碼研究室,您必須符合以下條件:

要執行的步驟:

建立簡易應用程式,以執行以下動作:

  • 顯示裝置功能
  • 偵測應用程式是在折疊式或雙螢幕裝置上執行
  • 決定裝置狀態
  • 使用 Jetpack WindowManager,與新的板型規格裝置搭配使用。

軟硬體需求

Android Emulator v30.0.6+ 包含虛擬轉軸角度感應器和 3D 檢視畫面的折疊式裝置支援。您可以使用一些折疊式裝置模擬器,如下圖所示:

7203779994e5c01d.png

2. 單螢幕裝置與折疊式裝置

與先前的行動裝置相比,折疊式裝置能夠為使用者提供更大的螢幕畫面,以及更多樣化的使用者介面。這類裝置在折疊後通常會比一般尺寸的平板電腦還要小,因此更加方便攜帶,且功能更多樣化。

撰寫本文時,折疊式裝置有以下兩種:

  • 單螢幕折疊式裝置:包含一個可折疊的螢幕。使用者可以透過 multi-window 模式,在一個螢幕上同時執行多個應用程式。
  • 雙螢幕折疊式裝置:有兩個螢幕,由轉軸接合在一起。這類裝置也可以折疊,但有兩個不同的邏輯顯示區域。

36ac8e233762dc14.png

如同平板電腦及其他單螢幕行動裝置一般,折疊式裝置也可以:

  • 在其中一個顯示區域中執行一個應用程式。
  • 並排執行兩個應用程式,不同的顯示區域分別執行一個應用程式 (使用 multi-window 模式)。

與單螢幕裝置不同的是,折疊式裝置也支援不同的形態。型態可用於透過不同方式顯示內容。

143cfdd54a81c18d.png

當應用程式橫跨 (顯示於) 整個顯示區域時 (在雙螢幕折疊式裝置上使用所有顯示區域),折疊式裝置可以提供不同的橫跨型態。

折疊式裝置也可以提供折疊的型態,例如桌面模式可讓您在平放的螢幕與向您傾斜的螢幕之間進行邏輯分割;而帳篷模式呈現內容的方式就像是搭配立架小工具使用裝置一般。

3. Jetpack WindowManager

Jetpack WindowManager 程式庫可協助應用程式開發人員支援新的裝置板型規格,並在舊版和新版平台版本上,針對多種 WindowManager 功能提供一般 API 介面。

主要功能

Jetpack WindowManager 1.0.0 版本包含 FoldingFeature 類別,用於說明彈性顯示畫面中的折疊方式,或是兩個實體顯示面板之間的轉軸。其 API 可讓您存取與裝置相關的重要資訊:

只要使用 WindowInfoTracker 介面,就能存取 windowLayoutInfo(),以收集 WindowLayoutInfoFlow,其中包含所有可用的 DisplayFeature

4. 設定

建立新專案並選取「空白活動」範本:

42ea544d85824f6e.png

您可以保留所有參數,作為預設值。

宣告依附元件

如要使用 Jetpack WindowManager,您必須在應用程式或模組的 build.gradle 檔案中新增依附元件:

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"
}

使用 WindowManager

您可以透過 WindowManager 的 WindowInfoTracker 介面存取視窗功能。

開啟 MainActivity.kt 來源檔案並呼叫 WindowInfoTracker.getOrCreate(this@MainActivity),即可初始化與目前活動相關聯的 WindowInfoTracker 執行個體:

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)
}

取得 WindowInfoTracker 執行個體後,您就可以取得裝置目前視窗狀態的相關資訊。

5. 設定應用程式 UI

我們可以透過 Jetpack WindowManager 取得關於視窗指標、版面配置和顯示設定的資訊。在主要活動版面配置中,分別針對每一個部分使用 TextView,以顯示這類資訊。

我們需要 ConstraintLayout,以及三個 TextView (置於畫面中央),才能執行此動作。

開啟 activity_main.xml 檔案並貼上下列內容:

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>

我們現在要使用查看繫結,在程式碼中連結這些 UI 元素。因此,我們需要在應用程式的 build.gradle 檔案中開始啟用這項功能:

app/build.gradle

android {
   // Other configurations

   buildFeatures {
      viewBinding true
   }
}

我們現在可以按照 Android Studio 的建議,同步處理 Gradle 專案,並使用以下程式碼在 MainActivity.kt 中使用查看繫結:

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. 以視覺化的方式呈現 WindowMetrics 資訊

MainActivityonCreate 方法中,我們會呼叫一個函式,並且在後續步驟中實作此函式。此函式將用於取得和顯示 WindowMetrics 資訊。首先,我們會在 onCreate 方法中新增 obtainWindowMetrics() 呼叫:

MainActivity.kt

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

   windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)

   obtainWindowMetrics()
}

我們現在會實作 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}"
}

如上所述,我們會透過搭配函式 getOrCreate() 取得 WindowMetricsCalculator 的執行個體。

我們會使用該 WindowMetricsCalculator 執行個體,將資訊設為 windowMetrics TextView。我們會使用 computeCurrentWindowMetrics.boundscomputeMaximumWindowMetrics.bounds 函式傳回的值。

這些值可提供實用資訊,說明視窗佔用的區域指標。

執行應用程式。視您使用的折疊式裝置而定,顯示的結果會有所不同。例如,在雙螢幕模擬器中 (如下圖所示),您會收到 CurrentWindowMetrics,與模擬器所模仿的裝置尺寸相符。當應用程式以單螢幕模式執行時,您也可以查看相關指標:

b032c729d6dce292.png

當應用程式跨螢幕顯示時,視窗指標會有所變化 (如下圖所示),因此會反映該應用程式所使用的較大視窗區域:

882fc97252d1483b.png

由於應用程式會持續執行,並且會在單螢幕和雙螢幕上占據完整顯示區域,因此目前視窗指標和最大視窗指標會具有相同的值。

在採用水平折疊的折疊式模擬器中,如果應用程式橫跨整個實體螢幕,其值會與應用程式採用多視窗模式執行時有所不同:

8f3db697d9d76415.png

如左側圖片所示,由於執行中的應用程式是使用目前的整個顯示區域,也是最大的整個顯示區域,因此這兩個指標會具有相同的值。

但在右側圖片中,當應用程式是以多視窗模式執行時,您可以看到,目前指標會顯示在分割畫面模式的特定區域 (頂端) 所執行的應用程式區域尺寸,您也可以看到最高指標會顯示裝置的最大顯示區域。

WindowMetricsCalculator 提供的指標非常實用,可用於判斷應用程式正在使用或可以使用的視窗區域。

7. 以視覺化的方式呈現 FoldingFeature 資訊

我們現在會註冊,以接收視窗版面配置的變更,以及模擬器或裝置的 DisplayFeatures 特性和界線。

為了收集 WindowInfoTracker#windowLayoutInfo() 的資訊,我們會使用針對各個 Lifecycle 物件所定義的 lifecycleScope。當生命週期遭到破壞時,系統會取消在這個範圍內啟動的任何協同程式。您可以透過 lifecycle.coroutineScopelifecycleOwner.lifecycleScope 屬性,存取生命週期的協同程式範圍。

MainActivityonCreate 方法中,我們會呼叫一個函式,並且在後續步驟中實作此函式。此函將將用於取得和顯示 WindowInfoTracker 資訊。首先,我們會在 onCreate 方法中新增 onWindowLayoutInfoChange() 呼叫:

MainActivity.kt

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

   windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)

   obtainWindowMetrics()
   onWindowLayoutInfoChange()
}

每當有新的版面配置設定變更時,我們會透過該函式的實作來取得資訊。

一起來看看如何執行:

定義函式簽章和架構。

MainActivity.kt

private fun onWindowLayoutInfoChange() {
}

我們可以透過函式受到的參數 (WindowInfoTracker),取得其 WindowLayoutInfo 資料。WindowLayoutInfo 包含視窗內的 DisplayFeature 清單。舉例來說,轉軸或螢幕畫面折疊可以跨越整個視窗,因此建議您將視覺內容和互動元素分為兩個群組 (例如清單詳細資料或檢視畫面控制項)。

系統只會回報目前視窗範圍內顯示的功能。如果視窗經過移動或在螢幕上的大小有所調整,視窗的位置和大小可能也會隨之改變。

透過 lifecycle-runtime-ktx 依附元件中定義的 lifecycleScope,我們可以取得 WindowLayoutInfoflow (如先前所述),其中包含所有顯示功能的清單。現在,我們可以新增 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)
                }
        }
    }
}

如上一個步驟所示,我們在 collect 中呼叫了 updateUI 函式。現在,我們會實作這個函式,以顯示並列印我們從 WindowLayoutInfoflow 所取得的資訊。如您所見,這裡的邏輯非常簡單:我們只是要檢查 WindowLayoutInfo 資料是否具備顯示功能。如果是的話,顯示功能會以某種方式與應用程式的使用者介面互動。如果 WindowLayoutInfo 資料沒有任何顯示功能,系統就會在單螢幕裝置 (或模式) 或多視窗模式中執行。

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"
    }
}

我們來看看執行所有新程式碼時,會收到什麼結果。在雙螢幕模擬器中,您會有:

49a85b4d10245a9d.png

如您所見,WindowLayoutInfo 是空的。List<DisplayFeature> 沒有任何內容。然而,如果您的模擬器中間有轉軸,為什麼您無法從 WindowManager 取得資訊呢?

只有在應用程式橫跨多個螢幕 (不論是否為實體螢幕) 時,WindowManager 才會透過 WindowInfoTracker 提供 WindowLayoutInfo 資料,包含裝置功能類型、裝置功能邊界和裝置型態。在上個圖表中,應用程式在單螢幕模式中執行,因此 WindowLayoutInfo 是空的。

有了這個資訊,您就可以知道應用程式正在哪一個模式中執行 (單螢幕模式或跨螢幕),因此可以在使用者介面/使用者體驗中做出相應的調整,並根據這些特定設定提供更好的使用者體驗。

在沒有實體雙螢幕的裝置上 (通常不會有實體轉軸),應用程式可以透過多視窗模式並排執行。當應用程式在這些裝置上以多視窗模式執行時,其執行方式與先前範例中在單螢幕上執行時一樣。當應用程式執行占據了所有邏輯顯示時,其執行方式與橫跨多個螢幕時一樣。請參考下圖:

eacdd758eefb6c3c.png

如您所見,在多視窗模式中執行應用程式時,WindowManager 會提供空白的 List<LayoutInfo>

總而言之,當應用程式執行占據所有邏輯顯示時,您就能獲得 WindowLayoutInfo 資料,與裝置功能 (折疊或轉軸) 相輔相成。在其他情況下,系統不會提供任何資訊。32e4190913b452e4.png

如果讓應用程式橫跨多個螢幕顯示,會發生什麼事?在雙螢幕模擬器中,WindowLayoutInfo 會包含 FoldingFeature 物件,提供裝置功能的相關資料:HINGE 用於表示該功能的邊界 (Rect (0, 0 - 1434, 1800)),以及 FLAT 用於表示裝置型態 (狀態)。

faab87600a42a484.png

以下說明每個欄位代表的意義:

  • type = TYPE_HINGE:這個雙螢幕模擬器會模仿有實體轉軸的真實 Surface Duo 裝置,這也是 WindowManager 回報的內容。
  • Bounds [0, 0 - 1434, 1800]:代表在視窗座標空間內,功能在應用程式視窗中的邊界矩形。如果您參閱 Surface Duo 裝置的尺寸規格,就會發現轉軸的位置與這些邊界 (左側、頂端、右側、底部) 回報的位置完全相同。
  • State:有兩種不同的值可代表裝置型態 (狀態)。
  • HALF_OPENED:折疊式裝置的轉軸位於開啟和關閉狀態的中間位置,而且彈性螢幕的各個部分之間或實體螢幕面板之間,會有一個彎曲的角度。
  • FLAT:折疊式裝置完全打開,使用者看到的螢幕空間是平的。

根據預設,模擬器開啟的高度為 180 度,因此 WindowsManager 傳回的型態為 FLAT

如果使用虛擬感應器選項將模擬器的型態變更為半開啟的型態,WindowManager 會通知您新的位置:HALF_OPENED

bbfbab436850fb4e.png

使用 WindowManager 調整使用者介面/使用者體驗

如圖中顯示的視窗版面配置資訊所呈現,顯示功能會切割顯示的資訊,如下所示:

422aa9714bdb2892.png

您可以為使用者提供更好的體驗。您可以使用 WindowManager 提供的資訊來調整使用者介面/使用者體驗。

如先前所述,當您的應用程式橫跨不同的顯示區域時,應用程式也會與裝置功能互動,因此 WindowManager 會提供視窗版面配置資訊,做為顯示狀態和顯示界線。因此,當應用程式橫跨不同的顯示區域時,您需要使用該資訊並調整使用者介面/使用者體驗。

您要做的就是在應用程式橫跨不同的顯示區域時,調整執行階段目前適用的使用者介面/使用者體驗,以免重要資訊遭到截斷,或被顯示功能隱藏。您必須建立可反映裝置顯示功能的檢視畫面,這個檢視畫面會用來當做參考資訊,以限制遭到切除或隱藏的 TextView,以便免遺漏任何資訊。

為了方便學習,您需要為這個新的檢視畫面上色,以便清楚看到,這個檢視畫面與實際裝置的顯示功能位於相同的特定位置,也具有相同的尺寸。

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" />

MainActivity.kt 中,前往您用來顯示特定 WindowLayoutInfo 資訊的 updateUI() 函式,並在出現顯示功能的 if-else 案例中,新增函式呼叫:

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"
   }
}

您已將接收的 alignViewToFoldingFeatureBounds 函式新增為 WindowLayoutInfo 參數。

建立該函式。在函式中建立 ConstraintSet,以便為檢視畫面套用新的限制。然後,使用 WindowLayoutInfo 取得顯示功能邊界。由於 WindowLayoutInfo 只會傳回介面的 DisplayFeature 清單,因此我們必須將其投放到 FoldingFeature,才能存取我們需要的所有資訊:

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
}

我們定義了 getFeatureBoundsInWindow() 函式,以將功能邊界轉譯成檢視畫面的座標空間,以及視窗中的目前位置。

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
}

現在,我們有了顯示功能邊界的相關資訊,因此可以針對參考檢視畫面設定正確的高度,並視情況移動。

alignViewToFoldingFeatureBounds 的完整程式碼會是:

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)
    }
}

現在,與裝置顯示功能有所衝突的 TextView 會考量功能所在的位置,因此絕不會切除或隱藏相關內容:

5f671f3a33054970.png

在雙螢幕模擬器 (上方、左側) 中,您可以看到橫跨多個螢幕顯示內容的 TextView,以及轉軸所切斷內容不會再遭到切斷,因此不會遺漏任何資訊。

在折疊式模擬器 (上方、右側) 中,您會看到淡紅色線條代表折疊顯示功能所在的位置,而 TextView 現已置於此功能下方。因此,當裝置折疊時 (例如筆電型態的 90 度折疊),沒有任何資訊會受到此功能影響。

如果您想知道在雙螢幕模擬器中的顯示功能所在位置 (因為這是轉軸型裝置),實際上是轉軸隱藏了呈現功能的檢視畫面。不過,如果將應用程式從橫跨移動到非橫跨,您會發現其與功能位於相同位置,高度和寬度都正確。

5318e7a182ee9281.png

8. 其他 Jetpack WindowManager 構件

除了主要構件,WindowManager 還提供其他實用構件來協助您以不同方式與元件互動,同時將您目前建構應用程式所用的環境納入考量。

Java 構件

如果您使用的是 Java 程式設計語言,而非 Kotlin,或者透過呼叫回呼監聽事件是更好的架構,則可運用 WindowManager 的 Java 構件,因為它提供了適用於 Java 的 API,可用來透過回呼,註冊及取消註冊事件監聽器。

RxJava 構件

如果已經使用了 RxJava (23 版),則可以使用特定構件,無論您使用的是 ObservablesFlowables,這些特定構件都有助於維持程式碼的一致性。

9. 使用 Jetpack WindowManager 進行測試

測試模擬器或裝置上的可折疊型態非常實用,有助於測試 UI 元素在 FoldingFeature 周圍的配置方式。

WindowManger 隨附實用的檢測設備測試構件,以協助達成執行此功能。

一起來看看我們如何運用這項功能。

我們搭配主要的 WindowManager 依附元件,在應用程式 build.gradle 檔案中新增了測試構件:androidx.window:window-testing

window-testing 構件隨附了全新實用的 TestRule (名為 WindowLayoutInfoPublisherRule),可協助您使用一串 WindowLayoutInfo 值進行測試。WindowLayoutInfoPublisherRule 可讓您根據需求,透過不同的 WindowLayoutInfo 值進行推送。

為方便使用,並在此建立一個範例,協助您利用這項新構件測試使用者介面,我們將更新由 Android Studio 範本所建立的測試類別。將 ExampleInstrumentedTest 類別中的所有程式碼替換為下列內容:

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)
    }
}

如上所述,我們也會搭配上述規則建立 ActvityScenarioRule 並將其鏈結在一起。

為了模擬 FoldingFeature,新構件隨附了一對非常實用的功能。我們會採用最容易使用的功能以提供預設值。

MainActivity 中,我們會將 TextView 與折疊功能的左側對齊。我們要建立測試,確保實作方式正確無誤。

建立名為 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

}

如您所知,我們正在建立含有 FLAT 狀態且方向為 VERTICAL 的測試 FoldingFeature。我們定義了特定的大小,因為我們希望虛假的 FoldingFeature 顯示在測試中的使用者介面,以便我們查看其在裝置上所在的位置。

我們會使用之前執行個體化的 WindowLayoutInfoPublishRule 來發布虛假的 FoldingFeaure,以便取得採用實際 WindowLayoutInfo 資料的結果:

最後一個步驟是測試使用者介面元素的位置適當對齊,以避免 FoldingFeature。方法很簡單,只要使用 EspressoMatchers,在剛剛建立的測試結尾加入宣告即可:

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))
)

完整的測試如下:

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
)

您現在可以在裝置或模擬器上執行測試,檢查應用程式是否可正常運作。請注意,您不需要折疊式裝置或模擬器即可執行這項測試。

10. 恭喜!

正如本程式碼研究室所述,Jetpack WindowManager 讓應用程式可以適用於新的板型規格裝置,例如折疊式裝置。

WindowManager 提供的資訊有助於根據折疊式裝置調整應用程式,以便提供最佳的使用者體驗。

簡而言之,在這個程式碼研究室中,我們已說明以下內容:

  • 什麼是折疊式裝置?
  • 不同折疊式裝置的差異
  • 折疊式裝置、單螢幕裝置和平板電腦之間的差異
  • Jetpack WindowManager API
  • 使用 Jetpack WindowManager,並根據新的裝置版型規格調整我們的應用程式
  • 使用 Jetpack WindowManager 進行測試

瞭解詳情