material-components-android icon indicating copy to clipboard operation
material-components-android copied to clipboard

[CollapsingToolbarLayout] Consuming system window insets blocks sibling views from receiving insets

Open ferinagy opened this issue 5 years ago • 9 comments

Description: CollapsingToolbarLayout is listening to window insets and inside the listener calls:

// Consume the insets. This is done so that child views with fitSystemWindows=true do not
// get the default padding functionality from View
return insets.consumeSystemWindowInsets();

While consuming the insets, so its children do not receive them makes sense, due to how insets are dispatched by system, this causes insets to not be dispatched also to its sibling views.

Maybe it could only consume the insets only if android:fitsSystemWindows="true"?

After quite some trial & error I discovered a workaround: if I set my own insets listener (doing nothing) on the CollapsingToolbarLayout, I can avoid it consuming the insets. But this feels like hacking around it instead of proper solution.

Expected behavior: System window insets are also dispatched to its sibling views.

Source code:

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout 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:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/appBar"
        android:layout_width="match_parent"
        android:layout_height="160dp">

        <com.google.android.material.appbar.CollapsingToolbarLayout
            android:id="@+id/collapsingToolbar"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:layout_scrollFlags="scroll|exitUntilCollapsed|snap">

            <com.google.android.material.appbar.MaterialToolbar
                android:layout_width="match_parent"
                android:layout_height="?attr/actionBarSize"
                app:layout_collapseMode="pin" />

    </com.google.android.material.appbar.CollapsingToolbarLayout>

    </com.google.android.material.appbar.AppBarLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    ViewCompat.setOnApplyWindowInsetsListener(recyclerView) { _, insets ->
        // this will not be called
        insets
    }

    ViewCompat.setOnApplyWindowInsetsListener(appBar) { _, insets ->
        // this is called ok
        insets
    }
}

Android API version: I checked on api 29.

Material Library version: 1.1.0, 1.2.0-alpha06

ferinagy avatar May 20 '20 07:05 ferinagy

Hi, this is a known issue that is currently blocked by AndroidX. @chrisbanes may have more information about it.

leticiarossi avatar May 20 '20 20:05 leticiarossi

I also stumbled over this on version 1.2.0-alpha04. A OnApplyWindowInsetsListener on BottomNavigationView would not be called anymore as soon as I navigate to a view containing CollapsingToolbarLayout, resulting in the BottomNavView being occluded by the system navigation bar.

My current workaround is opting out of the consuming behavior by removing the listener on the CollapsingToolbarLayout:

ViewCompat.setOnApplyWindowInsetsListener(collapsingToolbarLayout, null)

Would be great if this issue could be resolved.

ottne avatar Jun 22 '20 10:06 ottne

Would be great if this issue could be resolved, faced the same issue on 1.3.0 stable. Edit: Thanks to @bompf the workaround works for me.

itsandreramon avatar Apr 12 '21 18:04 itsandreramon

Still an issue on 1.4.0

paulkugaev avatar Aug 08 '21 02:08 paulkugaev

Very confusing behaviour to encounter. Though the provided workaround does work.

When targeting API 30+ and using the new WindowsInsetsCompat and WindowInsetsControllerCompat API, this behaviour is only seen when the legacy implementation is used (up to API 29) and not on API 30+.
This is extra worrying as developers use a single API and expect similar behavior on all devices.

The proposed changed to only do this with fitsSystemWindows="true" seems good, as it's available as opt-in when needed but does cause this unexpected issue by default.

Flamedek avatar Nov 26 '21 09:11 Flamedek

This issue still happens in version 1.7.0

jdelga avatar Nov 27 '22 02:11 jdelga

Any Updates?

petersnoopy avatar Jun 19 '23 08:06 petersnoopy

When implementing the workaround @ottne provided on apis +31, the collapsing toolbar scrim color (background when collapsed) is transparent and cannot be changed via xml or programmatically. Someone know why or a workaround??

PMARZV avatar Sep 02 '23 12:09 PMARZV

Welcome to another Android unexpected, non-documented behavior that makes no sense! I always think that they can't surprise me more with sh** implementation, but oh boy was I wrong.

The issue

In android versions from Android R (API 30+) the behavior for dispatching insets is the expected one as per the WindowInsets Android documentation: The dispatch is called to all children of a ViewGroup, and returning CONSUMED only stops the dispatch to go further down the hirearchy, it doesn't stop it to go to sibling views.

However in Android versions prior to API 30 this is not the behavior. If a sibling or a sibling child consumes the insets, the dispatch is not done for other sibling views.

Why does this happen?

Considering that using Compat libraries should make our life easier it would make sense to assume that this discrepancy between versions should not happen. And if it did for some unchangable reason, it should be documented.

As per usual, it's not documented.

After a lot of digging I found the following:

sBrokenInsetsDispatch = targetSdkVersion < Build.VERSION_CODES.R;

See the source code for it

This is a private static variable inside of the View class.

Now, see how the events are dispatched:

@Override
    public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {
        insets = super.dispatchApplyWindowInsets(insets);
        if (insets.isConsumed()) {
            return insets;
        }
        if (View.sBrokenInsetsDispatch) {
            return brokenDispatchApplyWindowInsets(insets);
        } else {
            return newDispatchApplyWindowInsets(insets);
        }
    }

    private WindowInsets brokenDispatchApplyWindowInsets(WindowInsets insets) {
        final int count = getChildCount();
        for (int i = 0; i < count; i++) {
            insets = getChildAt(i).dispatchApplyWindowInsets(insets);
            if (insets.isConsumed()) {
                break;
            }
        }
        return insets;
    }

    private WindowInsets newDispatchApplyWindowInsets(WindowInsets insets) {
        final int count = getChildCount();
        for (int i = 0; i < count; i++) {
            getChildAt(i).dispatchApplyWindowInsets(insets);
        }
        return insets;
    }

See source code here

As you can see, it calls the broken function or the correct one depending on this variable -> depending on the android APIP version. Beautiful implementation, really.

This will affect all the ViewGroups that haven't overriden the dispatchApplyWindowInsets correctly. For instance, here is a little compilation of some MaterialDesign ViewGroups when fitSystemWindows is set to true:

  • DrawerLayout: From my understanding it should not be a problem?. This is because it dispatches the insets to each children independently in the OnMeasure function. However it doesn't override the ViewGroup function so the behavior might be unexpected (if it's called in the OnMeasure function it will work but if it's called from another place, for instance doing ViewCompat.dispatchApplyWindowInsets(_drawerLayout, insets) it won't work
  • CoordinatorLayout: Doesn't work properly. As you can see in the source code it emits the events to its behaviors in the same way that the broken viewGroup function does. Therefore if you need the behaviors to not be affected by sibling behaviors or nested sibling behaviors you should find a way to override this. This also applies to the dispatch to child views of CoordinatorLayout because it doesn't override the default ViewGroup function.

This is a known issue since 2022.

See the answer of the maintainers:

Prior to API 30, window insets were dispatched through the view hierarchy in a non-intuitive way: Rather than being a full breadth-first traversal, they would be dispatched in preorder. If the insets were consumed at any point (like if you use fitSystemWindows), then the preorder would prevent insets from being dispatched to the rest of the view hierarchy. This is very unintuitive: It meant that sibling views (or views deeper in the tree) could stop later views from receiving the dispatch of insets.

In API 30, this behavior was changed to be more intuitive: Now, insets are dispatched in a top-down manner, so they can't be consumed in this weird, cross-tree way.

Basically, this issue has been solved and is known. Small caveat though; only for API 30+. Not only this doesn't make any sense but is just enraging. What about solving it for everyone, even if it meant breaking changes for some people? (because the implementation in API 29- is not what the docs say it should, so it's actually a bug). What about at least making that variable public? Updating the docs?

Thanks Android, again.

The "solution"

Since we can't depend on this being solved ( as per usual ), there is a bit of a workaround. This consists in overriding dispatchApplyWindowInsets in the view groups above where you have fitSystemWindows defined, and manually perform the new, non-broken dispatching behavior.

In this case this would mean overriding dispatchApplyWindowInsets in the CoordinatorLayout.

I've done it in .Net Android but should be the same in java/kotlin:

[Register("com.myapp.Utility.CoordinatorLayout")]
class CoordinatorLayout: AndroidX.CoordinatorLayout.Widget.CoordinatorLayout
{
   ...

    public override WindowInsets DispatchApplyWindowInsets(WindowInsets insets)
    {
       if(Build.VERSION.SdkInt < BuildVersionCodes.R)
       {
           base.DispatchApplyWindowInsets(insets)
            if (insets.IsConsumed)
               return insets;
            int count = ChildCount;
            for (int i = 0; i < count; i++)
            {
                GetChildAt(i).DispatchApplyWindowInsets(insets);
            }
       }
       else {
           return base.DispatchApplyWindowInsets(insets);
       }
        return insets;
    }
}

This is the same as calling the function newDispatchApplyWindowInsets that you can see a bit above. Now, the reason why when I call super.dispatchApplyWindowInsets I don't save the returned insets is because then the ViewGroup implementation gets called (the one that we try to avoid), and thus the inset returned is marked as consumed (because a sibling or sibling child returned the insets).

Calling the View class dispatchApplyWindowInsets (you can do it in c# with some dirty reflection) also doesn't work because it calls the onApplyWindowInsets of CoordinatorLayout, which in turn modifies the insets by calling its children layoutParams behaviors (I know, confusing). You can see the source code for that here. You could avoid that by setting fitSystemWindows of the CoordinatorLayout to false but who knows what other issues would arise then.

If you do not need the CoordinatorLayout behaviors to do anything with insets you can just comment out the call the super function. However there are some views like the FloatingActionButton whose Behavior add a bottom inset in order to not be behind the navigation bar, which might be required. That's why I still call it, even if I don't save the returned insets.

I hope this is useful for someone that gets stuck here like me, and if someone has any ideas on how to solve this in a better way, please let me know :)

Pd: Currently the issue is being tracked again here

DavidMarquezF avatar Sep 16 '24 10:09 DavidMarquezF