Support foldable and dual-screen devices with Jetpack WindowManager

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 Fold 2.

Prerequisites

To complete this codelab, you'll need:

  • Experience building Android apps
  • Experience with Activities, Fragments, ViewBinding, xml-layouts
  • Experience adding dependencies to your projects
  • Experience installing and using device emulators. For this codelab you'll use a foldable and/or dual screen emulator.

What you'll do

  • Create a simple app and enhance it to support foldable and dual-screen devices.
  • Use Jetpack WindowManager to work with new form factor devices.

What you'll need

  • Android Studio 4.2 or higher
  • A foldable device or emulator If you are using Android Studio 4.2, there are a few foldable emulators you can use, as shown in the image below:

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

Foldable devices offer users a bigger screen and more versatile user interface than previously available in a mobile device. Another benefit is that, 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.

The Jetpack WindowManager library was designed to help developers make adjustments to their apps, and take advantage of the new experience these devices provide users. Jetpack WindowManager helps application developers support new device form factors and provides a common API surface for different WindowManager features on both old and new platform versions.

Key features

Version 1.0.0-alpha03 of Jetpack WindowManager 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:

Through the main WindowManager class you can access important information, such as:

  • getCurrentWindowMetrics(): returns the WindowMetrics according to the current system state. The value of this is based on the current windowing state of the system.
  • getMaximumWindowMetrics(): returns the largest WindowMetrics according to the current system state. The value of this is based on the largest potential windowing state of the system. For example, for activities in Multi-Window mode the metrics returned are based on what the bounds would be if the user expanded the window to cover the entire screen.

Clone the GitHub repo or download the sample code for the app you'll be enhancing:

git clone https://github.com/googlecodelabs/android-foldable-codelab

Declare dependencies

In order to use Jetpack WindowManager, you have to add the dependency to it.

  1. First, add the Google Maven repository to your project.
  2. Add the dependency for the artifact in the build.gradle file for your app or module:
dependencies {
    implementation "androidx.window:window:1.0.0-alpha03"
}

Using WindowManager

Jetpack WindowManager can be used very easily by registering your app to listen for configuration changes.

First, initialize the WindowManager instance so you can have access to its API. To initialize the WindowManager instance, implement the following code inside your Activity:

private lateinit var wm: WindowManager

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
     
        wm = WindowManager(this)
}

The primary constructor just allows one parameter: a visual context, like an Activity or a ContextWrapper around one Activity. Under the hood, this constructor will use a default WindowBackend. This is a backing server class that will provide information for this instance.

Once you have your WindowManager instance, you can register a callback so you'll be able to know when posture changes happen, which device features the device has, and the boundaries of that feature (if any). Also, as mentioned previously, you can see the current and maximum metrics according to the current system state.

  1. Open Android Studio.
  2. Click File > New > New Project > Empty Activity to create a new project.
  3. Click Next, accept the default properties and values, then click Finish.

Now, create a simple layout so you can see the information that WindowManager will report. For that you'll have to create the layout folder and the specific layout file:

  1. Click File > New > Android resource directory.
  2. In the new window, select a Resource Type layout and click OK.
  3. Go to the project structure and in src/main/res/layout create a new layout resource file (File > New > Layout resource file) called activity_main.xml
  4. Open the file and then add this content as your layout:

res/layout/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"
       android:text="@string/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"
       android:text="@string/layout_change_text"
       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"
       android:text="@string/configuration_changed"
       android:textSize="20sp"
       app:layout_constraintBottom_toBottomOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/layout_change" />

</androidx.constraintlayout.widget.ConstraintLayout>

You've now built a simple layout based on a ConstraintLayout with three TextViews in it. The views are constrained between them in order to be aligned to the center of the parent (and the screen).

  1. Open the MainActivity.kt file and add the following code:

window_manager/MainActivity.kt

class MainActivity : AppCompatActivity() {
  1. Create an inner class that will help you to handle the result from the callbacks:
inner class LayoutStateChangeCallback : Consumer<WindowLayoutInfo> {
   override fun accept(newLayoutInfo: WindowLayoutInfo) {
       printLayoutStateChange(newLayoutInfo)
   }
}

The functions the inner classes use are simple functions that will print the information you get from WindowManager using your UI components (TextView):

private fun printLayoutStateChange(newLayoutInfo: WindowLayoutInfo) {
   binding.layoutChange.text = newLayoutInfo.toString()
   if (newLayoutInfo.displayFeatures.size > 0) {
       binding.configurationChanged.text = "Spanned across displays"
   } else {
       binding.configurationChanged.text = "One logic/physical display - unspanned"
   }
}
  1. Declare a lateinit WindowManager variable:
private lateinit var wm: WindowManager
  1. Create a variable that will handle the callbacks using WindowManager through the inner classes you've already created:
private val layoutStateChangeCallback = LayoutStateChangeCallback()
  1. Add binding so we can access the different views:
private lateinit var binding: ActivityMainBinding
  1. Now create a function that extends from Executor so you can provide it to the callback as the first parameter, and that will be used when the callback is called. In this case, you're going to create one that runs on the UI thread. You can create a different one that doesn't run on the UI thread, this is up to you.
private fun runOnUiThreadExecutor(): Executor {
   val handler = Handler(Looper.getMainLooper())
   return Executor() {
       handler.post(it)
   }
}
  1. In the onCreate of the MainActivity, initialize the WindowManager lateinit:
override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)
   binding = ActivityMainBinding.inflate(layoutInflater)
   setContentView(binding.root)

   wm = WindowManager(this)
}

Now the WindowManager instance has the Activity as the only parameter, and it will use the default WindowManager backend implementation.

  1. Find the function you added in step 5. Add, right after the function header, this line:
binding.windowMetrics.text =
   "CurrentWindowMetrics: ${wm.currentWindowMetrics.bounds.flattenToString()}\n" +
       "MaximumWindowMetrics: ${wm.maximumWindowMetrics.bounds.flattenToString()}"

Here, you're setting the value of the window_metrics TextView using the values that the function currentWindowMetrics.bounds.flattenToString() and maximumWindowMetrics.bounds.flattenToString() contain.

These values provide useful information about the metrics of the area the window occupies. As the image below illustrates, in a dual-screen emulator 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

And below you can see how the metrics change when the app is spanned across displays, so they now reflect the bigger window area that the app uses:

b72ca8a63b65e4c1.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:

5cb5270ee0e42320.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 Multi-Window mode, and you can see how the maximum metrics show the maximum display area that the device has.

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

Now you'll register for layout changes, so you're able to know the feature of the device (if it's a hinge or a fold device) and the boundaries of the feature.

The function we have to use has this signature:

public void registerLayoutChangeCallback (
                Executor executor, 
                Consumer<WindowLayoutInfo> callback)

This function uses the type WindowLayoutInfo. This class has the data you need to look at when the callback is called. This class internally contains a List< DisplayFeature>, this list will return a list of DisplayFeatures found in the device that intersects with the app. The list can be empty if there is no display feature intersecting with the app.

This class implements DisplayFeature and once you get the List<DisplayFeature> as result, you can cast (the items) to FoldingFeature, where you'll learn information such as the posture of the device, the device feature type, and its boundaries.

Let's see how you can use this callback and visualize the information that it provides. To the code that you already added in the previous step (Build your sample app):

  1. Override the onAttachedToWindow method:
override fun onAttachedToWindow() {
   super.onAttachedToWindow()
  1. Use the WindowManager instance registering to the layout changes callback, using the executor you implemented before as first parameter:
   wm.registerLayoutChangeCallback(
       runOnUiThreadExecutor(),
       layoutStateChangeCallback
   )
}

Let's see how the information that this callback provides looks. If you run this code in the 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 will provide the LayoutInfo 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.

Taking this into consideration, you'll know the mode the app is running (single screen mode or spanned) 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 on 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 it doesn't intersect with the foldable feature, so WindowManager will return an empty List<LayoutInfo>.

In summary, you will get LayoutInfo data just when the app intersects the device feature (fold or hinge), and if it doesn't then you won't get any information. 564eb78fc85f6d3e.png

What happens when you span the app across displays? In a dual-screen emulator, LayoutInfo 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

13edea3ff94baae4.png

Device type, as mentioned before, can take two values: FOLD and HINGE, as is also exposed in its source code:

@IntDef({
       TYPE_FOLD,
       TYPE_HINGE,
})
  • type = TYPE_HINGE. This dual-screen emulator mirrors a real Surface Duo device that has a physical hinge, and this is what WindowManager reports.
  • Rect (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 meeting these reported boundaries (left, top, right, bottom).
  • There are three 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.
  • STATE_FLIPPED, the foldable device is flipped with the flexible screen parts or physical screens facing opposite directions.
@IntDef({
       STATE_HALF_OPENED,
       STATE_FLAT,
       STATE_FLIPPED,
})

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.

7cfb0b26d251bd1.png

You can unregister from this callback when you don't need it anymore. Just call this function from the WindowManager API:

public void unregisterDeviceStateChangeCallback (Consumer<DeviceState> callback)

A good place to unregister your callback would be in onDestroy or onDetachedFromWindow method:

override fun onDetachedFromWindow() {
   super.onDetachedFromWindow()
   wm.unregisterLayoutChangeCallback(layoutStateChangeCallback)
}

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:

4ee805070989f322.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 feature 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 in run time 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.

  1. Add the new view that you will use as device feature reference in activity_main.xml.

res/layout/activity_main.xml

<View
   android:id="@+id/device_feature"
   android:layout_width="0dp"
   android:layout_height="0dp"
   android:background="@android:color/holo_red_dark"
   android:visibility="gone" />
  1. In MainActivity.kt, go to the function you used to display the information from our WindowManager callbacks and add a new function call in the if-else case where you had a display feature:

window_manager/MainActivity.kt

private fun printLayoutStateChange(newLayoutInfo: WindowLayoutInfo) {
   binding.windowMetrics.text =
       "CurrentWindowMetrics: ${wm.currentWindowMetrics.bounds.flattenToString()}\n" +
           "MaximumWindowMetrics: ${wm.maximumWindowMetrics.bounds.flattenToString()}"

   binding.layoutChange.text = newLayoutInfo.toString()
   if (newLayoutInfo.displayFeatures.size > 0) {
       binding.configurationChanged.text = "Spanned across displays"
       alignViewToDeviceFeatureBoundaries(newLayoutInfo)
   } else {
       binding.configurationChanged.text = "One logic/physical display - unspanned"
   }
}

You have added the function alignViewToDeviceFeatureBoundaries that receives as parameter the WindowLayoutInfo.

  1. Inside the new function, create your ConstraintSet in order to apply new constraints to your views:
private fun alignViewToDeviceFeatureBoundaries(newLayoutInfo: WindowLayoutInfo) {
   val constraintLayout = binding.constraintLayout
   val set = ConstraintSet()
   set.clone(constraintLayout)
  1. Now get the display feature boundaries using the WindowLayoutInfo:
val rect = newLayoutInfo.displayFeatures[0].bounds
  1. Now with the provided WindowLayoutInfo in your rect variable, set the correct height size for your reference view:
set.constrainHeight(
   R.id.device_feature,
   rect.bottom - rect.top
)
  1. Now adjust your view to the width of the display feature, based on right coordinate - left coordinate, so you know the width of your device feature:
set.constrainWidth(R.id.device_feature, rect.right - rect.left)
  1. Set the alignment constraints to your view reference, so it's aligned to its parent in start and top sides:
set.connect(
   R.id.device_feature, ConstraintSet.START,
   ConstraintSet.PARENT_ID, ConstraintSet.START, 0
)
set.connect(
   R.id.device_feature, ConstraintSet.TOP,
   ConstraintSet.PARENT_ID, ConstraintSet.TOP, 0
)

You could also add this directly in the xml as attributes for our view, instead of here in code.

Next, you'll want to cover all possible device feature placement: devices that have a display feature placed vertically (like the dual screen emulator) and devices that have the display feature placed horizontally (like the foldable emulator with the horizontal fold).

  1. For the first scenario, top == 0 indicates that your device feature will be placed vertically (like our dual screen emulator):
if (rect.top == 0) {
  1. Now is where you apply the margin to your reference view, so it's positioned in the exact same position where our real display feature is.
  2. After, apply the constraint to the TextView you want to place better to avoid the display feature, so its constraint takes the feature into consideration:
set.setMargin(R.id.device_feature, ConstraintSet.START, rect.left)
set.connect(
   R.id.layout_change, ConstraintSet.END,
   R.id.device_feature, ConstraintSet.START, 0
)

Horizontal display features

Your users device may have a display feature located horizontally (like our foldable emulator with the horizontal fold).

Depending on your UI, you might have a toolbar or a status bar to display, so a good idea is to get their heights so you can adjust your display feature representation to fit perfectly in your UI.

In our sample app we do have the status bar and the toolbar:

val statusBarHeight = calculateStatusBarHeight()
val toolBarHeight = calculateToolbarHeight()

A simple implementation of the functions to make these calculations (located outside our current function) are:

private fun calculateToolbarHeight(): Int {
   val typedValue = TypedValue()
   return if (theme.resolveAttribute(android.R.attr.actionBarSize, typedValue, true)) {
       TypedValue.complexToDimensionPixelSize(typedValue.data, resources.displayMetrics)
   } else {
       0
   }
}

private fun calculateStatusBarHeight(): Int {
   val rect = Rect()
   window.decorView.getWindowVisibleDisplayFrame(rect)
   return rect.top
}

Back in the main function in your else-statement, where you handle the horizontal-device feature, for the margin you can use the status bar height and the toolbar height, since the boundaries of the display feature don't take into consideration any UI element that we have, and are taken from (0,0) coordinates. You must take these elements in consideration in order to place our reference view in the correct place:

} else {
   //Device feature is placed horizontally
   val statusBarHeight = calculateStatusBarHeight()
   val toolBarHeight = calculateToolbarHeight()
   set.setMargin(
       R.id.device_feature, ConstraintSet.TOP,
       rect.top - statusBarHeight - toolBarHeight
   )
   set.connect(
       R.id.layout_change, ConstraintSet.TOP,
       R.id.device_feature, ConstraintSet.BOTTOM, 0
   )
}

The next step is to change the visibility of the reference view to visible, so you can see it in your sample (colored with red), and more importantly the constraints are applied. If the view is gone, there won't be constraints to apply:

set.setVisibility(R.id.device_feature, View.VISIBLE)

The final step is to apply the ConstraintSet you have built to your ConstraintLayout, to apply all changes and UI adjustments:

    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:

80993d3695a9a60.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 with the correct height and width.

4dbe464ac71b498e.png

So far, you have learned the difference between foldable devices and single screen devices.

One of the features that foldable devices provide is the option to run two apps side by side so that you can achieve more with less. As an example, users could display their email app on one side and calendar app on the other, or take a video call on one screen and take notes on the other. There are so many possibilities!

You can take advantage of having two screens just using existing APIs included in the Android framework. Let's see some enhancements you can do.

Launch an activity to the adjacent window

This enhancement allows you to enable your app to launch a new Activity on the adjacent window, to take advantage of multiple window areas at the same time without the need to do a lot of work.

Imagine that you have a button that, when clicked, the app launched the new Activity:

  1. First, create the function that will handle the click event:

intent/MainActivity.kt

private fun openActivityInAdjacentWindow() {
}
  1. Inside the function, create the Intent that will be used to launch the new Activity (in this case called SecondActivity. It's just a simple Activity with a TextView as message):
val intent = Intent(this, SecondActivity::class.java)
  1. Next, set the flags that will launch the new Activity when the adjacent screen is empty:
intent.addFlags(
   Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT or
       Intent.FLAG_ACTIVITY_NEW_TASK
)

What these flags do is:

  • FLAG_ACTIVITY_NEW_TASK = If set, this activity will become the start of a new task on this history stack.
  • FLAG_ACTIVITY_LAUNCH_ADJACENT = This flag is used for split-screen Multi-Window mode (and also works for dual screen devices with independent physical screens). The new activity will be displayed adjacent to the one launching it.

The platform, when it sees a new task, will try to use the adjacent window to allocate it there. The new task will be launched on top of your current task, so your new Activity will be launched on top of your current one.

  1. The last step is simply to launch the new activity using the Intent we have created:
     startActivity(intent)

The resulting test app would behave as you see in the below animations, where clicking on a button launches a new Activity on the empty adjacent window.

You can see it running on a dual screen device and on a foldable running on Multi-Window mode:

9696f7fa2ee1e35f.gif a2dc98dae26e3045.gif

Drag and drop

Adding drag and drop to your apps can provide a very useful functionality that your users would love. This functionality allows your app to provide content to other apps (implementing drag), accept content from other apps (implementing drop), or can include both features, so your app can provide and accept content from other apps and from itself (e.g. content located on different places within the same app).

Drag and drop has been available in the Android framework since API 11, but it was not until the introduction of Multi-Window support on API level 24 where drag and drop made more sense, since you could drag and drop elements between apps that ran side-by-side on the same screen.

Now, with the introduction of foldable devices that can have more area for Multi-Window purposes or even two different logical screens, drag and drop makes more sense. Useful scenarios include a to-do app that accepts (drop) text which becomes a new task when dropped, or a calendar app that accepts (drop) content to a day/time slot and becomes an event, etc.

Apps have to implement drag behaviour in order to become data consumers and/or drop behavior to become data producers to take advantage of this functionality.

In your sample, you are going to implement drag in one app and drop in a different app, but you can definitely implement drag and drop in the same app.

Implementing drag

Your "drag app" will simply have a TextView and will trigger the drag action when the user performs a long click on it.

  1. First, create a new app by going to File > New > New Project > Empty Activity.
  2. Then go to the activity_main.xml that has been already created. There, replace the existing layout for this one:

res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:orientation="vertical"
   tools:context=".MainActivity">

   <TextView
       android:id="@+id/drag_text_view"
       android:layout_width="match_parent"
       android:layout_height="wrap_content"
       android:layout_margin="20dp"
       android:text="@string/drag_text"
       android:textSize="30sp" />
</LinearLayout>
  1. Now open the MainActivity.kt file and add the tag and call its setOnLongClickListener function:

drag/MainActivity.kt

class MainActivity : AppCompatActivity(), View.OnLongClickListener {
   private lateinit var binding: ActivityMainBinding

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

       binding.dragTextView.tag = "text_view"
       binding.dragTextView.setOnLongClickListener(this)
   }
  1. Now override the onLongClick function so your TextView can use this overridden functionality for its onLongClickListener event.
override fun onLongClick(view: View): Boolean {
  1. Check if the receiver parameter is the type of the View you are adding the drag functionality to. In your case, it is a TextView:
return if (view is TextView) {
  1. Create a ClipData.item from the text that the TextView holds:
val text = ClipData.Item(view.text)
  1. Now we define the MimeType we are going to use:
val mimeType = arrayOf(ClipDescription.MIMETYPE_TEXT_PLAIN)
  1. With the previous items that you have created, create the bundle (a ClipData instance) that you will use for sharing the data:
val dataToShare = ClipData(view.tag.toString(), mimeType, text)

Providing feedback to our users is really important, so providing visual information about what is being dragged is a good idea.

  1. Create a shadow of the content we are dragging so users see the content under their finger when the drag interaction runs:
val dragShadowBuilder = View.DragShadowBuilder(view)
  1. Now, since you'll want to allow drag and drop between different apps, you first have to define a set of flags that will enable that functionality:
val flags =
   View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ

Following the documentation, the flags mean:

  • DRAG_FLAG_GLOBAL: Flag indicating that a drag can cross window boundaries.
  • DRAG_FLAG_GLOBAL_URI_READ: When this flag is used with DRAG_FLAG_GLOBAL, the drag recipient will be able to request read access to the content URI(s) contained in the ClipData object.
  1. Finally, call the startDragAndDrop function in the view with the components you have created, so the drag interaction starts:
view.startDragAndDrop(dataToShare, dragShadowBuilder, view, flags)
  1. Finish and close the onLongClick function and MainActivity:
         true
       } else {
           false
       }
   }
}

Implementing drop

In your sample, you are creating a simple app that has the drop functionality attached to an EditText. This view will accept text data (that can come from our drag app from its TextView).

Our EditText (or drop area) will change its background according to the drag stage we are in, so you can provide information to the users about the state of the drag and drop interaction, and users are able to see when they are allowed to drop the content.

  1. First, create a new app by going to File > New > New Project > Empty Activity.
  2. Next, go to the activity_main.xml that has been already created. Replace the existing layout for this one:

res/layout/activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<EditText
   android:id="@+id/drop_edit_text"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   android:background="@android:color/holo_blue_dark"
   android:gravity="top"
   android:hint="@string/drop_text"
   android:textColor="@android:color/white"
   android:textSize="30sp" />

</RelativeLayout>
  1. Now open the MainActivity.kt file and add a listener to the EditText setOnDragListener function:

drop/MainActivity.kt

class MainActivity : AppCompatActivity(), View.OnDragListener {
   private lateinit var binding: ActivityMainBinding

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       binding = ActivityMainBinding.inflate(layoutInflater)
       setContentView(binding.root)
       binding.dropEditText.setOnDragListener(this)
   }
  1. Now override the onDrag function so our EditText, as was written above, can use this overridden callback for its onDragListener function.

This function will be called whenever a new DragEvent happens, such as when a users finger enters the drop area or leaves it, when they release the finger in the drop area so the drop is performed, or they release the finger outside the drop area and cancel the drag and drop interaction.

override fun onDrag(v: View, event: DragEvent): Boolean {
  1. To react to different DragEvents that will be triggered, add a when statement to handle the different events:
return when (event.action) {
  1. Handle the ACTION_DRAG_STARTED that is triggered when the drag interaction starts. When this event is triggered, the drop area color changes so users know that your EditText accepts dropped content:
DragEvent.ACTION_DRAG_STARTED -> {
       setDragStartedBackground()
       true
}
  1. Handle the ACTION_DRAG_ENTERED drag event that is triggered when a finger enters the drop area. Change again the background color of the drop area to indicate to the user that the drop area is ready. (You of course could omit this event and not change the background event, this is just for informative purposes.)
DragEvent.ACTION_DRAG_ENTERED -> {
   setDragEnteredBackground()
   true
}
  1. Handle the ACTION_DROP event now. This event is triggered when users release their finger with the drag content on the drop area, so the drop action can be performed.
DragEvent.ACTION_DROP -> {
   handleDrop(event)
   true
}

We will see how to handle drop action later.

  1. Next, handle the ACTION_DRAG_ENDED event. This event is triggered after ACTION_DROP, so the complete drag and drop action has finished.

Here is a good time to restore the changes you did, for instance, where you changed the background of the drop area to its original values.

DragEvent.ACTION_DRAG_ENDED -> {
   clearBackgroundColor()
   true
}
  1. Next, handle the ACTION_DRAG_EXITED event. This event is triggered when users leave the drop area (when their finger is on the drop area but then leave it).

Here, if you change the background to highlight entering in the drop area, it is a good time to restore it to its previous value.

DragEvent.ACTION_DRAG_EXITED -> {
   setDragStartedBackground()
   true
}
  1. Finally, address the else case of your when statement and close the onDrag function:
      else -> false
   }
}

Now let's see how the drop action is handled. Before you saw that when the event ACTION_DROP is triggered, here is where we have to handle the drop functionality, so now you are going to see how to do that.

  1. Pass the DragEvent as a parameter, since it is that object that holds the drag data:
private fun handleDrop(event: DragEvent) {
  1. Inside the function, request drag and drop permissions. This is needed when you are doing drag and drops between different apps.
val dropPermissions = requestDragAndDropPermissions(event)
  1. Through the DragEvent parameter you can access the clipData item that was previously created in the "drag step":
val item = event.clipData.getItemAt(0)
  1. Now with the drag item, access the text that holds it and that was shared. This is the text that your TextView in the drag sample had:
val dragData = item.text.toString()
  1. Now that you have the real data that was shared (the text), you can just set it to your drop area (our EditText) as is normally done when you set text into an EditText in code:
binding.dropEditText.setText(dragData)
  1. The last step is to release the drag and drop permissions requested. If you don't do it after the drop action has been finished, when the Activity is destroyed the permissions will be released automatically. Close the function and class:
      dropPermissions?.release()
   }
}

After you have implemented this drop implementation in our drop simple app we can run both apps, side-by-side and see how drag and drop works.

In the animation below, see how it works and how the different drag events are triggered, and what you do when you handle them (changing the drop area background depending on the specific DragEvent and dropping the content):

d66c5c24c6ea81b3.gif

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.
  • Jetpack WindowManager. What does this API provide?
  • Using Jetpack WindowManager and adapting our apps to new form factor devices.
  • Enhancing apps by adding minimal changes to launch activities to the empty adjacent window, and implementing drag and drop that works between apps.

Learn more