WindowInsets.getDisplayCutout is NULL everywhere except within onAttachedToWindow in fullscreen Java app

4.1k Views Asked by At

I am having issues with getDisplayCutout() in my fullscreen Java app. I can only seem to get the value of the DisplayCutout within the onAttachedToWindow function. After that function completes, I can never get it again. Code to get the cutouts:

WindowInsets insets = myActivity.getWindow().getDecorView().getRootWindowInsets();
if (insets != null) {
    DisplayCutout displayCutout = insets.getDisplayCutout();
    if (displayCutout != null && displayCutout.getBoundingRects().size() > 0) {
        // we have cutouts to deal with
    }
}

Question

How can I get the display cutouts reliably, from anywhere in the code, at any time once attached to the view hierarchy?

Reproduce Issue

After much investigation I have narrowed it down to a VERY broad problem with fullscreen apps and I am surprised that no-one else is asking about it. We can in fact ignore my app and just work off two template projects which you can make yourselves right now.

In Android studio I'm talking about the Phone and Tablet projects called 'Basic Activity' and 'Fullscreen Activity'. If you create one of each, and make the following changes:

For Basic, change the Manifest to handle configuration changes yourself by adding android:configChanges="orientation|keyboardHidden|screenSize" under the Activity tag like so:

<activity
    android:name=".MainActivity"
    android:label="@string/app_name"
    android:configChanges="orientation|keyboardHidden|screenSize"
    android:theme="@style/AppTheme.NoActionBar">

Now for both of them, add the following two functions to the activity file:

@Override
public void onAttachedToWindow() {
    super.onAttachedToWindow();
    WindowInsets insets = getWindow().getDecorView().getRootWindowInsets();
    if (insets != null) {
        DisplayCutout displayCutout = insets.getDisplayCutout();
        if (displayCutout != null && displayCutout.getBoundingRects().size() > 0) {
            // we have cutouts to deal with
        }
    }
}

@Override
public void onConfigurationChanged(Configuration newConfig) {
    super.onConfigurationChanged(newConfig);
    WindowInsets insets = getWindow().getDecorView().getRootWindowInsets();
    if (insets != null) {
        DisplayCutout displayCutout = insets.getDisplayCutout();
        if (displayCutout != null && displayCutout.getBoundingRects().size() > 0) {
            // we have cutouts to deal with
        }
    }
}

That's all you need to do to reproduce this issue. I run this on a simulator for Pixel 3 on API Q, on which I have enabled simulating cutouts and have chosen BOTH (so there's a cutout on the bottom and the top)

Now if you breakpoint on the line where we try to get display cutouts (DisplayCutout displayCutout = insets.getDisplayCutout();), you'll see that on the Basic app, it works on startup and when you change orientation, but in the fullscreen app it only works on startup.

In fact on my application I have tested by using the following code within onAttachedToWindow:

@Override
public void onAttachedToWindow() {
    super.onAttachedToWindow();
    // start a thread so that UI thread execution can continue
    WorkerThreadManager.StartWork(new WorkerCallback() {
        @Override
        public void StartWorkSafe() {
            // run the following code on the UI thread
            XPlatUtil.RunOnUiThread(new SafeRunnable() {
                public synchronized void RunSafe() {
                    WindowInsets insets = myActivity.getWindow().getDecorView().getRootWindowInsets();
                    if (insets != null) {
                        DisplayCutout displayCutout = insets.getDisplayCutout();
                        if (displayCutout != null && displayCutout.getBoundingRects().size() > 0) {
                            // we have cutouts to deal with
                        }
                    }
                }
            });
        }
    });
}

This code starts a thread so that the onAttachedToWindow function can finish running; but the thread immediately sends execution back to the UI thread to check the cutouts.

The delay between the onAttachedToWindow function completing its execution and my code checking for the displayCutout must be in the order of nano seconds, but the cutouts are immediately unavailable.

Any ideas? Is this somehow expected?

Without access to the cutouts when orientation changes I have no recourse but to record the Largest inset (top or bottom in portrait, since long edges cannot have them), and apply it to both the top and bottom in portrait, or the left and right in landscape.

This is because I cannot find a way in android to check WHICH kind of landscape is currently active (eg is the top of the phone on the left, or on the right). If I could check that, I could at least only apply the inset on the edge of the phone where it is needed.

1

There are 1 best solutions below

3
On

I've figured out a bunch of stuff that might help other people.
First I'll answer the original question, then I'll explain why it works, with some alternatives for people struggling with the same issue I was: How to display a fullscreen app on a phone with cutouts by properly letterboxing my app.
NOTE: All of my layout is done programatically (I don't even have any xml files in my layout directory). This was done for cross platform reasons and is perhaps why I am having this issue while others are not.

OP Answer

Use the following code to get display cutouts (with approriate null checks):

<activity>.getWindowManager().getDefaultDisplay().getCutout();

In my testing, this returned the correct cutouts in onConfigurationChanged and is not null when called outside of onAttachedToWindow (if there are cutouts of course).

Explained

getWindow().getDecorView().getRootWindowInsets().getDisplayCutout() appears to always be NULL if accessed outside the onAttachedToWindow function when your application is fullscreen; I did not find a way around that.
If you take your app out of fullscreen by doing this:

rootView.setSystemUiVisibility(0
            | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
            | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);

You will get a value from getDisplayCutout when calling it from outside onAttachedToWindow.
This is of course not a solution.
Worse than that, the value you get from this function call when inside onConfigurationChanged seems to actually be wrong most of the time; it is both out of date (showing the cutout locations from before the orientation change) and sometimes completely missing a cutout (when there are multiple cutouts) resulting in the safeInsets being wrong.

Useful Observations

I found out some useful stuff to know while trying to solve this issue, which may help others trying to deal with cutouts.
In my scenario I have a full-screen app and do not want to add complexity by trying to use the whole screen, and having to adjust layout based on cutout size and location.
Handling cutouts perfectly is far more effort than it is worth for me as a single developer working on an app.

All I want is to be able to reliably lay out all of my views programmatically without having to deal with cutouts; this means all I need to know is the size and position of a rectangle in which it is safe to place my content.
This sounds easy but was made difficult by the issue above, and some quirk in Android's letterboxing of content where system overlays and cutouts are involved.

Below are some tips on how this can be handled with varying results.

Getting available screen size (fullscreen app)

Method 1

You can get available screen size using

<activity>.getWindowManager().getDefaultDisplay().getSize()

This size appears to always be safe to use, with or without cutouts, when using LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER to automatically inset the the content to avoid the cutouts.

It does seem to reserve space for the navigation bar though.
With the following flags set:

setSystemUiVisibility(0
    | View.SYSTEM_UI_FLAG_LOW_PROFILE
    | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
    | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION // hide nav bar
    | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN // hide status bar
    | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION // hide nav bar
    | View.SYSTEM_UI_FLAG_FULLSCREEN // hide status bar
    | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY // if they swipe to show the soft-navigation, hide it again after a while.
);

The getSize() function appears to reserve space for the navigation bar at the bottom of the screen in portrait.
If you are using LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER, then this size will have compensated for the cutouts which means your content can fit in the space provided, but it will also have reserved extra space for the nav bar.
If you are on a device without cutouts, there will still be space reserved at the bottom for the navigation bar.
This is not something that I want.

Method 2

Another method for getting screen size is to do

DisplayMetrics metrics = new DisplayMetrics();
MainActivity.getWindowManager().getDefaultDisplay().getRealMetrics(metrics);

This will fetch the actual screen size in pixels, edge to edge, ignoring system overlays or bars, and phone cutouts.
This looks like the best way to go for a fullscreen app, when combined with an accurate and reliable method for determining the cutout insets.
The status bar and soft navigation will overlap a little strangely with the empty space at the edge of your app when the user swipes to show them, but it is by far the best solution I have found where I can avoid trying to actually wrap around the cutouts with my layout.

Use LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES, then use the display cutouts to both shrink and reposition your root view.

// Get the actual screen size in pixels
DisplayMetrics metrics = new DisplayMetrics();
MainActivity.getWindowManager().getDefaultDisplay().getRealMetrics(metrics);
int width = metrics.widthPixels;
int height = metrics.heightPixels;
// if there are no cutouts, top and left offsets are zero
int top = 0;
int left = 0;
// get any cutouts
DisplayCutout displayCutout = MainActivity.getWindowManager().getDefaultDisplay().getCutout();
// check if there are any cutouts
if (displayCutout != null && displayCutout.getBoundingRects().size() > 0) {
    // add safe insets together to shrink width and height
    width -= (displayCutout.getSafeInsetLeft() + displayCutout.getSafeInsetRight());
    height -= (displayCutout.getSafeInsetTop() + displayCutout.getSafeInsetBottom());
    // NOTE:: with LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES, we can render on the whole screen
    // NOTE::    and therefore we CAN and MUST set the top/left offset to avoid the cutouts.
    top = displayCutout.getSafeInsetTop();
    left = displayCutout.getSafeInsetLeft();
}

This is the method I ended up using, and have found no issues with it so far.

Method 3

Here we are using the same method as in #2 to determine the Width and Height we have available, but using LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER so that Android deals with the position of that content.
I thought this would work fine, after all I am not interested in rendering in the cutout space.
This causes a strange issue in portrait though - the height that android reserved for the status bar was greater than the height of the top cutout.
This has the effect of my code calculating that I have more height than in fact I do, and my content gets cut off at the bottom as a result.

If you could determine the height of the status bar then you could adjust the height you calculate accordingly, but I couldn't be bothered since it seems to be an inferior solution.
I tried to find the status bar height for a while by the way, but I couldn't manage it (it seems to be zero while it is hidden).

Conclusion

Use <activity>.getWindowManager().getDefaultDisplay().getCutout(); to get display cutouts.
After that just decide which method to use to determine where the safe area to render is.

I recommend method 2