SystemUI 系统导航栏 NavigationBar 分析(AndroidO)

1 概述

导航栏是指在屏幕底端或侧边容纳了一排虚拟按键的一个窗口,这些按键包括了常用的返回键、主页键、最近任务键等。导航栏窗口作为 SystemUI 的一个重要组件,它的启动时机是在状态栏之后,同时导航栏窗口也是通过 WindowManager.addView() 方法创建的。

2 导航栏分析

2.1 导航栏窗口创建

和状态栏一样,导航栏的创建入口也是在 StatusBar.makeStatusBarView()。

// frameworks\base\packages\SystemUI\src\com\android\systemui\statusbar\phone\StatusBar.java
protected void makeStatusBarView() {
    // 创建状态栏...
    ...
    // 创建导航栏
    try {
        boolean showNav = mWindowManagerService.hasNavigationBar(); // 1 是否显示导航栏
        if (showNav) {
            createNavigationBar(); // 2 创建导航栏的控件树
        }
    } catch (RemoteException ex) {
        // no window manager? good luck with that
    }
    ...
}

通过 hasNavigationBar() 判断是否需要创建显示导航栏,首先看配置文件,然后检查系统属性 qemu.hw.mainkeys 是否设置,两个判断标志都在 PhoneWindowManager 类的 setInitialDisplaySize() 方法中获取设置。

// frameworks\base\services\core\java\com\android\server\wm\WindowManagerService.java 
public boolean hasNavigationBar() {
    return mPolicy.hasNavigationBar();
}

// frameworks\base\services\core\java\com\android\server\policy\PhoneWindowManager.java
public class PhoneWindowManager implements WindowManagerPolicy {
    boolean mHasNavigationBar = false;
    public boolean hasNavigationBar() {
        return mHasNavigationBar; //
    }

    public void setInitialDisplaySize(Display display, int width, int height, int density) {
        // frameworks\base\core\res\res\values\config.xml  默认为true
        mHasNavigationBar = res.getBoolean(com.android.internal.R.bool.config_showNavigationBar);
        // 可以在模拟器中通过设置系统属性进行覆盖
        String navBarOverride = SystemProperties.get("qemu.hw.mainkeys");
        if ("1".equals(navBarOverride)) {
            mHasNavigationBar = false;
        } else if ("0".equals(navBarOverride)) {
            mHasNavigationBar = true;
        }
    } 
}

createNavigationBar() 通过调用 NavigationBarFragment.create() 来加载导航栏控件树。

// frameworks\base\packages\SystemUI\src\com\android\systemui\statusbar\phone\StatusBar.java
private NavigationBarFragment mNavigationBar;
private View mNavigationBarView;
protected void createNavigationBar() {
    // navigation_bar_window.xml
    mNavigationBarView = NavigationBarFragment.create(mContext, (tag, fragment) -> { 
        // navigation_bar.xml
        mNavigationBar = (NavigationBarFragment) fragment; 
        if (mLightBarController != null) {
            mNavigationBar.setLightBarController(mLightBarController);
        }
        mNavigationBar.setCurrentSysuiVisibility(mSystemUiVisibility);
    });
}

create() 方法和创建状态栏相似,同样是调用 WindowManager.addView 创建导航栏窗口,而在 onCreateView() 方法中真正加载的 RootView 是 navigation_bar.xml 布局。在这里,导航栏的窗口类型是 TYPE_NAVIGATION_BAR,导航栏的宽度高度都是 MATCH_PARENT,但不会充满整个屏幕,而是充满由 WindowManager 提供的父布局 ,其大小是在 PhoneWindowManager.beginLayoutLw() 中计算的,后面会分析到。

// frameworks\base\packages\SystemUI\src\com\android\systemui\statusbar\phone\NavigationBarFragment.java
public static View create(Context context, FragmentListener listener) {
    WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
            LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT, // 高度宽度
            WindowManager.LayoutParams.TYPE_NAVIGATION_BAR, //窗口类型 FIRST_SYSTEM_WINDOW+19
            WindowManager.LayoutParams.FLAG_TOUCHABLE_WHEN_WAKING // 接受导致设备唤醒的触摸事件
                    | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE //不接受按键事件
                    | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL // 消耗所有指针事件本身,而不管它们是否在窗口内
                    | WindowManager.LayoutParams.FLAG_SPLIT_TOUCH // // 该窗口将接受超出其边界的触摸事件,并将其发送到也支持拆分触摸的其他窗口
                    | WindowManager.LayoutParams.FLAG_SLIPPERY, // 在手势中期,使触摸能够从窗口滑出到相邻窗口中,而不是在手势持续时间内被捕获
            PixelFormat.TRANSLUCENT);
    lp.token = new Binder();
    lp.setTitle("NavigationBar");
    lp.windowAnimations = 0; // 不使用窗口动画

    //加载导航栏控件树 frameworks\base\packages\SystemUI\res\layout\navigation_bar_window.xml
    View navigationBarView = LayoutInflater.from(context).inflate(
            R.layout.navigation_bar_window, null);
    if (navigationBarView == null) return null;
	// 创建导航栏窗口
    context.getSystemService(WindowManager.class).addView(navigationBarView, lp);
    
    FragmentHostManager fragmentHost = FragmentHostManager.get(navigationBarView);
    NavigationBarFragment fragment = new NavigationBarFragment();
    fragmentHost.getFragmentManager().beginTransaction()
            .replace(R.id.navigation_bar_frame, fragment, TAG) // navigation_bar_window.xml
            .commit();
    fragmentHost.addTagListener(TAG, listener);
    return navigationBarView;
}

NavigationBarFragment

// frameworks\base\packages\SystemUI\src\com\android\systemui\statusbar\phone\NavigationBarFragment.java
public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
                         Bundle savedInstanceState) {
    // 加载布局  frameworks\base\packages\SystemUI\res\layout\navigation_bar.xml
    return inflater.inflate(R.layout.navigation_bar, container, false);
}

@Override
public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);
    mNavigationBarView = (NavigationBarView) view;
    // 设置导航栏按键触摸事件的监听器
    prepareNavigationBarView();
    ...
}
private void prepareNavigationBarView() {
    mNavigationBarView.reorient();
    ButtonDispatcher recentsButton = mNavigationBarView.getRecentsButton();
    recentsButton.setOnClickListener(this::onRecentsClick);
    recentsButton.setOnTouchListener(this::onRecentsTouch);
    recentsButton.setLongClickable(true);
    recentsButton.setOnLongClickListener(this::onLongPressBackRecents);

    ButtonDispatcher backButton = mNavigationBarView.getBackButton();
    backButton.setLongClickable(true);
    backButton.setOnLongClickListener(this::onLongPressBackRecents);
    ButtonDispatcher homeButton = mNavigationBarView.getHomeButton();

    homeButton.setOnTouchListener(this::onHomeTouch);
    homeButton.setOnLongClickListener(this::onHomeLongClick);
    ...
}

再来看一下 PhoneWindowManager.beginLayoutLw() 是怎么计算的导航栏位置的。

一般来说,导航栏位于屏幕底部和右侧,这是根据屏幕方向及高宽大小决定的。 这里 mNavigationBarCanMove 变量代表导航栏是否可以移动,是在PhoneWindowManager.setInitialDisplaySize() 方法中设置的。mNavigationBarPosition 变量代表导航栏最终的布局位置。

// frameworks\base\services\core\java\com\android\server\policy\PhoneWindowManager.java
public void beginLayoutLw(boolean isDefaultDisplay, int displayWidth, int displayHeight,int displayRotation, int uiMode) {
    ...
        boolean updateSysUiVisibility = layoutNavigationBar(displayWidth, displayHeight,displayRotation, uiMode, overscanLeft, overscanRight, overscanBottom, dcf, navVisible, navTranslucent,navAllowedHidden, statusBarExpandedNotKeyguard); //
    updateSysUiVisibility |= layoutStatusBar(pf, df, of, vf, dcf, sysui, isKeyguardShowing); //
    if (updateSysUiVisibility) {
        updateSystemUiVisibilityLw();
    }
}

private boolean layoutNavigationBar(int displayWidth, int displayHeight, int displayRotation,
                                    int uiMode, int overscanLeft, int overscanRight, int overscanBottom, ...) {
    if (mNavigationBar != null) {
        // 根据屏幕方向及高宽大小选择导航栏的位置
        mNavigationBarPosition = navigationBarPosition(displayWidth, displayHeight,displayRotation); //
        if (mNavigationBarPosition == NAV_BAR_BOTTOM) {  // 在屏幕底部
            int top = displayHeight - overscanBottom
                - getNavigationBarHeight(displayRotation, uiMode);
            mTmpNavigationFrame.set(0, top, displayWidth, displayHeight - overscanBottom);
            ...
        } else if (mNavigationBarPosition == NAV_BAR_RIGHT) { // 屏幕右侧
            int left = displayWidth - overscanRight
                - getNavigationBarWidth(displayRotation, uiMode);
            mTmpNavigationFrame.set(left, 0, displayWidth - overscanRight, displayHeight);
            ...
        } else if (mNavigationBarPosition == NAV_BAR_LEFT) { // 屏幕左侧
            // Seascape screen; nav bar goes to the left.
            int right = overscanLeft + getNavigationBarWidth(displayRotation, uiMode);
            mTmpNavigationFrame.set(overscanLeft, 0, right, displayHeight);
            ...
        }
    }
    ...
    // 计算最终的导航栏布局
    mNavigationBar.computeFrameLw(mTmpNavigationFrame, mTmpNavigationFrame,mTmpNavigationFrame, 
              mTmpNavigationFrame, mTmpNavigationFrame, dcf,mTmpNavigationFrame, mTmpNavigationFrame);
}

private int navigationBarPosition(int displayWidth, int displayHeight, int displayRotation) {
    // mNavigationBarCanMove 是根据 mNavigationBarCanMove = width != height && shortSizeDp < 600; 得到的
    if (mNavigationBarCanMove && displayWidth > displayHeight) {
        if (displayRotation == Surface.ROTATION_270) {
            return NAV_BAR_LEFT;
        } else {
            return NAV_BAR_RIGHT;
        }
    }
    return NAV_BAR_BOTTOM;
}

2.2 导航栏布局加载

从上面可以看到导航栏窗口布局主要加载了 navigation_bar.xml,该布局下有两个控件,一个是 NavigationBarView,一个是 NavigationBarInflaterView。

// frameworks\base\packages\SystemUI\res\layout\navigation_bar.xml
<com.android.systemui.statusbar.phone.NavigationBarView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:systemui="http://schemas.android.com/apk/res-auto"
    android:layout_height="match_parent"
    android:layout_width="match_parent"
    android:background="@drawable/system_bar_background">
	
<com.android.systemui.statusbar.phone.NavigationBarInflaterView
    android:id="@+id/navigation_inflater"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
</com.android.systemui.statusbar.phone.NavigationBarView>
2.2.1 NavigationBarView

在构造方法中初始化导航栏相关图标的信息。

// frameworks\base\packages\SystemUI\src\com\android\systemui\statusbar\phone\NavigationBarView.java
public NavigationBarView(Context context, AttributeSet attrs) {
    super(context, attrs);
    ...
    mConfiguration = new Configuration();
    mConfiguration.updateFrom(context.getResources().getConfiguration());
    // 根据配置信息更新图标
    updateIcons(context, Configuration.EMPTY, mConfiguration);
    mBarTransitions = new NavigationBarTransitions(this);
    // 存储导航栏相关按键信息
    mButtonDispatchers.put(R.id.back, new ButtonDispatcher(R.id.back));
    mButtonDispatchers.put(R.id.home, new ButtonDispatcher(R.id.home));
    mButtonDispatchers.put(R.id.recent_apps, new ButtonDispatcher(R.id.recent_apps));
    mButtonDispatchers.put(R.id.menu, new ButtonDispatcher(R.id.menu));
    mButtonDispatchers.put(R.id.ime_switcher, new ButtonDispatcher(R.id.ime_switcher));
    mButtonDispatchers.put(R.id.accessibility_button, new ButtonDispatcher(R.id.accessibility_button));
}

updateIcons() 根据不同的情况会重新加载导航栏图标,除了在构造方法中,这个方法也会在 onConfigurationChanged() 中被调用

private void updateIcons(Context ctx, Configuration oldConfig, Configuration newConfig) {
    if (oldConfig.orientation != newConfig.orientation
                || oldConfig.densityDpi != newConfig.densityDpi) {
            // 更新分屏后的最近任务图标
            mDockedIcon = getDrawable(ctx,
                    R.drawable.ic_sysbar_docked, R.drawable.ic_sysbar_docked_dark);
        }
    if (oldConfig.densityDpi != newConfig.densityDpi) {
        mBackIcon = getDrawable(ctx, R.drawable.ic_sysbar_back, R.drawable.ic_sysbar_back_dark);
        mBackLandIcon = mBackIcon;
        mBackAltIcon = getDrawable(ctx,
                R.drawable.ic_sysbar_back_ime, R.drawable.ic_sysbar_back_ime_dark);
        mBackAltLandIcon = mBackAltIcon;
        mHomeDefaultIcon = getDrawable(ctx,
                R.drawable.ic_sysbar_home, R.drawable.ic_sysbar_home_dark);
        mRecentIcon = getDrawable(ctx,
                R.drawable.ic_sysbar_recent, R.drawable.ic_sysbar_recent_dark);
        mMenuIcon = getDrawable(ctx, R.drawable.ic_sysbar_menu, R.drawable.ic_sysbar_menu_dark);
        ...
    }
}

再来看一下最终设置导航栏图标的方法,入口是 setNavigationIconHints(),最后会从 mButtonDispatchers 中获取不同图标布局id对应的 ButtonDispatcher,通过 ButtonDispatcher 设置图标。

public void setNavigationIconHints(int hints, boolean force) {
    ...
    // 返回按钮图标 (在横竖屏切换时返回图标的方向也会改变)
    KeyButtonDrawable backIcon = (backAlt)
            ? getBackIconWithAlt(mUseCarModeUi, mVertical)
            : getBackIcon(mUseCarModeUi, mVertical);
    getBackButton().setImageDrawable(backIcon);
	// 最近任务图标  分屏情况下会显示不同的图标 
    // 这个方法也会被这两个方法回调: onFinishInflate() / onConfigurationChanged() 
    updateRecentsIcon();
    if (mUseCarModeUi) {
        getHomeButton().setImageDrawable(mHomeCarModeIcon);
    } else {
        getHomeButton().setImageDrawable(mHomeDefaultIcon); // Home 图标
    }
    ...
}

public ButtonDispatcher getHomeButton() {
    return mButtonDispatchers.get(R.id.home);
}
...

讲到这里,可能对 ButtonDispatcher 这个类会有疑惑,它到底是用来干嘛的呢?它其实是用来存储不同的虚拟按键的相关信息,但由于导航栏布局位置的差异,就会存在加载多个导航栏布局文件的情况,而按键图标,按键事件等信息自然就会对应地设置很多次,为了对这些不同布局下的同一类按键信息进行统一管理,就有了 ButtonDispatcher 。

在 NavigationBarView 内部使用了 ButtonDispatcher 类型的 Map 数组变量 mButtonDispatchers, 根据控件id来对应一个按键类型的 ButtonDispatcher,而 ButtonDispatcher 下面则又包含java了不同布局下的同一类按键信息,这里使用 View 类型数组变量 mViews 进行存储,当需要对这一类按键信息进行处理时,就会循环 mViews 数组进行设置,如更新图标、设置监听器等。

相关的代码调用链如下:

// frameworks\base\packages\SystemUI\src\com\android\systemui\statusbar\phone\NavigationBarView.java
public void onFinishInflate() { // 在 xml 布局加载完之后,会回调 onFinishInflate() 方法
    mNavigationInflaterView = (NavigationBarInflaterView) findViewById(
            R.id.navigation_inflater);
    updateRotatedViews();
    mNavigationInflaterView.setButtonDispatchers(mButtonDispatchers);
    ...
}
// frameworks\base\packages\SystemUI\src\com\android\systemui\statusbar\phone\NavigationBarInflaterView.java
public void setButtonDispatchers(SparseArray<ButtonDispatcher> buttonDisatchers) {
    mButtonDispatchers = buttonDisatchers;
    for (int i = 0; i < buttonDisatchers.size(); i++) {
        initiallyFill(buttonDisatchers.valueAt(i));
    }
}
// frameworks\base\packages\SystemUI\src\com\android\systemui\statusbar\phone\NavigationBarInflaterView.java
private void initiallyFill(ButtonDispatcher buttonDispatcher) { // 添加各个布局下的按键
    addAll(buttonDispatcher, (ViewGroup) mRot0.findViewById(R.id.ends_group));
    addAll(buttonDispatcher, (ViewGroup) mRot0.findViewById(R.id.center_group));
    addAll(buttonDispatcher, (ViewGroup) mRot90.findViewById(R.id.ends_group));
    addAll(buttonDispatcher, (ViewGroup) mRot90.findViewById(R.id.center_group));
}

private void addAll(ButtonDispatcher buttonDispatcher, ViewGroup parent) {
    for (int i = 0; i < parent.getChildCount(); i++) {
        // 判断parent布局下的子id 是否和当前的 buttonDispatcher 的 id 相同
        if (parent.getChildAt(i).getId() == buttonDispatcher.getId()) { 
            buttonDispatcher.addView(parent.getChildAt(i)); // 直接添加到 mViews
        } else if (parent.getChildAt(i) instanceof ViewGroup) {
            addAll(buttonDispatcher, (ViewGroup) parent.getChildAt(i));
        }
    }
}
public class ButtonDispatcher {
    private final ArrayList<View> mViews = new ArrayList<>();
    // mViews 元素添加方法
    void addView(View view) {
        mViews.add(view);
        view.setOnClickListener(mClickListener);
        view.setOnTouchListener(mTouchListener);
        view.setOnLongClickListener(mLongClickListener);
        ...
    }
    // 为同一类按键设置图标的方法
    public void setImageDrawable(KeyButtonDrawable drawable) {
        mImageDrawable = drawable;
        final int N = mViews.size();
        for (int i = 0; i < N; i++) {
            ((ButtonInterface) mViews.get(i)).setImageDrawable(mImageDrawable);
        }
    }
}
2.2.2 NavigationBarInflaterView

先看构造方法。

// frameworks\base\packages\SystemUI\src\com\android\systemui\statusbar\phone\NavigationBarInflaterView.java
protected LayoutInflater mLayoutInflater;
protected LayoutInflater mLandscapeInflater;

public NavigationBarInflaterView(Context context, AttributeSet attrs) {
    super(context, attrs);
    Display display = ((WindowManager);
    createInflaters(); // 
    Display display = ((WindowManager)
            context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
    Mode displayMode = display.getMode();
    isRot0Landscape = displayMode.getPhysicalWidth() > displayMode.getPhysicalHeight(); // 是否横屏
}

createInflaters() 分别初始化了 mLayoutInflater 和 mLandscapeInflater。

private void createInflaters() {
    mLayoutInflater = LayoutInflater.from(mContext);
    Configuration landscape = new Configuration();
    landscape.setTo(mContext.getResources().getConfiguration());
    landscape.orientation = Configuration.ORIENTATION_LANDSCAPE;
    mLandscapeInflater = LayoutInflater.from(mContext.createConfigurationContext(landscape));
}

在 xml 布局加载完之后,会回调 onFinishInflate() 方法

@Override
protected void onFinishInflate() {
    super.onFinishInflate();
    inflateChildren(); // 1 加载导航栏布局
    inflateLayout(getDefaultLayout()); // 2 加载按键控件
}

protected String getDefaultLayout() {
    //left,back;home;recent,right  
    return mContext.getString(R.string.config_navBarLayout);  // 从SystemUI\res\values\config.xml配置文件中获取
}
  1. 加载导航栏布局

inflateChildren() 得到 mRot0 和 mRot90 两个布局,分别用于底部与侧面的导航栏布局,根据方向进行显示。

private void inflateChildren() {
    removeAllViews();
    mRot0 = (FrameLayout) mLayoutInflater.inflate(R.layout.navigation_layout, this, false);
    mRot0.setId(R.id.rot0);
    addView(mRot0);
    mRot90 = (FrameLayout) mLayoutInflater.inflate(R.layout.navigation_layout_rot90, this,
            false);
    mRot90.setId(R.id.rot90);
    addView(mRot90);
    if (getParent() instanceof NavigationBarView) {
        ((NavigationBarView) getParent()).updateRotatedViews(); // 更新视图
    }
}

初始化不同方向的导航栏布局并根据当前方向显示对应导航栏。

void updateRotatedViews() {
    mRotatedViews[Surface.ROTATION_0] =
        mRotatedViews[Surface.ROTATION_180] = findViewById(R.id.rot0);
    mRotatedViews[Surface.ROTATION_270] =
        mRotatedViews[Surface.ROTATION_90] = findViewById(R.id.rot90);

    final int rot = mDisplay.getRotation();
    for (int i=0; i<4; i++) {
        mRotatedViews[i].setVisibility(View.GONE);
    }
    mCurrentView = mRotatedViews[rot];
    mCurrentView.setVisibility(View.VISIBLE); // 设置当前方向位置的导航栏布局可见
    mNavigationInflaterView.setAlternativeOrder(rot == Surface.ROTATION_90);
    for (int i = 0; i < mButtonDispatchers.size(); i++) {
        mButtonDispatchers.valueAt(i).setCurrentView(mCurrentView); 
    }
    updateLayoutTransitionsEnabled();
    mCurrentRotation = rot;
}
  1. 加载按键控件。

先从配置文件中获取布局字符串,用于分割, 如 “left,back;home;recent,right ”,再根据字符串来进行按键控件的创建。

protected void inflateLayout(String newLayout) {
    mCurrentLayout = newLayout;
    if (newLayout == null) {
        // left,back;home;recent,right 
        newLayout = getDefaultLayout();
    }
    String[] sets = newLayout.split(GRAVITY_SEPARATOR, 3);
    String[] start = sets[0].split(BUTTON_SEPARATOR);
    String[] center = sets[1].split(BUTTON_SEPARATOR);
    String[] end = sets[2].split(BUTTON_SEPARATOR);
	// 创建按键
    inflateButtons(start, (ViewGroup) mRot0.findViewById(R.id.ends_group), isRot0Landscape);
    inflateButtons(start, (ViewGroup) mRot90.findViewById(R.id.ends_group), !isRot0Landscape);

    inflateButtons(center, (ViewGroup) mRot0.findViewById(R.id.center_group), isRot0Landscape);
    inflateButtons(center, (ViewGroup) mRot90.findViewById(R.id.center_group), !isRot0Landscape);

    inflateButtons(end, (ViewGroup) mRot0.findViewById(R.id.ends_group), isRot0Landscape);
    inflateButtons(end, (ViewGroup) mRot90.findViewById(R.id.ends_group), !isRot0Landscape);
}
private void inflateButtons(String[] buttons, ViewGroup parent, boolean landscape) {
    for (int i = 0; i < buttons.length; i++) {
        inflateButton(buttons[i], parent, landscape);
    }
}

protected View inflateButton(String buttonSpec, ViewGroup parent, boolean landscape) {
    LayoutInflater inflater = landscape ? mLandscapeInflater : mLayoutInflater;
    float size = extractSize(buttonSpec);
    View v = createView(buttonSpec, parent, inflater, landscape); // 创建按键 View
    if (v == null) return null;

    if (size != 0) {
        ViewGroup.LayoutParams params = v.getLayoutParams();
        params.width = (int) (params.width * size);
    }
    parent.addView(v); // 添加按键 view
    ...
    return v;
}

createView() 根据分割出的字符串(left,back;home;recent,right)创建对应的 View

private View createView(String buttonSpec, ViewGroup parent, LayoutInflater inflater,
        boolean landscape) {
    View v = null;
    String button = extractButton(buttonSpec);
    ...
    if (HOME.equals(button)) {
        v = inflater.inflate(R.layout.home, parent, false); // 主页键
    } else if (BACK.equals(button)) {
        v = inflater.inflate(R.layout.back, parent, false); // 返回键
    } else if (RECENT.equals(button)) {
        v = inflater.inflate(R.layout.recent_apps, parent, false); // 最近任务键
    } else if (MENU_IME.equals(button)) {
        ...
    } 
    return v;
}

2.3 导航栏按键监听

导航栏的返回、主页、最近任务键这三个按键分别对应 back.xml、home.xml 和 recent_apps.xml 这三个布局,它们使用的是自定义控件 KeyButtonView。

// frameworks\base\packages\SystemUI\res\layout\back.xml
<com.android.systemui.statusbar.policy.KeyButtonView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:systemui="http://schemas.android.com/apk/res-auto"
    android:id="@+id/back"
    android:layout_width="@dimen/navigation_key_width"
    android:layout_height="match_parent"
    android:layout_weight="0"
    systemui:keyCode="4"  // 映射KeyCode为4的KeyEvent
    android:scaleType="center"
    android:contentDescription="@string/accessibility_back"
    android:paddingStart="@dimen/navigation_key_padding"
    android:paddingEnd="@dimen/navigation_key_padding"/>
// frameworks\base\packages\SystemUI\res\layout\home.xml
<com.android.systemui.statusbar.policy.KeyButtonView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:systemui="http://schemas.android.com/apk/res-auto"
    android:id="@+id/home"
    android:layout_width="@dimen/navigation_key_width"
    android:layout_height="match_parent"
    android:layout_weight="0"
    systemui:keyCode="3" 	// 映射KeyCode为3的KeyEvent
    android:scaleType="center"
    android:contentDescription="@string/accessibility_home"
    android:paddingStart="@dimen/navigation_key_padding"
    android:paddingEnd="@dimen/navigation_key_padding"/>
// frameworks\base\packages\SystemUI\res\layout\recent_apps.xml
<com.android.systemui.statusbar.policy.KeyButtonView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:systemui="http://schemas.android.com/apk/res-auto"
    android:id="@+id/recent_apps"
    android:layout_width="@dimen/navigation_key_width"
    android:layout_height="match_parent"
    android:layout_weight="0"	// recent_apps 没映射对应的 KeyEvent, 另外设置了监听器
    android:scaleType="center"
    android:contentDescription="@string/accessibility_recent"
    android:paddingStart="@dimen/navigation_key_padding"
    android:paddingEnd="@dimen/navigation_key_padding"/>

以 Home 键为例,在 KeyButtonView 类的 onTouchEvent() 中捕获触摸事件。

// frameworks\base\packages\SystemUI\src\com\android\systemui\statusbar\policy\KeyButtonView.java
public boolean onTouchEvent(MotionEvent ev) {
    final int action = ev.getAction();
    ...
    switch (action) {
        case MotionEvent.ACTION_DOWN:
            mDownTime = SystemClock.uptimeMillis();
            mLongClicked = false;
            setPressed(true);
            if (mCode != 0) { // 布局文件中的 systemui:keyCode
                sendEvent(KeyEvent.ACTION_DOWN, 0, mDownTime);
            } else {
                performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
            }
            ...
            break;
    }
    return true;
}

sendEvent() 负责调用 InputManager 模拟按键分发。

void sendEvent(int action, int flags, long when) {
    final int repeatCount = (flags & KeyEvent.FLAG_LONG_PRESS) != 0 ? 1 : 0;
    // public static final int KEYCODE_HOME = 3;
    final KeyEvent ev = new KeyEvent(mDownTime, when, action, mCode, repeatCount,
            0, KeyCharacterMap.VIRTUAL_KEYBOARD, 0,
            flags | KeyEvent.FLAG_FROM_SYSTEM | KeyEvent.FLAG_VIRTUAL_HARD_KEY,
            InputDevice.SOURCE_KEYBOARD);
    // 模拟按键分发,如 KEYCODE_HOME,会在 PhoneWindowManager 中处理
    InputManager.getInstance().injectInputEvent(ev,
            InputManager.INJECT_INPUT_EVENT_MODE_ASYNC);
}

3 客制化

3.1 导航栏的创建/隐藏

// frameworks\base\packages\SystemUI\src\com\android\systemui\statusbar\phone\StatusBar.java
protected void makeStatusBarView() {
    try {
        // showNav 表示是否创建导航栏
        boolean showNav = mWindowManagerService.hasNavigationBar(); 
        if (showNav) {
            createNavigationBar();
        }
    } catch (RemoteException ex) {
    }
    ...
}
// frameworks\base\core\res\res\values\config.xml
public void setInitialDisplaySize(Display display, int width, int height, int density) {
    // 修改这个配置文件中的config_showNavigationBar配置  frameworks\base\core\res\res\values\config.xml
    mHasNavigationBar = res.getBoolean(com.android.internal.R.bool.config_showNavigationBar);
	// 也可以在mk文件中添加新的属性来控制
    String navBarOverride = SystemProperties.get("xxxx"); // qemu.hw.mainkeys
    if ("1".equals(navBarOverride)) {
        mHasNavigationBar = false;
    } else if ("0".equals(navBarOverride)) {
        mHasNavigationBar = true;
    }
}

3.2 导航栏按键的增加与删除

以添加一个截图按键为例:

  1. NavigationBarView 类修改
// frameworks\base\packages\SystemUI\src\com\android\systemui\statusbar\phone\NavigationBarView.java
public NavigationBarView(Context context, AttributeSet attrs) {
    super(context, attrs);
    updateIcons(context, Configuration.EMPTY, mConfiguration); // 截图图标也需要添加
    // 添加 R.id.screenshot +
    mButtonDispatchers.put(R.id.screenshot, new ButtonDispatcher(R.id.screenshot));
    mButtonDispatchers.put(R.id.back, new ButtonDispatcher(R.id.back));
    mButtonDispatchers.put(R.id.home, new ButtonDispatcher(R.id.home));
    ...
}
// +
public ButtonDispatcher getScreenshotButton() {
    return mButtonDispatchers.get(R.id.screenshot);
}

private void prepareNavigationBarView() {
    mNavigationBarView.reorient();
    // 添加截图按键事件监听 +
    ButtonDispatcher screenshotButton = mNavigationBarView.getScreenshotButton();
    screenshotButton.setOnClickListener(this::onScreenshotClick);
    screenshotButton.setLongClickable(true);
    screenshotButton.setOnLongClickListener(this::onLongPressScreenshot);
    ...
}

private void onScreenshotClick(View v) { // 单击监听器
    Log.e(TAG , "onScreenshotClick()");
}

private boolean onLongPressScreenshot(View v) { // 长按监听器
    Log.e(TAG , "onLongPressScreenshot()");
    return true;
}

private KeyButtonDrawable mScreenshotIcon; // 添加截图图标变量
private void updateIcons(Context ctx, Configuration oldConfig, Configuration newConfig) {
    ...
        if (oldConfig.densityDpi != newConfig.densityDpi) {
            // 添加图标 +
            mScreenshotIcon = getDrawable(ctx, 
                                          R.drawable.ic_sysbar_screenshot,R.drawable.ic_sysbar_screenshot_dark);
            mBackIcon = getDrawable(ctx, R.drawable.ic_sysbar_back, R.drawable.ic_sysbar_back_dark);
            ...
        }
}

public void setNavigationIconHints(int hints, boolean force) {

    // 设置截图按键图标
    getScreenshotButton().setImageDrawable(mScreenshotIcon);
    getBackButton().setImageDrawable(backIcon);
    ...
}

public void setDisabledFlags(int disabledFlags, boolean force) {
    if (!force && mDisabledFlags == disabledFlags) return;
    mDisabledFlags = disabledFlags;
    final boolean disableHome = ((disabledFlags & View.STATUS_BAR_DISABLE_HOME) != 0);
    boolean disableRecent = mUseCarModeUi
        || ((disabledFlags & View.STATUS_BAR_DISABLE_RECENT) != 0);
    final boolean disableBack = ((disabledFlags & View.STATUS_BAR_DISABLE_BACK) != 0)
        && ((mNavigationIconHints & StatusBarManager.NAVIGATION_HINT_BACK_ALT) == 0);

    ...

    getBackButton().setVisibility(disableBack      ? View.INVISIBLE : View.VISIBLE);
    getHomeButton().setVisibility(disableHome      ? View.INVISIBLE : View.VISIBLE);
    // 隐藏/显示
    getScreenshotButton().setVisibility(disableHome     ? View.INVISIBLE : View.VISIBLE);
    ...
}
  1. config.xml 文件修改
// frameworks\base\packages\SystemUI\res\values\config.xml
<!-- 增加 screenshot 字符串配置 -->
<string name="config_navBarLayout" translatable="false">left,back;home;recent,screenshot,right</string>
  1. NavigationBarInflaterView 类修改
// frameworks\base\packages\SystemUI\src\com\android\systemui\statusbar\phone\NavigationBarInflaterView.java
public static final String SCREENSHOT = "screenshot";
private View createView(String buttonSpec, ViewGroup parent, LayoutInflater inflater,
                        boolean landscape) {
    if (HOME.equals(button)) {
        v = inflater.inflate(R.layout.home, parent, false);
    } else if (SCREENSHOT.equals(button)) { // 添加截图按键的判断 +
        v = inflater.inflate(R.layout.screenshot, parent, false);
    } else if (BACK.equals(button)) {
        v = inflater.inflate(R.layout.back, parent, false);
    } ...
}
  1. 添加 screenshot.xml 布局文件
// frameworks\base\packages\SystemUI\res\layout\screenshot.xml
<com.android.systemui.statusbar.policy.KeyButtonView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:systemui="http://schemas.android.com/apk/res-auto"
    android:id="@+id/screenshot"
    android:layout_width="@dimen/navigation_key_width"
    android:layout_height="match_parent"
    android:layout_weight="0"
    // 设置映射截图按键KeyCode ,这里和 PhoneWindowManager 保持一致,最终会模拟这个按键的分发
    // 这里也可以设置为 0,如果要实现单击和长按的话
    systemui:keyCode="128"  
    android:scaleType="center"
    android:contentDescription="@string/accessibility_SCREENSHOT"
    android:paddingStart="@dimen/navigation_key_padding"
    android:paddingEnd="@dimen/navigation_key_padding"
    />
Logo

加入社区!打开量化的大门,首批课程上线啦!

更多推荐