Support foldable and dual-screen devices with Jetpack WindowManager

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:

  1. Single-screen foldable device : Equipped with a foldable screen. In multi-window mode, users can run multiple applications simultaneously on the same screen.

  2. 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-windowmode).

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 FoldingFeatureclasses 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 whether FoldingFeaturethe 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 determine FoldingFeaturewhether to obscure part of the window.
  • orientation()FoldingFeature: Returns if width is greater than height FoldingFeature.Orientation.HORIZONTAL; otherwise, returns FoldingFeature.Orientation.VERTICAL.
  • bounds(): Provides an Rectinstance that contains the boundaries of a device's functionality, such as the boundaries of a physical hinge.

With WindowInfoTrackerthe interface, you can access windowLayoutInfo()a collection of all available DisplayFeatureprocesses 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 WindowInfoTrackeraccess 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.xmlthe 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 MainActivityof onCreate, call a function to get and display WindowMetrics the information. onCreateAdd 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 WindowMetricsCalculatorinstance to set the information into windowMetricsthe TextView. Use the values ​​returned by the functions computeCurrentWindowMetrics().boundsand .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.

WindowMetricsCalculatorThe 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 DisplayFeaturesproperties and boundaries.

To be able to WindowInfoTracker#windowLayoutInfo()collect information from , use the Lifecycleone defined for each object lifecycleScope. Coroutines started within this scope will Lifecyclebe canceled when destroyed. You can access the coroutine scope via lifecycle.coroutineScopethe or lifecycleOwner.lifecycleScopeattribute .Lifecycle

In the method MainActivityof onCreate, call a function to obtain and display WindowInfoTrackerthe information. First, onCreateadd 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

WindowInfoTrackerGet its data with the help of the parameters received by this function WindowLayoutInfo. WindowLayoutInfoContains 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-ktxthe dependencies , which contains a list of all display functions. Add the text of :lifecycleScopeWindowLayoutInfoonWindowLayoutInfoChange

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

collectCalling function from updateUI. Implement this function to display and output WindowLayoutInfoinformation received from flow. Check WindowLayoutInfowhether the data has display function. If so, the display functionality interacts with the app's interface in some way. If WindowLayoutInfothe 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

WindowLayoutInfois empty, which contains an empty one List<DisplayFeature>. But if the simulator has a hinge in the middle, why not WindowManagerget the information from ?

WindowManagerData (device capability type, device capability boundary, and device collapse state) are provided (by WindowInfoTracker) when the app spans screens (either physically or virtually) . WindowLayoutInfoSo, in the image above, when the app is running in single-screen mode, WindowLayoutInfoit is empty.

When handling WindowLayoutInfothe 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>.

WindowLayoutInfoIn 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 FoldingFeatureobject 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 TextViewa 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 WindowLayoutInfoinformation updateUI()and add a if-elsenew 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 alignViewToFoldingFeatureBoundsthat receives WindowLayoutInfoas a parameter.

Create the corresponding function. Within the function, create a ConstraintSetto apply new constraints to your view. Then, use WindowLayoutInfoto get the bounds of the display function. Since WindowLayoutInforeturns a DisplayFeaturelist that is used only as an interface, it should be converted to in FoldingFeatureorder 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.

alignViewToFoldingFeatureBoundsThe 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 TextViewwill 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 WindowManagerJava 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 RxJavayou are using .ObservablesFlowables

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/

Guess you like

Origin blog.csdn.net/u011897062/article/details/135267552