Support foldable and dual-screen devices with Jetpack WindowManager

1. Before you begin

This practical codelab will teach you the basics of developing for dual-screen and foldable devices. When you're finished, you'll be able to enhance your app to support devices like the Microsoft Surface Duo and the Samsung Galaxy Z Fold3.

Prerequisites

To complete this codelab, you'll need:

What you'll do

  • Create a simple app that displays the device features.
  • Detect when the application is running on a foldable or dual-screen device.
  • Determine the device state.
  • Use Jetpack WindowManager to work with new form factor devices.

What you'll need

7203779994e5c01d.png

  • If you want to use a dual-screen emulator, you can download the Microsoft Surface Duo emulator for your platform (Windows, MacOS or GNU/Linux) here.

2. Single screen devices vs. foldable devices

Foldable devices offer users a bigger screen and more versatile user interface than previously available in a mobile device. When folded, these devices are often smaller than a common-size tablet, making them more portable and functional.

At the time of this writing, there are two types of foldable devices:

  • Single-screen foldable devices, with one screen that can be folded. Users can run multiple apps on the same screen at the same time using multi-window mode.
  • Dual-screen foldable devices, with two screens joined by a hinge. These devices can be folded as well, but they have two different logical display regions.

affbd6daf04cfe7b.png

Like tablets and other single screen mobile devices, foldables can:

  • Run one app in one of the display regions.
  • Run two apps side by side, each one on a different display region (using Multi-window mode).

Unlike single screen devices, foldable devices also support different postures. Postures can be used to display content in different ways.

f2287b68f32b59e3.png

Foldable devices can offer different spanning postures when an app is spanned (displayed) across the whole display region (using all display regions on dual-screen foldable devices).

Foldable devices can also offer folded postures, like tabletop mode, so you can have a logical split between the part of the screen that's flat and the part that's tilted towards you, and tent mode, so you can visualize the content as if the device was using a stand gadget.

3. Jetpack WindowManager

The Jetpack WindowManager library helps application developers support new device form factors and provides a common API surface for various WindowManager features on both old and new platform versions.

Key features

Jetpack WindowManager version 1.0.0-beta03 contains the FoldingFeature class that describes a fold in the flexible display or a hinge between two physical display panels. Its API provides access to important information related to the device:

Using the WindowInfoRepository interface you can access windowLayoutInfo() to collect a Flow of WindowLayoutInfo that contains all the available DisplayFeatures.

4. Set up

Create a new project and select the "Empty Activity" template:

e266da71551681b8.png

You can leave all the parameters as their default.

Declare dependencies

In order to use Jetpack WindowManager, you have to add the dependency in the build.gradle file for your app or module:

app/build.gradle

dependencies {
    ext.windowmanager_version = "1.0.0-beta03"

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

Using WindowManager

You can access window features via WindowManager's WindowInfoRepository interface.

Open the MainActivity.kt source file and call windowInfoResository() to initialize the WindowInfoRepository instance associated with the current activity:

MainActivity.kt

import androidx.window.layout.WindowInfoRepository
import androidx.window.layout.WindowInfoRepository.Companion.windowInfoRepository

private lateinit var windowInfoRepository: WindowInfoRepository

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        windowInfoRepository = windowInfoRepository()
}

Once you have your WindowInfoRepository instance, you will be able to get information about the current Window state of the device.

5. Set up the application UI

From Jetpack WindowManager we can get information about window metrics, layout and display configuration info. Let's show this in the main activity layout, using a TextView for each of these.

To do this, we'll need a ConstraintLayout, with 3 TextViews, centered.

Open the activity_main.xml file and paste the following content:

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>

We are now going to connect these UI elements in the code using view binding. To do this we start enabling it in the application's build.gradle file:

app/build.gradle

android {
   // Other configurations

   buildFeatures {
      viewBinding true
   }
}

We can now sync the gradle project as suggested by Android Studio and use view binding in MainActivity.kt using the following code:

MainActivity.kt

class MainActivity : AppCompatActivity() {
    private lateinit var windowInfoRepository: WindowInfoRepository
    private lateinit var binding: ActivityMainBinding

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

        windowInfoRepository = windowInfoRepository()
    }
}

6. Visualize the WindowMetrics information

In the onCreate() of the MainActivity, we will call a function that will be implemented in next steps and that will be used to get and show the WindowMetrics information. We will start adding obtainWindowMetrics()call in the onCreate method:

MainActivity.kt

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

   windowInfoRepository = windowInfoRepository()

   obtainWindowMetrics()
}

Now we are going to implement the first function we just invoked above, 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}"
}

As we can see above, we get an instance of WindowMetricsCalculator through its companion function getOrCreate().

Using that windowMetricsCalculator instance, we set the information into the windowMetrics TextView. We use the values that the functions computeCurrentWindowMetrics.bounds and computeMaximumWindowMetrics.bounds return.

These values provide useful information about the metrics of the area the window occupies.

Run the app. Depending on the foldable device you use, you will get different results. For example in a dual-screen emulator, pictured below, you get the CurrentWindowMetrics that fit with the dimensions of the device that mirrors. You can also see the metrics when the app runs in single-screen mode:

b032c729d6dce292.png

When the app is spanned across displays, the window metrics change, like in the image below, so they now reflect the bigger window area used by the app:

882fc97252d1483b.png

Both the current and maximum window metrics have the same values, since the app is always running and taking the whole available display area, both on single and dual-screen.

In a foldable emulator with a horizontal fold the values differ when the app is run spanned across the entire physical display and using multi-window:

b260d1cbe8238976.png

As you can see in the image on the left, both metrics have the same value, since the app running is using the whole display area that is the current and maximum available.

But in the image on the right, with the app running in multi-window mode, you can see how the current metrics show the dimensions of the area the app is running in that specific area (top) of the split-screen, and you can see how the maximum metrics show the maximum display area that the device has.

The metrics provided by WindowMetricsCalculator are very useful in order to know the area of the window the app is using or can use.

7. Visualize the FoldingFeature information

Now we'll register to receive window layout changes and know characteristics and boundaries of the DisplayFeatures of the emulator/device.

To collect the information from WindowInfoRepository#windowLayoutInfo() we are going to use the lifecycleScope that is defined for each Lifecycle object. Any coroutine launched in this scope is canceled when the Lifecycle is destroyed. You can access the CoroutineScope of the Lifecycle either via lifecycle.coroutineScope or lifecycleOwner.lifecycleScope properties.

In the onCreate() of the MainActivity, we will call a function that will be implemented in next steps and that will be used to get and show the WindowInfoRepository information. We will start adding onWindowLayoutInfoChange()call in the onCreate() method:

MainActivity.kt

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

   windowInfoRepository = windowInfoRepository()

   obtainWindowMetrics()
   onWindowLayoutInfoChange()
}

We will use the implementation of that function to get information whenever a new layout configuration changes.

Let's see how we can do it:

Define the function signature and skeleton.

MainActivity.kt

private fun onWindowLayoutInfoChange() {
}

With the parameter the function receives, a WindowInfoRepository, we can get its WindowLayoutInfo data. WindowLayoutInfo contains the list of DisplayFeatures located within the window. For example, a hinge or display fold can go across the window, in which case it might make sense to separate the visual content and interactive elements into two groups, e.g. list-detail or view controls.

Only the features that are present within the current window bounds are reported. Their positions and sizes can change if the window is moved or resized on screen.

Through the coroutines scope that we defined previously, we can get a flow of WindowLayoutInfo, that as we have mentioned, contains a list of all display features. Inside the function declared before, we can add its body:

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.launc

private fun onWindowLayoutInfoChange() {
lifecycleScope.launch(Dispatchers.Main) {
   lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
       windowInfoRepository.windowLayoutInfo.collect { value ->
           updateUI(value)
       }
   }
}

As you have seen in the previous step, inside collect we were calling updateUI() function. Now we will implement this function to show and print the information we have gotten from the flow of WindowLayoutInfo. As you see, the logic here is very basic; we are just checking whether or not the WindowLayoutInfo data we have contains display features. If it has any, then the display feature is interacting somehow with our app's UI; if there are not any, then we will be running in 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"
    }
}

Let's see now what we get when we run all the new code. In a dual-screen emulator, you will have:

49a85b4d10245a9d.png

As you can see, WindowLayoutInfo is empty. It has an empty List<DisplayFeature>, but if you have an emulator with a hinge in the middle, why don't you get the information from WindowManager?

WindowManager (through WindowInfoRepository) will provide the WindowLayoutInfo data (device feature type, device feature boundaries, and device posture) just when the app is spanned across displays (physical or not). So in the previous figure, where the app runs on single-screen mode, WindowLayoutInfo is empty.

Having that information, you can know in which mode the app is running (single-screen mode or spanned across displays), so you can then make changes in your UI/UX, providing a better experience for your users, adapted to these specific configurations.

On devices that don't have two physical displays (they don't usually have a physical hinge) apps can run side by side, using multi-window. On these devices, when the app runs in multi-window, it will act as it would on a single screen like in the previous example, and when the app runs occupying all logic displays, it will act as the app is spanned. You can see this in the next figure:

ecdada42f6df1fb8.png

As you see, when the app runs in multi-window mode WindowManager will provide an empty List<LayoutInfo>.

In summary, you will get WindowLayoutInfo data just when the app runs occupying all logic displays, intersecting the device feature (fold or hinge). In all other cases, you won't get any information. 564eb78fc85f6d3e.png

What happens when you span the app across displays? In a dual-screen emulator, WindowLayoutInfo will have a FoldingFeature object that provides data about the device feature: a HINGE, the boundaries of that feature: Rect (0, 0- 1434, 1800), and the posture (state) of the device: FLAT

faab87600a42a484.png

Let's see what each field means:

  • type = TYPE_HINGE. This dual-screen emulator mirrors a real Surface Duo device that has a physical hinge, and this is what WindowManager reports.
  • Bounds [0, 0 - 1434, 1800], represents the bounding rectangle of the feature within the application window in the window coordinate space. If you read the dimensions specs of the Surface Duo device, you'll see that the hinge is located at the exact position reported with these boundaries (left, top, right, bottom).
  • State. There are two different values that represent the device posture (state) of the device:
  • STATE_HALF_OPENED, the foldable device's hinge is in an intermediate position between opened and closed state, there is a non-flat angle between parts of the flexible screen or between physical screen panels.
  • STATE_FLAT, the foldable device is completely open, the screen space that is presented to the user is flat.

The emulator by default is open in 180 degrees, so the posture that WindowManager returns is STATE_FLAT.

If you change the posture of the emulator using the Virtual Sensors to the Half Opened posture, WindowManager will notify you about the new position: STATE_HALF_OPENED.

bbfbab436850fb4e.png

Using WindowManager to adapt your UI/UX

As you have seen in the figures showing the window layout information, the information shown was cut by the display feature, as you can see again here:

422aa9714bdb2892.png

This is not the best experience you can offer to users. You can use the information that WindowManager provides in order to adjust your UI/UX.

As you have seen before, when your app is spanned across all different display regions is also when your app intersects with the device feature, so WindowManager provides Window Layout Info as display state and display boundaries. So here, when the app is spanned, is when you need to use that information and adjust your UI/UX.

What you are going to do then is to adjust the UI/UX you currently have at runtime when your app is spanned so no important information is cut/hidden by the display feature. You'll create a view that mirrors the device's display feature, and will be used as a reference to constrain the TextView that is cut/hidden, so you don't have missing information any more.

For learning purposes, you are going to color this new view, so you can easily see that it is located specifically in the same place the real device display feature is, and with its same dimensions.

Add the new view that you will use as device feature reference in 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" />

In MainActivity.kt, go to the updateUI() function you used to display the information from a given WindowLayoutInfo, and add a new function call in the if-else case where you had a display feature:

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 the function alignViewToFoldingFeatureBounds that receives as parameter the WindowLayoutInfo.

Create that function, and inside the function, create your ConstraintSet in order to apply new constraints to your views. Then, get the display feature boundaries using the WindowLayoutInfo. Since WindowLayoutInfo returns a list of DisplayFeature that is just an interface, we will have to cast it to FoldingFeature in order to get access to all the information we need:

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
}

We're defining a getFeatureBoundsInWindow()function to translate 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? {
    // The the location of the view in 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
}

Now that we have the information about the boundaries of the display feature, we can use it to set the correct height size for your reference view and move it accordingly.

The complete code for alignViewToFoldingFeatureBounds will be:

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 the TextView that conflicted with the device display feature takes into consideration where the feature is located, so its content is never cut or hidden:

5f671f3a33054970.png

In the dual-screen emulator (left), you can see how the TextView that displayed the content across displays and that was cut by the hinge is not cut anymore, so there is no missing information.

In a foldable emulator (right), you'll see a light red line that represents where the fold display feature is located, and the TextView has been placed now below the feature, so when the device is folded (e.g, in 90 degrees in a laptop posture) no information is affected by the feature.

If you are wondering where the display feature is on the dual-screen emulator, since this is a hinge-type device, the view that represents the feature is hidden by the hinge. But, if we move the app from span to unspan, you'll see it in the same position the feature is in, with the correct height and width.

5318e7a182ee9281.png

8. Other Jetpack WindowManager artifacts

WindowManager also comes with other useful artifacts apart from the main one that will help you to interact with the component differently taking into consideration the current environment you are using while building your apps.

Java artifact

If you are using the Java programming language instead of Kotlin and/or if, for any reason, using listening for events through callbacks is a better approach for your architecture, the WindowManager's Java artifact can be useful since it provides a Java-friendly API to register and unregister listeners for events through callbacks.

RxJava artifact(s)

If you are already using RxJava (version 2 or 3) you can use specific artifacts that will help you to keep consistency in your code, whether you use Observables or Flowables.

9. Test using Jetpack WindowManager

Testing foldable postures on any emulator or device can be very useful in order to test how UI elements can be placed around the FoldingFeature.

To achieve that, WindowManager comes also with a very useful artifact for instrumented tests.

Let's see how we can use it.

Together with the main WindowManager dependency, we have added the testing artifact in app/build.gradle: "androidx.window:window-testing"

The window-testing artifact comes with a useful new TestRule called WindowLayoutInfoPublisherRule that will help test consuming a stream of WindowLayoutInfo values. WindowLayoutInfoPublisherRule allows you to push through different WindowLayoutInfo values on demand.

In order to use it, and from there to create a sample that can help you to test your UI with this new artifact, we are going to update the testing class created by the Android Studio template. Replace all the code in the ExampleInstrumentedTest class with the following:

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

As you can see above, together with the mentioned rule, we also create an ActvityScenarioRule and chain them together.

In order to mock a FoldingFeature the new artifact comes with a couple of very useful functions to do it. We will use the simplest one that provides already some default values.

In MainActivity we aligned the TextViews to the left of the folding feature. Let's create a test that checks that this was implemented correctly.

Create a test called 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

}

As you may note there, we are creating a testing FoldingFeature that will have a FLAT state and whose orientation will be VERTICAL. We have defined a specific size since we want that the fake FoldingFeature is shown in the UI in our tests so we can see where it's located in the device.

We use the WindowLayoutInfoPublishRule that we instantiated before to publish the fake FoldingFeaure, so then we can get it as it would be a real WindowLayoutInfo data:

The final step is just to test that our UI elements are located where they should be aligned avoiding the FoldingFeature. To do that we simply use EspressoMatchers adding the assertion at the end of the test we just created:

ExampleInstrumentedTest.kt

import androidx.test.espresso.Espresso.onView
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))
   )

The complete test is going to be:

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
)

You can now run your test on a device or emulator to check that the application behaves as expected. Note that you don't need a foldable device or emulator to run this test.

10. Congratulations!

As we have seen in this content block, using Jetpack WindowManager will help us to work with new form factor devices such as foldables.

The information that it provides is very helpful in order to adapt our apps to these devices so we can deliver a better experience when our apps run on these devices.

As a summary, through the whole codelab you have learned:

  • What foldable devices are.
  • Differences between the different foldable devices.
  • Differences between foldable devices and single screen devices, and tablets.
  • Enhancing apps by adding minimal changes to launch activities to the empty adjacent window, and implementing drag and drop that works between apps.
  • Jetpack WindowManager. What does this API provide?
  • Using Jetpack WindowManager and adapting our apps to new form factor devices.
  • Testing using Jetpack WindowManager

Learn more