Android導航欄的處理-HostStatusLayout加入底部的導航欄適配

語言: CN / TW / HK

theme: smartblue highlight: agate


持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第4天,點選檢視活動詳情

前言

在之前的文章中,大家比較關注宿主侵入的方式,並且有要求適配導航欄的操作。

其實大部分的應用都只需要使用到狀態列,導航欄由系統去管理,為什麼不自己管理導航欄,就是導航欄的坑太多。

背景設定的坑,判斷是否存在的坑,手動設定隱藏顯示導航欄的坑,導航欄高度獲取的坑。

如果專案中確實需要用到操作導航欄怎麼辦?

導航欄的處理

導航欄為什麼難處理,因為之前的一些新增Flag的方案有些不實用,有相容問題,也可以說手機廠商並沒有完全適配,導致相容性有問題。

而我們通過 WindowInsetsController / WindowInsets 的一些方式則可以相對方便的操作導航欄。

那麼是不是 WindowInsetsController / WindowInsets 的方式就完全相容了呢?也並不是,只是相對好一點,重要的功能能用而已。

下面介紹一下相對穩定的一些操作方法。

判斷當前是否顯示了導航欄: ```java /* * 當前是否顯示了底部導航欄 / public static void hasNavigationBars(Activity activity, BooleanValueCallback callback) {

    View decorView = activity.findViewById(android.R.id.content);
    boolean attachedToWindow = decorView.isAttachedToWindow();

    if (attachedToWindow) {

        WindowInsetsCompat windowInsets = ViewCompat.getRootWindowInsets(decorView);

        if (windowInsets != null) {

            boolean hasNavigationBar = windowInsets.isVisible(WindowInsetsCompat.Type.navigationBars()) &&
                    windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom > 0;

            callback.onBoolean(hasNavigationBar);
        }

    } else {

        decorView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
            @Override
            public void onViewAttachedToWindow(View v) {

                WindowInsetsCompat windowInsets = ViewCompat.getRootWindowInsets(v);

                if (windowInsets != null) {

                    boolean hasNavigationBar = windowInsets.isVisible(WindowInsetsCompat.Type.navigationBars()) &&
                            windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom > 0;

                    callback.onBoolean(hasNavigationBar);
                }
            }

            @Override
            public void onViewDetachedFromWindow(View v) {
            }
        });
    }
}

```

其實核心程式碼是一樣的,只是區分了是否已經onAttach了,防止在onCreate方法中呼叫的時候會報錯。

它的核心思路是和老版本的方法是相似的,只是老版本是從window中找到導航欄佈局去判斷是否隱藏和顯示和判斷高度。而新版本通過WindowInset 的方式獲取導航欄物件相對比較穩妥。

獲取導航欄的高度:

```java /* * 獲取底部導航欄的高度 / public static void getNavigationBarHeight(View view, HeightValueCallback callback) {

    boolean attachedToWindow = view.isAttachedToWindow();

    if (attachedToWindow) {

        WindowInsetsCompat windowInsets = ViewCompat.getRootWindowInsets(view);
        assert windowInsets != null;
        int top = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()).top;
        int bottom = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom;
        int height = Math.abs(bottom - top);
        if (height > 0) {
            callback.onHeight(height);
        } else {
            callback.onHeight(getNavigationBarHeight(view.getContext()));
        }

    } else {

        view.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
            @Override
            public void onViewAttachedToWindow(View v) {

                WindowInsetsCompat windowInsets = ViewCompat.getRootWindowInsets(v);
                assert windowInsets != null;
                int top = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()).top;
                int bottom = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom;
                int height = Math.abs(bottom - top);
                if (height > 0) {
                    callback.onHeight(height);
                } else {
                    callback.onHeight(getNavigationBarHeight(view.getContext()));
                }
            }

            @Override
            public void onViewDetachedFromWindow(View v) {
            }
        });
    }
}

/**
 * 老的方法獲取導航欄的高度
 */
private static int getNavigationBarHeight(Context context) {
    int result = 0;
    int resourceId = context.getResources().getIdentifier("navigation_bar_height", "dimen", "android");
    if (resourceId > 0) {
        result = context.getResources().getDimensionPixelSize(resourceId);
    }
    return result;
}

```

新版的方法和老版本的方法都定義了,通常我們使用 WindowInsets 的方式即可獲取到導航欄物件,然後去獲取它的高度。

而老版本的方式則是通過獲取系列內建的一個高度值,而一些手機並不會按這個高度設定導航欄高度,所以獲取出來的值則是錯誤的。

如下圖所示:

導航欄的隱藏與沉浸式處理:

在一些應用需要全屏的時候,我們需要隱藏導航欄(是的,你無法返回了)。

```java /* * 顯示隱藏底部導航欄(注意不是沉浸式效果) / public static void showHideNavigationBar(Activity activity, boolean isShow) {

    View decorView = activity.findViewById(android.R.id.content);
    WindowInsetsControllerCompat controller = ViewCompat.getWindowInsetsController(decorView);

    if (controller != null) {
        if (isShow) {
            controller.show(WindowInsetsCompat.Type.navigationBars());
            controller.setSystemBarsBehavior(WindowInsetsControllerCompat.BEHAVIOR_SHOW_BARS_BY_TOUCH);
        } else {
            controller.hide(WindowInsetsCompat.Type.navigationBars());
            controller.setSystemBarsBehavior(WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
        }
    }
}

```

而在一些常規的頁面,我們如果想像狀態列一樣獲取沉浸式體驗,我們則是不同的處理邏輯:

```java /* * 5.0以上-設定NavigationBar底部導航欄的沉浸式 / public static void immersiveNavigationBar(Activity activity) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { Window window = activity.getWindow(); View decorView = window.getDecorView(); decorView.setSystemUiVisibility(decorView.getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE);

        window.setNavigationBarColor(Color.TRANSPARENT);
    }
}

```

我們把導航欄常用的一些操作理清之後,我們再來看 StatusHostLayout 這樣的宿主方案如何幫助我們管理導航欄。

修改StatusHostLayout方案

前文我們講到過狀態列的管理,如果加入導航欄的管理,我們需要做哪些操作?

先理清一下思路:

  1. 定義一個自定義的ViewGroup,內部順序排列狀態列,內容容器,導航欄三個佈局。
  2. 我們需要強制設定狀態列和導航欄的沉浸式,讓我們自己的狀態列.導航欄View的佈局展示出來。
  3. 自定義狀態列View,與導航欄View,我們只需要獲取到正確的高度,然後測量的時候定死指定的高度即可。
  4. 我們可以以View的形式來操作自定義導航欄/狀態列的背景,圖片,顯示隱藏等操作。
  5. 把我們DecorView中的跟檢視替換為我們自定義的佈局。
  6. 暴露一個inject方法注入到指定的Activity中去,並提供自定義佈局的物件。

之前狀態列的邏輯已經做好了,現在我們只需要處理導航欄的邏輯。我們定義好上面的一些導航欄操作工具類方法。

先定義一個自己的導航欄View,只需要處理高度即可。

```java /* * 自定義底部導航欄的View,用於StatusBarHostLayout中使用 / class NavigationView extends View {

private int mBarSize;

public NavigationView(Context context) {
    this(context, null, 0);
}

public NavigationView(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
}

public NavigationView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);

    StatusBarHostUtils.getNavigationBarHeight(this, new HeightValueCallback() {
        @Override
        public void onHeight(int height) {

            mBarSize = height;
        }
    });
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mBarSize);
    } else {
        setMeasuredDimension(0, 0);
    }
}

public int getBarSize() {
    return mBarSize;
}

} ```

然後在自定義的佈局中新增我們的導航欄View

```java //載入自定義的宿主佈局 if (mStatusView == null && mContentLayout == null) { setOrientation(LinearLayout.VERTICAL);

    mStatusView = new StatusView(mActivity);
    mStatusView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
    addView(mStatusView);

    mContentLayout = new FrameLayout(mActivity);
    mContentLayout.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1.0f));
    addView(mContentLayout);

    mNavigationView = new NavigationView(mActivity);
    mNavigationView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
    addView(mNavigationView);
}

```

核心方法是替換掉 DecorView 中的 ContentView: ```java private void replaceContentView() { Window window = mActivity.getWindow(); ViewGroup contentLayout = window.getDecorView().findViewById(Window.ID_ANDROID_CONTENT); if (contentLayout.getChildCount() > 0) { //先找到DecorView的容器移除掉已經設定的ContentView View contentView = contentLayout.getChildAt(0); contentLayout.removeView(contentView); ViewGroup.LayoutParams contentParams = contentView.getLayoutParams();

        //外部設定的ContentView新增到宿主中來
        mContentLayout.addView(contentView, contentParams.width, contentParams.height);
    }
    //再把整個宿主新增到Activity對應的DecorView中去
    contentLayout.addView(this, -1, -1);
}

```

然後我們暴露一些公共的方法供外界操作我們自定義的導航欄: ```java /* * 設定導航欄圖片顏色為黑色 / public StatusBarHostLayout setNavigatiopnBarIconBlack() { StatusBarHostUtils.setNavigationBarDrak(mActivity, true); return this; }

/**
 * 設定導航欄圖片顏色為白色
 */
public StatusBarHostLayout setNavigatiopnBarIconWhite() {
    StatusBarHostUtils.setNavigationBarDrak(mActivity, false);
    return this;
}

  /**
 * 設定自定義狀態列佈局的背景顏色
 */
public StatusBarHostLayout setNavigationBarBackground(int color) {
    if (mNavigationView != null)
        mNavigationView.setBackgroundColor(color);
    return this;
}

/**
 * 設定自定義狀態列佈局的背景圖片
 */
public StatusBarHostLayout setNavigationBarBackground(Drawable drawable) {
    if (mNavigationView != null)
        mNavigationView.setBackground(drawable);
    return this;
}

/**
 * 設定自定義狀態列佈局的透明度
 */
public StatusBarHostLayout setNavigationBarBackgroundAlpha(int alpha) {
    if (mNavigationView != null) {
        Drawable background = mNavigationView.getBackground();
        if (background != null) {
            background.mutate().setAlpha(alpha);
        }
    }
    return this;
}

/**
 * 設定自定義導航欄的沉浸式
 */
public StatusBarHostLayout setNavigationBarImmersive(boolean needImmersive, int color) {
    if (mNavigationView != null) {
        if (needImmersive) {
            mNavigationView.setVisibility(GONE);
        } else {
            mNavigationView.setVisibility(VISIBLE);
            mNavigationView.setBackgroundColor(color);
        }
    }
    return this;
}

```

使用的時候我們這樣用:

```kotlin val hostLayout = StatusBarHost.inject(this) .setStatusBarBackground(startColor) .setStatusBarBlackText() .setNavigationBarBackground(startColor)

//修改導航欄的圖示顏色 - 深色
fun btn07(view: View) {
    hostLayout.setNavigationBarIconBlack()
}

//修改導航欄的圖示顏色 - 亮色
fun btn08(view: View) {
    hostLayout.setNavigationBarIconWhite()
}

fun btn06(view: View) {
    hostLayout.setNavigationBarBackground(resources.getColor(R.color.teal_200))
}

```

其中的一些效果如下圖所示,更多的示例程式碼可以檢視原始碼:

狀態列的操作:

導航欄的操作:

狀態列與導航欄的沉浸式處理

狀態列與導航欄圖片背景的設定

全面屏手機與老款的可動態隱藏導航欄的手機都能正確的判斷是否有導航欄:

host_layout_01.gif

Android5.0的老款手機,不帶內建導航欄的:

device-2022-09-30-095520 00_00_00-00_00_30.gif

Android12三星手機滾動的效果:

總結

由於使用了 WindowInsetsController 的Api,所以本方案支援Android5.0+版本。

有關更多的Demo與效果可以檢視我的原始碼專案,點選檢視,我會持續更新和優化。大家可以點個Star關注一波。

關於本文的Demo我也單獨做了專案與Demo的效果,點選檢視

如果你想直接使用,我也已經上傳到 MavenCentral ,直接依賴即可。

implementation "com.gitee.newki123456:status_host_layout:1.0.0"

慣例,我如有講解不到位或錯漏的地方,希望同學們可以指出交流。

如果感覺本文對你有一點點的啟發,還望你能點贊支援一下,你的支援是我最大的動力。

Ok,這一期就此完結。

「其他文章」