Support foldable and dual-screen devices with Jetpack WindowManager
Preparation
In order to test the functionality, you'll need Android Studio Arctic Fox or higher and
a foldable device or emulator .
Android emulator v30.0.6 and higher supports foldable devices, as well as virtual hinge sensors and 3D views. There are several foldable device emulators available to you, as shown in the image below:
To use a dual-screen device emulator, click to download the Microsoft Surface Duo emulator for your platform (Windows, MacOS, or GNU/Linux).
Single-screen devices and foldable devices
Foldable devices provide users with larger screens and more flexible interfaces than previous mobile devices. When folded, such devices are often smaller than regular tablets, making them more portable and practical.
There are currently two foldable devices available on the market:
-
Single-screen foldable device : Equipped with a foldable screen. In multi-window mode, users can run multiple applications simultaneously on the same screen.
-
Dual-screen foldable device : Two screens are connected by a hinge. Such devices can also be folded but have two different logical display areas.
Like tablets and other single-screen mobile devices, foldable devices can:
- Run an application in a display area.
- Run two applications in two display areas simultaneously (in
multi-window
mode).
Unlike single-screen devices, foldable devices also support different folding states that can display content in different ways.
Foldable devices can provide different cross-screen folding states when an application is displayed across the entire display area (utilizing all display areas on a dual-screen foldable device).
Foldable devices also support different folding states. For example, in tabletop mode you can logically split between laying the screen flat and tilting it towards you, and in tent mode you can prop up the device like a little stand to watch content.
Jetpack WindowManager
The Jetpack WindowManager library helps application developers support new device form factors and provides a common API surface for various WindowManager functions on both old and new platforms.
https://developer.android.google.cn/jetpack/androidx/releases/window?hl=zh-cn
The main function
Jetpack WindowManager version 1.1.0 contains FoldingFeature
classes that describe the folded state of a flexible display or the hinged state between two physical display panels. You can access important device-related information through its API:
state()
: Provides the current folding state of the device, which is one of the defined folding states (FLAT and HALF_OPENED).isSeparating()
: Computes whetherFoldingFeature
the window should be considered to be split into multiple physical regions that the user can treat as logically separate regions.occlusionType()
: Calculate the occlusion pattern to determineFoldingFeature
whether to obscure part of the window.orientation()
FoldingFeature
: Returns if width is greater than heightFoldingFeature.Orientation.HORIZONTAL
; otherwise, returnsFoldingFeature.Orientation.VERTICAL
.bounds()
: Provides anRect
instance that contains the boundaries of a device's functionality, such as the boundaries of a physical hinge.
With WindowInfoTracker
the interface, you can access windowLayoutInfo()
a collection of all available DisplayFeature
processes WindowLayoutInfo
.
Jetpack WindowManager installation configuration
Declare dependencies
To be able to use Jetpack WindowManager, add the relevant dependencies in your app or module's build.gradle file:
//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"
}
Using WindowManager
You WindowInfoTracker
access window functionality through the WindowManager's interface.
Open the MainActivity.kt source file and call it WindowInfoTracker.getOrCreate(this@MainActivity)
to initialize the instance associated with the current 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)
}
Set up application interface
Get information about window indicators, layout, and display configuration from Jetpack WindowManager. Display this information in the main activity layout and use it for each item TextView
.
Create one that contains three TextView
and is centered on the screen ConstraintLayout
.
Open activity_main.xml
the file and paste the following:
//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>
Now we will use view binding functionality to associate these interface elements in code. To do this, we first enable the feature in our app's build.gradle file:
//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)
}
}
Visual presentation of WindowMetrics information
In the method MainActivity
of onCreate
, call a function to get and display WindowMetrics
the information. onCreate
Add the call in the method obtainWindowMetrics()
:
//MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)
obtainWindowMetrics()
}
Implementation 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}"
}
Get WindowMetricsCalculator instance
getOrCreate()
Get an instance of through the companion function WindowMetricsCalculator
.
val calculator = WindowMetricsCalculator.getOrCreate()
Use this WindowMetricsCalculator
instance to set the information into windowMetrics
the TextView. Use the values returned by the functions computeCurrentWindowMetrics().bounds
and .computeMaximumWindowMetrics().bounds
val currentWindowMetricsBounds = calculator.computeCurrentWindowMetrics().bounds
val maximumWindowMetricsBounds = calculator.computeMaximumWindowMetrics().bounds
windowMetrics.text = "当前窗口指标:$currentWindowMetricsBounds\n最大窗口指标:$maximumWindowMetricsBounds"
These values provide useful information about various metrics about the area occupied by the window.
Run the application. In the dual-screen device simulator (shown below), you will get the dimensions corresponding to the device the simulator is mirroring CurrentWindowMetrics
. You can also view metrics when your app is running in single-screen mode.
When an app is displayed across displays, the window metrics change (as shown in the image below) so that they now reflect a larger window area used by the app than before:
since the app always runs and takes up the entire Display area, so the current window indicator and the maximum window indicator have the same value.
In the Foldable Device Simulator with horizontal folding edges, these values differ when the app runs across the entire physical display and when running in multi-window mode:
As shown in the figure on the left, the values of these two indicators are the same because the running application occupies the entire display area, which is both the current display area and the maximum display area.
But in the image on the right, the app is running in multi-window mode, and you can see how the current metric shows the size of the area occupied by the app when running in a specific area (top) in split-screen mode, and how the maximum metric shows The maximum display area of the device.
WindowMetricsCalculator
The metrics provided are useful for determining the area of the window that an app is currently using or can use.
Visually present FoldingFeature information
Register window layout change listener
Register now to receive window layout changes, as well as emulator or device DisplayFeatures
properties and boundaries.
To be able to WindowInfoTracker#windowLayoutInfo()
collect information from , use the Lifecycle
one defined for each object lifecycleScope
. Coroutines started within this scope will Lifecycle
be canceled when destroyed. You can access the coroutine scope via lifecycle.coroutineScope
the or lifecycleOwner.lifecycleScope
attribute .Lifecycle
In the method MainActivity
of onCreate
, call a function to obtain and display WindowInfoTracker
the information. First, onCreate
add onWindowLayoutInfoChange()
the call in the method:
//MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
windowInfoTracker = WindowInfoTracker.getOrCreate(this@MainActivity)
obtainWindowMetrics()
onWindowLayoutInfoChange()
}
An implementation of this function is used to obtain information whenever a new layout configuration changes.
Define function signatures and frames.
//MainActivity.kt
private fun onWindowLayoutInfoChange() {
}
Get window layout information
WindowInfoTracker
Get its data with the help of the parameters received by this function WindowLayoutInfo
. WindowLayoutInfo
Contains the list located within the window DisplayFeature
. For example, a hinge or display fold can pass through a window, in which case it may be necessary to separate visual content and interactive elements into two groups (such as list details or view controls).
Only features displayed within the boundaries of the current window are reported. If a window is moved or resized on the screen, its position and size may change.
Gets the flow defined in lifecycle-runtime-ktx
the dependencies , which contains a list of all display functions. Add the text of :lifecycleScope
WindowLayoutInfo
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
Calling function from updateUI
. Implement this function to display and output WindowLayoutInfo
information received from flow. Check WindowLayoutInfo
whether the data has display function. If so, the display functionality interacts with the app's interface in some way. If WindowLayoutInfo
the data does not have any display functionality, it means the app is running on a single-screen device/mode or in multi-window mode.
//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"
}
}
Run the application. In the dual-screen device simulator, you will get the following results:
Handle the situation when WindowLayoutInfo is empty
WindowLayoutInfo
is empty, which contains an empty one List<DisplayFeature>
. But if the simulator has a hinge in the middle, why not WindowManager
get the information from ?
WindowManager
Data (device capability type, device capability boundary, and device collapse state) are provided (by WindowInfoTracker
) when the app spans screens (either physically or virtually) . WindowLayoutInfo
So, in the image above, when the app is running in single-screen mode, WindowLayoutInfo
it is empty.
When handling WindowLayoutInfo
the case where is empty, you can change the interface/UX based on your app's current screen configuration to provide a better user experience. For example, on devices that don't have two physical displays (and typically don't have physical hinges), apps can run side by side in multi-window mode. In this case, when the app is running in multi-window mode, it will behave the same as it does in single-screen mode. When an app runs to fill the screen, it behaves as if it were spanning multiple screens. When an app fills the logical display while running, it behaves as if it spans multiple screens. Please look at the picture below:
When the app is running in multi-window mode, the WindowManager provides an empty one List<LayoutInfo>
.
WindowLayoutInfo
In short, you only get data when your app fills the logical display while running and interacts with device features (folding edges or hinges) . In all other cases, you get no information.
What happens when an app is displayed across multiple screens? In a dual-screen device simulator, WindowLayoutInfo
the FoldingFeature
object will provide the following data: the device's capability ( HINGE
), the bounds of that capability ( Rect
[0, 0 - 1434, 1800]), and the device's collapsed state ( FLAT
).
Let's take a look at what each field means:
type = TYPE_HINGE
: This dual-screen device emulator mirrors a real Surface Duo device with a physical hinge, as are the results reported by WindowManager.Bounds [0, 0 - 1434, 1800]
: Represents the bounding rectangle of the function within the application window in the window coordinate space. If you've read the dimensional specifications for Surface Duo devices, you'll notice that the hinges are positioned exactly as reported for these boundaries (left, top, right, bottom).State
: There are three values indicating the folded state of the device.HALF_OPENED
: The hinge of a foldable device is in the middle position between the expanded and closed states, and the angles between parts of the flexible screen or between the physical screens are not straight angles.FLAT
: The foldable device is fully open and the screen area presented to the user is flat.- The simulator is opened 180 degrees by default, so the collapsed state returned by WindowManager is
FLAT
.
- If you use the virtual sensor option to change the emulator's folded state to the half-open state, WindowManager will notify you of the new position:
HALF_OPENED
.
Adjust the interface/user experience through WindowManager
As shown in the figure showing window layout information, the displayed information is clipped by the display function, and the same thing happens here:
it's not the best user experience. You can use the information provided by WindowManager to adjust the interface/user experience.
As mentioned earlier, when your app spans different display areas and when your app interacts with device functionality, the WindowManager provides window layout information as display state and display boundaries. Therefore, when your app spans multiple screens, you need to adjust the interface/user experience based on this information.
Next, all you have to do is adjust the interface/user experience of the current cross-screen display of your application at runtime to ensure that any important information is not cropped or hidden by the display function. You'll create a view that mirrors the device's display capabilities and uses it as TextView
a reference for constraints, ensuring that no information is ever clipped or hidden.
To make learning easier, set the color of this new view so that users can easily see that the view is positioned exactly where the real device displays the functionality, and at the same size.
Add activity_main.xml
a new view that will be used as a reference for device capabilities:
<!-- It's not important where this view is placed by default, it will be positioned dynamically at runtime 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" />
In MainActivity.kt
, find the function used to display the given WindowLayoutInfo
information updateUI()
and add a if-else
new function call that is made if the app has display capabilities:
//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"
}
}
You have added a function alignViewToFoldingFeatureBounds
that receives WindowLayoutInfo
as a parameter.
Create the corresponding function. Within the function, create a ConstraintSet
to apply new constraints to your view. Then, use WindowLayoutInfo
to get the bounds of the display function. Since WindowLayoutInfo
returns a DisplayFeature
list that is used only as an interface, it should be converted to in FoldingFeature
order to access all information:
//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
}
Define a getFeatureBoundsInWindow()
function that converts the feature bounds to the view's coordinate space and current position in the window.
//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
}
Using the information about the bounds of the display feature, you can set the correct height size for the reference view and move the reference view accordingly.
alignViewToFoldingFeatureBounds
The complete code is as follows:
//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)
}
}
Now, features that once conflicted with the device's display TextView
will take that feature's position into account to ensure that its content is no longer cropped or hidden:
In the dual-screen simulator (above left), you can see how the TextView spans The screen displays content, and the content that was once clipped by the hinge is displayed normally, and no information is missing.
In the Foldable Device Simulator (above, right), you'll see a light red line indicating where the foldable display function is, with the TextView now appearing below it. Therefore, this feature does not affect the display of any information when the device is folded (for example, a laptop is folded 90 degrees).
If you're wondering where the display function is on the dual-screen device emulator (since this is a hinge-type device), the view used to display the function is hidden by the hinge. However, if the app changes from spanning to not spanning, you'll see the view where the functionality is, with the correct height and width.
Jetpack WindowManager other components
Java artifacts
If you are using the Java programming language instead of Kotlin, or if listening to events via callbacks is a better approach for your architecture, the WindowManager
Java artifact may be useful as it provides a Java-friendly API to Callbacks to register and unregister event listeners.
RxJava artifacts
If you are already using (version 2 or 3), there are specific artifacts available to help you maintain consistency in your code, regardless of whether RxJava
you are using .Observables
Flowables
in conclusion
Jetpack WindowManager helps developers work with new types of devices, such as foldables.
The information provided by WindowManager is very useful in adapting Android applications to foldable devices to provide a better user experience.
Github
https://github.com/android/large-screen-codelabs
reference
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/