为了测试相关功能,你需要安装Android Studio Arctic Fox 或更高版本以及
可折叠设备或模拟器。
Android 模拟器 v30.0.6 及更高版本支持可折叠设备,以及虚拟合页传感器和 3D 视图。您可以使用下图中所示的几种可折叠设备模拟器:
如需使用双屏设备模拟器,请点击下载适用于您的平台(Windows、MacOS 或 GNU/Linux)的 Microsoft Surface Duo 模拟器。
与之前的移动设备相比,可折叠设备为用户提供了更大的屏幕和更灵活多变的界面。折叠后,这类设备通常比普通平板电脑更小,更方便携带且实用。
目前市场上有两种可折叠设备可供选择:
单屏可折叠设备:配备一个可折叠屏幕。在多窗口模式下,用户可以在同一屏幕上同时运行多个应用程序。
双屏可折叠设备:两个屏幕通过合页连接。这类设备也可以折叠,但具有两个不同的逻辑显示区域。
与平板电脑和其他单屏移动设备一样,可折叠设备可以:
multi-window
模式下)。与单屏设备不同的是,可折叠设备还支持不同的折叠状态,这些状态可以以不同的方式显示内容。
当应用程序跨越整个显示区域(利用双屏可折叠设备上的所有显示区域)显示时,可折叠设备可以提供不同的跨屏折叠状态。
可折叠设备还支持不同的折叠状态。例如,在桌面模式下,您可以在将屏幕平放和屏幕朝向自己倾斜之间进行逻辑拆分;在帐篷模式下,您可以像使用小支架一样支撑设备来观看内容。
Jetpack WindowManager库可帮助应用开发者支持新的设备外形规格,并为新旧版本平台上的各种WindowManager功能提供通用的API Surface。
https://developer.android.google.cn/jetpack/androidx/releases/window?hl=zh-cn
Jetpack WindowManager版本1.1.0包含FoldingFeature
类,该类用于描述柔性显示屏的折叠状态或两个物理显示面板之间的合页状态。您可以通过其API访问与设备相关的重要信息:
state()
: 提供设备当前的折叠状态,该状态为已定义的折叠状态(FLAT和HALF_OPENED)列表中的一种。isSeparating()
: 计算是否应将FoldingFeature
视为将窗口拆分为多个物理区域,而用户可以将这些区域视为逻辑上互相独立的区域。occlusionType()
: 计算遮挡模式,以确定FoldingFeature
是否遮挡窗口的一部分。orientation()
: 如果FoldingFeature
宽度大于高度,则返回FoldingFeature.Orientation.HORIZONTAL
;否则,返回FoldingFeature.Orientation.VERTICAL
。bounds()
: 提供一个Rect
实例,其中包含设备功能的边界,例如物理合页的边界。借助WindowInfoTracker
接口,您可以访问windowLayoutInfo()
以收集包含所有可用DisplayFeature
的WindowLayoutInfo
的流程。
声明依赖项
为了能够使用 Jetpack WindowManager,请在应用或模块的 build.gradle 文件中添加相关依赖项:
//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"
}
您可通过 WindowManager 的 WindowInfoTracker
接口访问窗口功能。
打开 MainActivity.kt 源文件并调用 WindowInfoTracker.getOrCreate(this@MainActivity)
,以初始化与当前 activity 相关联的 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)
}
从 Jetpack WindowManager 中,获取窗口指标、布局和显示配置的相关信息。在主 activity 布局中显示这些信息,并针对每项信息使用 TextView
。
创建一个包含三个 TextView
并在屏幕上居中显示的 ConstraintLayout
。
打开 activity_main.xml
文件,然后粘贴以下内容:
//activity_main.xml
<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>
现在,我们将使用视图绑定功能在代码中关联这些界面元素。为此,我们首先在应用的 build.gradle 文件中启用该功能:
//app/build.gradle
android {
// Other configurations
buildFeatures {
viewBinding true
}
}
//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)
}
}
在 MainActivity
的 onCreate
方法中,调用一个函数来获取并显示 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
的实例。
val calculator = WindowMetricsCalculator.getOrCreate()
借助该 WindowMetricsCalculator
实例,将信息设置到 windowMetrics
TextView 中。使用由函数 computeCurrentWindowMetrics().bounds
和 computeMaximumWindowMetrics().bounds
返回的值。
val currentWindowMetricsBounds = calculator.computeCurrentWindowMetrics().bounds
val maximumWindowMetricsBounds = calculator.computeMaximumWindowMetrics().bounds
windowMetrics.text = "当前窗口指标:$currentWindowMetricsBounds\n最大窗口指标:$maximumWindowMetricsBounds"
这些值会提供有关窗口所占区域各项指标的实用信息。
运行应用。在双屏设备模拟器中(如下图所示),您会得到与模拟器所镜像的设备尺寸对应的 CurrentWindowMetrics
。您还可以查看应用在单屏模式下运行时的指标。
当应用跨显示屏显示时,窗口指标会发生变化(如下图所示),因此它们现在反映的应用所用窗口区域比之前大:
由于该应用在单屏和双屏设备上始终运行并占满整个显示区域,因此当前窗口指标和最大窗口指标的值相同。
在有水平折叠边的可折叠设备模拟器中,这些值在应用跨整个物理显示屏运行时和在多窗口模式下运行时会有所不同:
如左图所示,这两个指标的值相同,这是因为运行的应用占满了整个显示区域,该区域既是当前的显示区域,也是最大显示区域。
但在右图中,应用在多窗口模式下运行,您可看到当前指标如何显示应用在分屏模式下的特定区域(顶部)运行时所占区域的尺寸,还可看到最大指标如何显示设备的最大显示区域。
WindowMetricsCalculator
提供的指标对于确定应用当前使用或可以使用的窗口区域非常有用。
现在进行注册,以便接收窗口布局变化,以及模拟器或设备 DisplayFeatures
的特性和边界。
为了能从 WindowInfoTracker#windowLayoutInfo()
收集信息,使用为每个 Lifecycle
对象定义的 lifecycleScope
。在此作用域内启动的协程会在 Lifecycle
被销毁时取消。您可以通过 lifecycle.coroutineScope
或 lifecycleOwner.lifecycleScope
属性访问 Lifecycle
的协程作用域。
在 MainActivity
的 onCreate
方法中,调用一个函数来获取并显示 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
,获取 WindowLayoutInfo
的 flow,其中包含所有显示功能的列表。添加 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
函数。实现此函数,以便显示和输出从 WindowLayoutInfo
的 flow 收到的信息。检查 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"
}
}
WindowLayoutInfo
为空,其中包含一个空的 List
。但如果模拟器的中间位置有合页,为什么不从 WindowManager
获取信息呢?
WindowManager
会(通过 WindowInfoTracker
)在应用跨屏显示(以物理或虚拟方式)时提供 WindowLayoutInfo
数据(设备功能类型、设备功能边界和设备折叠状态)。因此,在上图中,应用在单屏模式下运行时,WindowLayoutInfo
为空。
在处理 WindowLayoutInfo
为空的情况时,您可以根据应用当前的屏幕配置来更改界面/用户体验,以提供更好的用户体验。例如,在没有两个物理显示屏的设备(通常没有物理合页)上,应用可以在多窗口模式下并排运行。在这种情况下,当应用在多窗口模式下运行时,其行为将与在单屏模式下的行为一样。当应用在占满屏幕时运行时,其行为就像跨屏一样。当应用在运行时占满逻辑显示屏时,其行为就像跨屏一样。请看下图:
当应用在多窗口模式下运行时,WindowManager 会提供一个空的 List
。
简而言之,只有在应用在运行时占满逻辑显示屏、与设备功能(折叠边或合页)互动的情况下,您才会获得 WindowLayoutInfo
数据。在所有其他情况下,您都无法获得任何信息。
应用跨屏显示时会出现什么情况?在双屏设备模拟器中,WindowLayoutInfo
的 FoldingFeature
对象将提供以下数据:设备功能 (HINGE
)、该功能的边界 (Rect
[0, 0 - 1434, 1800]),以及设备的折叠状态 (FLAT
)。
type = TYPE_HINGE
:此双屏设备模拟器镜像的是具有物理合页的真实 Surface Duo 设备,WindowManager 报告的结果也是如此。Bounds [0, 0 - 1434, 1800]
:表示窗口坐标空间中应用窗口内功能的边界矩形。如果您已阅读 Surface Duo 设备的尺寸规范,便会发现合页的位置与这些边界(左、上、右、下)报告的情况完全一致。State
:表示设备的折叠状态的值有三个。
HALF_OPENED
:可折叠设备的合页处于展开状态和闭合状态的中间位置,且柔性屏幕的各部分之间或物理屏幕之间的夹角不是平角。FLAT
:可折叠设备处于完全打开状态,且呈现给用户的屏幕区域是平面。FLAT
。HALF_OPENED
。如显示窗口布局信息的图所示,显示的信息被显示功能裁剪,同样的情况也发生在这里:
这并非最佳用户体验。您可以利用 WindowManager 提供的信息来调整界面/用户体验。
如前所述,当您的应用跨越不同的显示区域时,也是您的应用与设备功能互动时,WindowManager 会提供窗口布局信息作为显示状态和显示的边界。因此,当应用跨屏显示时,您便需要根据这些信息来调整界面/用户体验。
接下来,您要做的就是调整目前应用在运行时跨屏显示的界面/用户体验,确保任何重要信息都不会被显示功能裁剪或隐藏。您将创建一个镜像设备显示功能的视图,并将其作为约束 TextView
的参考,确保任何信息都不会再被剪裁或隐藏。
为便于学习,请设置此新视图的颜色,以便用户能够轻松看出该视图的位置与真实的设备显示功能的位置完全一致,且尺寸相同。
在 activity_main.xml
中添加将用作设备功能参考的新视图:
<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
会将该功能的位置考虑在内,以确保其内容不会再被裁剪或隐藏:
在双屏模拟器中(上方左图),您可以看到 TextView 如何跨屏显示内容,曾被合页裁剪的内容已正常显示,任何信息都没有缺失。
在可折叠设备模拟器中(上方右图),您会看到一条表示折叠显示功能所在位置的浅红色线,现在 TextView 显示在该功能下方。因此当设备处于折叠状态时(例如,笔记本电脑处于 90 度折叠状态),该功能不会影响任何信息的显示。
如果您想知道显示功能在双屏设备模拟器上的位置(由于这是一种合页型设备),用于展示该功能的视图是被合页隐藏了。但是,如果应用从跨屏显示更改为不跨屏显示,您就会在功能所在位置看到该视图,且视图高度和宽度正确无误。
如果您使用的是 Java 编程语言而非 Kotlin,或者通过回调来监听事件对您的架构而言是更好的方法,则 WindowManager
的 Java 工件可能会很有用,因为它提供了适合 Java 的 API,以便通过回调来注册和取消注册事件的监听器。
如果您已经在使用 RxJava
(版本 2 或 3),则无论您使用的是 Observables
还是 Flowables
,都可以使用特定工件来帮助您在代码中保持一致性。
Jetpack WindowManager 可帮助开发者使用新型设备(例如可折叠设备)。
在使 Android 应用适应可折叠设备以提供更好的用户体验方面,WindowManager 提供的信息非常有用。
https://github.com/android/large-screen-codelabs
https://developer.android.google.cn/guide/topics/ui/foldables?hl=zh-cn
https://developer.android.google.cn/guide/topics/ui/multi-window?hl=zh-cn
https://github.com/android/platform-samples/tree/main/samples/user-interface/windowmanager
https://docs.microsoft.com/dual-screen/android/