Android SystemUI 导航栏

2017/08/29 SystemUI

导航栏和状态栏都是通过WindowManager直接添加的View,他们一般称为装饰窗口。下面来看导航栏的创建过程。 回到之前的PhoneStatusBar中的makeStatusBarView函数中:

        try {
            boolean showNav = mWindowManagerService.hasNavigationBar();
            if (DEBUG) Log.v(TAG, "hasNavigationBar=" + showNav);
            if (showNav) {
                createNavigationBarView(context);
            }
        } catch (RemoteException ex) {
            // no window manager? good luck with that
        }

首先判断是否支持虚拟按键,判断条件逻辑在PhoneWindowManager中:

        mHasNavigationBar = res.getBoolean(com.android.internal.R.bool.config_showNavigationBar);

        // Allow a system property to override this. Used by the emulator.
        // See also hasNavigationBar().
        String navBarOverride = SystemProperties.get("qemu.hw.mainkeys");
        if ("1".equals(navBarOverride)) {
            mHasNavigationBar = false;
        } else if ("0".equals(navBarOverride)) {
            mHasNavigationBar = true;
        }

决定是否显示为qemu.hw.mainkeys实体按键属性。如果支持的话,则调用createNavigationBarView创建导航栏控件树:

    protected void createNavigationBarView(Context context) {
        inflateNavigationBarView(context);
        mNavigationBarView.setDisabledFlags(mDisabled1);
        ......
    }

mNavigationBarView.setDisabledFlags(mDisabled1)是设置导航栏中各按键是否可见flag。 重点来看inflateNavigationBarView:

    protected void inflateNavigationBarView(Context context) {
        mNavigationBarView = (NavigationBarView) View.inflate(
                context, R.layout.navigation_bar, null);
    }

该函数加载创建了一个NavigationBarView类型的布局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>

该布局中包含一个定义布局NavigationBarInflaterView。下面重点介绍一下这两个自定义布局NavigationBarView和NavigationBarInflaterView。 首先来看NavigationBarView的创建函数:

    public NavigationBarView(Context context, AttributeSet attrs) {
        .......
        updateIcons(context, Confi  guration.EMPTY, mConfiguration);

        mBarTransitions = new NavigationBarTransitions(this);

        mButtonDisatchers.put(R.id.back, new ButtonDispatcher(R.id.back));
        mButtonDisatchers.put(R.id.home, new ButtonDispatcher(R.id.home));
        mButtonDisatchers.put(R.id.recent_apps, new ButtonDispatcher(R.id.recent_apps));
        mButtonDisatchers.put(R.id.menu, new ButtonDispatcher(R.id.menu));
        mButtonDisatchers.put(R.id.ime_switcher, new ButtonDispatcher(R.id.ime_switcher));
    }

updateIcons主要是初识化导航栏中各个按键的图标。 这里的初始化涉及到了ButtonDisatchers类,这个类是用于处理多个具有相同操作的虚拟按键设置。如设置图片、设置click和touch事件等。

再来看NavigationBarView的onFinishInflate函数:

    public void onFinishInflate() {
        mNavigationInflaterView = (NavigationBarInflaterView) findViewById(
                R.id.navigation_inflater);
        updateRotatedViews();
        mNavigationInflaterView.setButtonDispatchers(mButtonDisatchers);
        ......
    }

其主要创建加载了NavigationBarInflaterView布局,先来看其创建和加载函数:

    public NavigationBarInflaterView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mDensity = context.getResources().getConfiguration().densityDpi;
        createInflaters();
    }

创建函数中主要获取了屏幕密度和初始化布局加载器。 再看加载函数:

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        inflateChildren();
        clearViews();
        inflateLayout(getDefaultLayout());
    }

首先看inflateChildren的实现:

    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);
        updateAlternativeOrder();
        if (getParent() instanceof NavigationBarView) {
            ((NavigationBarView) getParent()).updateRotatedViews();
        }
    }

可以看到添加了两个布局navigation_layout和navigation_layout_rot90,这两个布局分别代表水平方式和垂直方式的导航栏布局,以navigation_layout为例:

<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:systemui="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <FrameLayout
        android:id="@+id/nav_buttons"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <LinearLayout
            android:id="@+id/ends_group"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="horizontal"
            android:clipChildren="false" />

        <LinearLayout
            android:id="@+id/center_group"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:gravity="center"
            android:orientation="horizontal"
            android:clipChildren="false" />

    </FrameLayout>

    <com.android.systemui.statusbar.policy.DeadZone
        android:id="@+id/deadzone"
        android:layout_height="match_parent"
        android:layout_width="match_parent"
        android:layout_gravity="top"
        systemui:minSize="@dimen/navigation_bar_deadzone_size"
        systemui:maxSize="@dimen/navigation_bar_deadzone_size_max"
        systemui:holdTime="@integer/navigation_bar_deadzone_hold"
        systemui:decayTime="@integer/navigation_bar_deadzone_decay"
        systemui:orientation="horizontal"
        />

</FrameLayout>

主要包括了nav_buttons和deadzone,nav_buttons即包含了虚拟按键子控件,子控件以ends_group和center_group布局加载,后面会详细介绍具体的子控件加载。navigation_layout_rot90和其唯一的区别在在于ends_group和center_group使用的是ReverseLinearLayout,这个布局可以自动反转子view添加的顺序,还可以反转布局参数的宽度和高度值。 deadzone是一个DeadZone类型的控件,覆盖在其他所有控件之上。这个类主要是为了避免用户误操作,其会将导航栏边缘的触摸事件当做用户误操作消费掉。

再来看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);

        updateCurrentView();
    }

可以看到水平方向使用的是rot0,垂直方向使用的rot90的布局,再看updateCurrentView的实现:

    private void updateCurrentView() {
        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 < mButtonDisatchers.size(); i++) {
            mButtonDisatchers.valueAt(i).setCurrentView(mCurrentView);
        }
        updateLayoutTransitionsEnabled();
        mCurrentRotation = rot;
    }

可以看到该函数获取了当前的旋转方向,然后选择相应的布局显示。

然后回到onFinishInflate,初始化好相应布局后,调用clearViews清除布局的相应子view,然后调用inflateLayout设置布局的相关控件,来看其实现:

    protected void inflateLayout(String newLayout) {
        mCurrentLayout = newLayout;
        if (newLayout == null) {
            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);
        // Inflate these in start to end order or accessibility traversal will be messed up.
        inflateButtons(start, (ViewGroup) mRot0.findViewById(R.id.ends_group), false);
        inflateButtons(start, (ViewGroup) mRot90.findViewById(R.id.ends_group), true);

        inflateButtons(center, (ViewGroup) mRot0.findViewById(R.id.center_group), false);
        inflateButtons(center, (ViewGroup) mRot90.findViewById(R.id.center_group), true);

        addGravitySpacer((LinearLayout) mRot0.findViewById(R.id.ends_group));
        addGravitySpacer((LinearLayout) mRot90.findViewById(R.id.ends_group));

        inflateButtons(end, (ViewGroup) mRot0.findViewById(R.id.ends_group), false);
        inflateButtons(end, (ViewGroup) mRot90.findViewById(R.id.ends_group), true);
    }

getDefaultLayout是获取一串定义的字符串:

<string name="config_navBarLayout" translatable="false">space,back;home;recent,menu_ime</string>

然后解析这一个字串并保存,这个字串其实就是设置三个虚拟按键可以显示的内容。其是通过inflateButton函数来实现的。来看实现:

    protected View inflateButton(String buttonSpec, ViewGroup parent, boolean landscape,
            int indexInParent) {
        LayoutInflater inflater = landscape ? mLandscapeInflater : mLayoutInflater;
        float size = extractSize(buttonSpec);
        String button = extractButton(buttonSpec);
        View v = null;
        if (HOME.equals(button)) {
            v = inflater.inflate(R.layout.home, parent, false);
            if (landscape && isSw600Dp()) {
                setupLandButton(v);
            }
        } else if (BACK.equals(button)) {
            v = inflater.inflate(R.layout.back, parent, false);
            if (landscape && isSw600Dp()) {
                setupLandButton(v);
            }
        } else if (RECENT.equals(button)) {
            v = inflater.inflate(R.layout.recent_apps, parent, false);
            if (landscape && isSw600Dp()) {
                setupLandButton(v);
            }
        } else if (MENU_IME.equals(button)) {
            v = inflater.inflate(R.layout.menu_ime, parent, false);
        } else if (NAVSPACE.equals(button)) {
            v = inflater.inflate(R.layout.nav_key_space, parent, false);
        } else if (CLIPBOARD.equals(button)) {
            v = inflater.inflate(R.layout.clipboard, parent, false);
        } else if (button.startsWith(KEY)) {
            String uri = extractImage(button);
            int code = extractKeycode(button);
            v = inflater.inflate(R.layout.custom_key, parent, false);
            ((KeyButtonView) v).setCode(code);
            if (uri != null) {
                ((KeyButtonView) v).loadAsync(uri);
            }
        } else {
            return null;
        }

        if (size != 0) {
            ViewGroup.LayoutParams params = v.getLayoutParams();
            params.width = (int) (params.width * size);
        }
        parent.addView(v);
        addToDispatchers(v, landscape);
        View lastView = landscape ? mLastRot90 : mLastRot0;
        if (lastView != null) {
            v.setAccessibilityTraversalAfter(lastView.getId());
        }
        if (landscape) {
            mLastRot90 = v;
        } else {
            mLastRot0 = v;
        }
        return v;
    }

可以看到根据相应的String设置不同的布局文件,以home为例,其加载的为home layout:

<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"
    android:src="@drawable/ic_sysbar_home"
    systemui:keyCode="3"
    android:scaleType="center"
    android:contentDescription="@string/accessibility_home"
    android:paddingStart="@dimen/navigation_key_padding"
    android:paddingEnd="@dimen/navigation_key_padding"
    />

其是一个KeyButtonView类型的子控件,KeyButtonView继承自ImageView,主要扩展的功能是根据派发给它的触摸事件转化为按键事件,并通过InputManager.injectInputEvent方法将按键事件注入到InputDispatcher的派发队列中,模拟按键输入。 在KeyButtonView中比较重要的字段为mCode和mSupportsLongpress,表示按键事件的键值和是否产生按键的长按事件,这两个值的定义是通过自定义控件的属性keyCode和keyRepeat设置的,但是长按事件在这里都没有设置,长按事件的处理都是通过设置相关监听事件,后面会介绍。如下为代码分析:

    <declare-styleable name="KeyButtonView">
        <!-- key code to send when pressed; if absent or 0, no key is sent -->
        <attr name="keyCode" format="integer" />
        <!-- does this button generate longpress / repeat events? -->
        <attr name="keyRepeat" format="boolean" />
        <attr name="android:contentDescription" />
    </declare-styleable>

这是属性的定义,在上面的 systemui:keyCode=”3”即为设置Home的键值。下面来看KeyButtonView中的处理,首先是初始化:

    public KeyButtonView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs);

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.KeyButtonView,
                defStyle, 0);

        mCode = a.getInteger(R.styleable.KeyButtonView_keyCode, 0);

        mSupportsLongpress = a.getBoolean(R.styleable.KeyButtonView_keyRepeat, true);
        ......
        setClickable(true);
        ......
        setBackground(new KeyButtonRipple(context, this));
    }

可以看到,首先获取定义的键值和是否支持长按,并设置为可点击和背景。下面看OnTouch事件处理:

    public boolean onTouchEvent(MotionEvent ev) {
        ......
        switch (action) {  
            case MotionEvent.ACTION_DOWN:
                setPressed(true);
                ......
                if (mCode != 0) {
                    sendEvent(KeyEvent.ACTION_DOWN, 0, mDownTime);
                }
                ......
                postDelayed(mCheckLongPress, ViewConfiguration.getLongPressTimeout());
            ......
            case MotionEvent.ACTION_CANCEL:
                setPressed(false);
                if (mCode != 0) {
                    sendEvent(KeyEvent.ACTION_UP, KeyEvent.FLAG_CANCELED);
                }
                removeCallbacks(mCheckLongPress);
            case MotionEvent.ACTION_UP:
                final boolean doIt = isPressed() && !mLongClicked;
                setPressed(false);
                if (mCode != 0) {
                    if (doIt) {
                        sendEvent(KeyEvent.ACTION_UP, 0);
                        sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
                    } else {
                        sendEvent(KeyEvent.ACTION_UP, KeyEvent.FLAG_CANCELED);
                    }
                } else {
                    // no key code, just a regular ImageView
                    if (doIt && mOnClickListener != null) {
                        mOnClickListener.onClick(this);
                        sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_CLICKED);
                    }
                }
                removeCallbacks(mCheckLongPress);
                break;
        }

        return true;
    }

    private final Runnable mCheckLongPress = new Runnable() {
        public void run() {
            if (isPressed()) {
                // Log.d("KeyButtonView", "longpressed: " + this);
                if (isLongClickable()) {
                    // Just an old-fashioned ImageView
                    performLongClick();
                    mLongClicked = true;
                } else if (mSupportsLongpress) {
                    sendEvent(KeyEvent.ACTION_DOWN, KeyEvent.FLAG_LONG_PRESS);
                    sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_LONG_CLICKED);
                    mLongClicked = true;
                }
            }
        }
    };

可以看到,不管是按键还是长按事件,最终都会调用sendEvent发送相应的事件,下面来看其实现:

    void sendEvent(int action, int flags, long when) {
        final int repeatCount = (flags & KeyEvent.FLAG_LONG_PRESS) != 0 ? 1 : 0;
        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);
        InputManager.getInstance().injectInputEvent(ev,
                InputManager.INJECT_INPUT_EVENT_MODE_ASYNC);
    }

可以看到最后是通过InputManager.injectInputEvent将keyEvent加入到InputDispatcher的派发队列,模式按键的输入事件。 总结一下: 如设置了keyCode,则通过OnTouchEvent完成从触摸事件到按键事件的映射,在通过SendEvent完成按键事件的创建与发送。 如没有设置KeyCode,则触发监听器OnClickListener,执行对应的处理函数。

到这里,布局的创建基本已经完成,但是具体的按键图标还未设置,前面的NavigationBarView的初始化中,updateIcons只是获取了相关的图标资源,并未设置到具体的控件中,下面就接着来看。 回到PhoneStatusBar的Start函数中,在执行完super.start()后,紧着着执行的是addNavigationBar()函数,我们来看其实现:

    protected void addNavigationBar() {
        if (DEBUG) Log.v(TAG, "addNavigationBar: about to add " + mNavigationBarView);
        if (mNavigationBarView == null) return;

        try {
            WindowManagerGlobal.getWindowManagerService()
                    .watchRotation(new IRotationWatcher.Stub() {
                @Override
                public void onRotationChanged(final int rotation) throws RemoteException {
                    // We need this to be scheduled as early as possible to beat the redrawing of
                    // window in response to the orientation change.
                    Message msg = Message.obtain(mHandler, new Runnable() {
                        @Override
                        public void run() {
                            if (mNavigationBarView != null
                                    && mNavigationBarView.needsReorient(rotation)) {
                                repositionNavigationBar();
                            }
                        }
                    });
                    msg.setAsynchronous(true);
                    mHandler.sendMessageAtFrontOfQueue(msg);
                }
            });
        } catch (RemoteException e) {
            throw e.rethrowFromSystemServer();
        }

        prepareNavigationBarView();

        mWindowManager.addView(mNavigationBarView, getNavigationBarLayoutParams());

首先注册了一个方向变化的监听,用于在变化时及时更新。 下面的prepareNavigationBarView和mWindowManager.addView是初始化的重点函数。 先来看prepareNavigationBarView:

    private void prepareNavigationBarView() {
        mNavigationBarView.reorient();

        ButtonDispatcher recentsButton = mNavigationBarView.getRecentsButton();
        recentsButton.setOnClickListener(mRecentsClickListener);
        recentsButton.setOnTouchListener(mRecentsPreloadOnTouchListener);
        recentsButton.setLongClickable(true);
        recentsButton.setOnLongClickListener(mRecentsLongClickListener);

        ButtonDispatcher backButton = mNavigationBarView.getBackButton();
        backButton.setLongClickable(true);
        backButton.setOnLongClickListener(mLongPressBackListener);

        ButtonDispatcher homeButton = mNavigationBarView.getHomeButton();
        homeButton.setOnTouchListener(mHomeActionListener);
        homeButton.setOnLongClickListener(mLongPressHomeListener);

        mAssistManager.onConfigurationChanged();
    }

看NavigationBarView中reorient的实现:

    public void reorient() {
        updateCurrentView();

        getImeSwitchButton().setOnClickListener(mImeSwitcherClickListener);

        mDeadZone = (DeadZone) mCurrentView.findViewById(R.id.deadzone);

        // force the low profile & disabled states into compliance
        mBarTransitions.init();
        setDisabledFlags(mDisabledFlags, true /* force */);
        setMenuVisibility(mShowMenu, true /* force */);

        if (DEBUG) {
            Log.d(TAG, "reorient(): rot=" + mCurrentRotation);
        }

        updateTaskSwitchHelper();
        setNavigationIconHints(mNavigationIconHints, true);
    }

主要是更新水平or垂直布局,并设置是否弹出输入法的相关显示即监听事件,最后是setNavigationIconHints函数,我们来看这里面的实现:

    public void setNavigationIconHints(int hints, boolean force) {
        if (!force && hints == mNavigationIconHints) return;
        final boolean backAlt = (hints & StatusBarManager.NAVIGATION_HINT_BACK_ALT) != 0;
        if ((mNavigationIconHints & StatusBarManager.NAVIGATION_HINT_BACK_ALT) != 0 && !backAlt) {
            mTransitionListener.onBackAltCleared();
        }
        if (DEBUG) {
            android.widget.Toast.makeText(getContext(),
                "Navigation icon hints = " + hints,
                500).show();
        }

        mNavigationIconHints = hints;

        // We have to replace or restore the back and home button icons when exiting or entering
        // carmode, respectively. Recents are not available in CarMode in nav bar so change
        // to recent icon is not required.
        Drawable backIcon = (backAlt)
                ? getBackIconWithAlt(mUseCarModeUi, mVertical)
                : getBackIcon(mUseCarModeUi, mVertical);

        getBackButton().setImageDrawable(backIcon);

        updateRecentsIcon();

        if (mUseCarModeUi) {
            getHomeButton().setImageDrawable(mHomeCarModeIcon);
        } else {
            getHomeButton().setImageDrawable(mHomeDefaultIcon);
        }

        final boolean showImeButton = ((hints & StatusBarManager.NAVIGATION_HINT_IME_SHOWN) != 0);
        getImeSwitchButton().setVisibility(showImeButton ? View.VISIBLE : View.INVISIBLE);
        getImeSwitchButton().setImageDrawable(mImeIcon);

        // Update menu button in case the IME state has changed.
        setMenuVisibility(mShowMenu, true);
        getMenuButton().setImageDrawable(mMenuIcon);

        setDisabledFlags(mDisabledFlags, true);
    

可以看到这个函数就是将具体的图标资源设置到相关的控件中。

回到前面prepareNavigationBarView中,执行完reorient后,获取了相关的button并设置了相关按键事件的监听,包括点击和长按。前面提到,对于设置了KeyCode的keyButtonView(home、back),则在KeyButtonView中会解析并发送相应的按键事件,而对于未设置的(recent),则是在这里设置相应的click事件。对于长按事件,三个按键都是在这里设置。

在回到addNavigationBar中,执行完prepareNavigationBarView后,则控件树和资源都基本已加载完,就剩最后一步,将控件树加载到Window中,即mWindowManager.addView,其中相关的添加参数在getNavigationBarLayoutParams中:

    private WindowManager.LayoutParams getNavigationBarLayoutParams() {
        WindowManager.LayoutParams lp = new WindowManager.LayoutParams(
                LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT,
                WindowManager.LayoutParams.TYPE_NAVIGATION_BAR,
                    0
                    | WindowManager.LayoutParams.FLAG_TOUCHABLE_WHEN_WAKING
                    | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
                    | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
                    | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
                    | WindowManager.LayoutParams.FLAG_SPLIT_TOUCH
                    | WindowManager.LayoutParams.FLAG_SLIPPERY,
                PixelFormat.TRANSLUCENT);
        // this will allow the navbar to run in an overlay on devices that support this
        if (ActivityManager.isHighEndGfx()) {
            lp.flags |= WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED;
        }

        lp.setTitle("NavigationBar");
        lp.windowAnimations = 0;
        return lp;
    }

至此,导航栏就已经添加创建完毕。

下面总结一下导航栏的控件树及其加载过程。 整体控件树如下:

加载过程如下:

Search

    Table of Contents