沉浸式布局应用整理

1,602 阅读3分钟

沉浸式布局能够使得状态栏和内容区域更加融合,带来更加美好的视觉效果。

未处理的效果:

我们要的效果:

我们要修改状态栏的颜色,安卓5.0的时候已经提供了相关API。但是我们同时也需要将状态栏的字体颜色设置成黑色才可以,否则状态栏文字就看不见。状态栏字体颜色的修改直到6.0才提供了相关API,所以对沉浸式的适配我们基于Android6.0。

适配状态栏的工具方法如下:

package com.znq.demo.utils;

import android.app.Activity;
import android.graphics.Color;
import android.os.Build;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;

public class StatusBar{
    public static void fitSystemBar(Activity activity){
        if(Build.VERSION.SDK_INT < Build.VERSION_CODES.M){
            return;
        }
        Window window = activity.getWindow();
        View decorView = window.getDecorView();
        decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN|View.SYSTEM_UI_FLAG_LAYOUT_STABLE|View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
        
        window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
        window.setStatusBarColor(Color.TRANSPARENT);
        
    }
}

适配状态栏,首先判断是否为6.0之上的设备,如果低于6.0就不做操作,直接返回;否则获取decorView,调用decorView的setSystemUiVisibility,为decorView增加对应的flag:

  • View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN:能够使得我们的布局页面延伸到状态栏之下,但不会隐藏状态栏。
  • View.SYSTEM_UI_FLAG_LAYOUT_STABLE:无论底部导航键是显示还是隐藏,都能保证页面布局在状态栏执行,同时保证布局宽高匹配屏幕尺寸。
  • View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR:将状态栏设置为白底黑字的样式

View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREENView.SYSTEM_UI_FLAG_FULLSCREEN区别?

View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN:能够使得我们的布局页面延伸到状态栏之下,但不会隐藏状态栏。也就相当于状态栏是遮盖在布局之上。View.SYSTEM_UI_FLAG_FULLSCREEN:能够使得页面的布局延伸到状态栏,但是会隐藏状态栏。它和WindowManager.LayoutParams.FLAG_FULLSCREEN作用一样。

指定了decorView的Flag还需要处理下window。首先为Widnow增加FLag:WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS,开启Wondow对状态栏背景进行绘制。开启Window对状态栏绘制之后,就需要指定下状态栏的颜色Color.TRANSPARENT,如果不指定,Widow的状态栏就是灰色的。这样就处理完成了。

在需要适配状态栏的Activity的onCreate方法应用该工具方法之后,页面的布局就能延伸到状态栏之下,但这个时候页面的文字就会和状态栏的文字重叠 ,所以还需要对每个页面的根布局设置属性android:fitSystenWindows="true"

NavHostFragment适配问题

运行起来发现首页的状态栏展示正常,但是沙发页面的状态栏文字和页面文字还是重叠。

android:fitsSystemWindows="true"这个属性的作用就是给每个页面添加一个上下左右的内间距,使得页面能够适应状态栏。首页和沙发页面都是添加都MainActivity的NavHostFragment里面的,问题也就处在这里:

<?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"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent">


    <fragment
        android:id="@+id/nav_host_fragment"
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_marginBottom="56dp"
        android:tag="nav_host_fragment"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toTopOf="@id/nav_view"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

...

在NavHostFragnent的onCreateView方法中,会创建一个FrameLayout:

  @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
                             @Nullable Bundle savedInstanceState) {
        FrameLayout frameLayout = new FrameLayout(inflater.getContext());
        // When added via XML, this has no effect (since this FrameLayout is given the ID
        // automatically), but this ensures that the View exists as part of this Fragment's View
        // hierarchy in cases where the NavHostFragment is added programmatically as is required
        // for child fragment transactions
        frameLayout.setId(getId());
        return frameLayout;
    }

FrameLayout是继承自ViewGroup,问题也就在ViewGroup中。ViewGrop中有个分发适应状态栏的方法dispatchApplyWindowInsets,在这个分发的过程如果WindowInsets没有被消费才回去分发,在分发的过程中,通过dispatchApplyWindowInsets方法将事件分发给子View,判断了ViewGroup的子View 如果将WindowInsets消费了,就直接return,后面的子View就不会收到dispatchApplyWindowInsets事件。也就是只有首页的布局被加入到NavHostFragment创建的FrameLayout可以收到适配状态栏的分发方法dispatchApplyWindowInsets,沙发页面的布局在被加入到NavHostFragment创建的FrameLayout的时候没有调用到dispatchApplyWindowInsets方法,自然无法设置状态栏间隔,导致页面布局文字和系统状态栏文字重叠。

ViewGroup中的dispatchApplyWindowInsets方法:

@Override
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {
        if (!insets.isConsumed()) {
            final int count = getChildCount();
            for (int i = 0; i < count; i++) {
                insets = getChildAt(i).dispatchApplyWindowInsets(insets);
                if (insets.isConsumed()) {//Android 10之后就没有这个判断了
                    break;
                }
            }
        }
        return insets;
    }

ViewGroup中的dispatchApplyWindowInsets的方法是在ViewRootImpl的ViewGroup中的dispatchApplyInsets被调用的。ViewRootImpl的dispatchApplyInsets如下:

 void dispatchApplyInsets(View host) {
        Trace.traceBegin(Trace.TRACE_TAG_VIEW, "dispatchApplyInsets");
        WindowInsets insets = getWindowInsets(true /* forceConstruct */);
        final boolean dispatchCutout = (mWindowAttributes.layoutInDisplayCutoutMode
                == LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS);
        if (!dispatchCutout) {
            // Window is either not laid out in cutout or the status bar inset takes care of
            // clearing the cutout, so we don't need to dispatch the cutout to the hierarchy.
            insets = insets.consumeDisplayCutout();
        }
        host.dispatchApplyWindowInsets(insets);
        Trace.traceEnd(Trace.TRACE_TAG_VIEW);
    }

ViewRootImpl的dispatchApplyInsets这个方法在Activity的setContentView时候会被调用。

明白了问题原因,解决起来就容易了。解决办法:

  • 首先自定义View,继承FrameLayout,覆写dispatchApplyWindowInsets方法:在子View消费了WindowInsets后仍要将适配状态栏的事件分发给下面的子View
  • 完成上面步骤后,再需要复写addView方法:在addView方法中请求ViewRootImpl重新分发适配状态栏的方法

具体的代码实现如下:

package com.znq.demo.view;

import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.view.WindowInsets;
import android.widget.FrameLayout;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

public class NavHostFrameLayout extends FrameLayout {
    public NavHostFrameLayout(@NonNull Context context) {
        super(context);
    }

    public NavHostFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    public NavHostFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    public NavHostFrameLayout(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }

    @Override
    public void addView(View child) {
        super.addView(child);
        requestApplyInsets();
    }

    @Override
    public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {
        insets = super.dispatchApplyWindowInsets(insets);
        if (!insets.isConsumed()) {
            final int count = getChildCount();
            for (int i = 0; i < count; i++) {
                insets = getChildAt(i).dispatchApplyWindowInsets(insets);
            }
        }
        return insets;
    }
}

这样无论先添加还是后添加的View都能收到适配状态栏的事件。

重写完的布局文件还需要被应用了才会起作用。这就需要重写NavHostFragment的onCreateView方法,将其创建的FrameLayout替换成上面处理过的NavHostFrameLayout。

package com.znq.demo.view;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.navigation.fragment.NavHostFragment;

public class FixedNavHostFragment extends NavHostFragment {
    @Nullable
    @Override
    public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
        NavHostFrameLayout frameLayout = new NavHostFrameLayout(inflater.getContext());
        // When added via XML, this has no effect (since this FrameLayout is given the ID
        // automatically), but this ensures that the View exists as part of this Fragment's View
        // hierarchy in cases where the NavHostFragment is added programmatically as is required
        // for child fragment transactions
        frameLayout.setId(getId());
        return frameLayout;
    }
}

最后,将MainActivity布局文件之中的NavHostFragment替换成上面的FixedNavHostFragment就大功告成了。

<?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"
    android:id="@+id/container"
    android:layout_width="match_parent"
    android:layout_height="match_parent">


    <fragment
        android:id="@+id/nav_host_fragment"
        android:name="com.znq.demo.view.FixedNavHostFragment"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_marginBottom="56dp"
        android:tag="nav_host_fragment"
        app:defaultNavHost="true"
        app:layout_constraintBottom_toTopOf="@id/nav_view"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />