Android 导航栏虚拟按键

Android 虚拟按键

Android手机可分为有导航栏以及没导航栏两种,一般有物理按键的机器不会带有导航栏,而没有物理按键的机器则基本会带,比如华为的手机基本都是带导航栏的。当然现在全面屏手机大多都有全面屏手势,但这并不影响我们对导航栏的分析。

导航栏是如何加载到桌面上?是如何实现与物理按键相同的功能的呢?带着种种疑问,我们来read the fucking source code。

导航栏是属于系统界面的一部分,也就是SystemUI的一部分。在SystemUI中导航栏实质上是一个继承LinearLayout的ViewGroup:NavigationBarView,在系统界面初始化的时候在phone/StatusBar.java的makeStatusBarView方法中进行的

从状态栏入口函数makeStatusBarView开始看


    protected void makeStatusBarView(@Nullable RegisterStatusBarResult result) {
        final Context context = mContext;
        updateDisplaySize(); // populates mDisplayMetrics
        updateResources();
        updateTheme();

        inflateStatusBarWindow();
        //...
        createNavigationBar(result);
    }

进入 createNavigationBar 方法,发现主要是用NavigationBarController来管理

    protected void createNavigationBar(@Nullable RegisterStatusBarResult result) {
        mNavigationBarController.createNavigationBars(true /* includeDefaultDisplay */, result);
    }

看NavigationBarController,调用的是NavigationBarFragment.create静态方法


    public void createNavigationBars() {
        for (Display display : displays) {
            if (includeDefaultDisplay || display.getDisplayId() != DEFAULT_DISPLAY) {
                createNavigationBar(display, result);
            }
        }
    }


    void createNavigationBar(Display display, RegisterStatusBarResult result) {
       
        NavigationBarFragment.create(context, (tag, fragment) -> {
            NavigationBarFragment navBar = (NavigationBarFragment) fragment;
        });
    }

看 NavigationBarFragment 的create方法,终于知道,先载入navigation_bar_window.xml创建一个navigationBarView对象,内部就是一个NavigationBarFrame extend FrameLayout,在这个View的onViewAttachedToWindow时用来加载NavigationBarFragment对象到其中,然后是WindowManager去addView了这个导航栏的navigationBarView,触发内部的NavigationBarFragment的onCreateView来加载实际的布局。(其实SystemUI所有的模块都是WindowManager来加载View)

    public static View create(Context context, FragmentListener listener) {
        
        View navigationBarView = LayoutInflater.from(context).inflate(
                R.layout.navigation_bar_window, null);
                
        final NavigationBarFragment fragment = FragmentHostManager.get(navigationBarView)
                .create(NavigationBarFragment.class);
        navigationBarView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
            @Override
            public void onViewAttachedToWindow(View v) {
                final FragmentHostManager fragmentHost = FragmentHostManager.get(v);
                fragmentHost.getFragmentManager().beginTransaction()
                        .replace(R.id.navigation_bar_frame, fragment, TAG)
                        .commit();
                fragmentHost.addTagListener(TAG, listener);
            }

            @Override
            public void onViewDetachedFromWindow(View v) {
                FragmentHostManager.removeAndDestroy(v);
                navigationBarView.removeOnAttachStateChangeListener(this);
            }
        });
        context.getSystemService(WindowManager.class).addView(navigationBarView, lp);
        return navigationBarView;
    }
navigation_bar_window.xml
<com.android.systemui.statusbar.phone.NavigationBarFrame
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:systemui="http://schemas.android.com/apk/res-auto"
    android:id="@+id/navigation_bar_frame"
    android:theme="@style/Theme.SystemUI"
    android:layout_height="match_parent"
    android:layout_width="match_parent">

</com.android.systemui.statusbar.phone.NavigationBarFrame>
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container,
            Bundle savedInstanceState) {
        return inflater.inflate(R.layout.navigation_bar, container, false);
    }

navigation_bar_window.xml只是多了一层嵌套,进入导航栏的真正根布局:navigation_bar.xml,好吧又是自定义view,又是嵌套结构,我们来看NavigationBarView和NavigationBarInflaterView

<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>

来看NavigationBarView的构造方法,就是初始化了一堆ButtonDispatcher对象,这个是用来处理虚拟按键图标显示的,之后再说

    public NavigationBarView(Context context, AttributeSet attrs) {
        super(context, attrs);

        mButtonDispatchers.put(R.id.back, new ButtonDispatcher(R.id.back));
        mButtonDispatchers.put(R.id.home, new ButtonDispatcher(R.id.home));
        mButtonDispatchers.put(R.id.home_handle, new ButtonDispatcher(R.id.home_handle));
        mButtonDispatchers.put(R.id.recent_apps, new ButtonDispatcher(R.id.recent_apps));
        //...
    }

而它实际生成NavigationBarInflaterView对象是在它的onFinishInflate方法中,NavigationBarView和NavigationBarInflaterView都是继承自View,
而onFinishInflate()方法,这是view的生命周期,每个view被inflate之后都会回调。两个类都Override了onFinishInflate方法,这个在inflate完会触发,其中NavigationBarInflaterView就在这个方法里调用inflateLayout用来创建虚拟按键布局

//NavigationBarView
    @Override
    public void onFinishInflate() {
        super.onFinishInflate();
        mNavigationInflaterView = findViewById(R.id.navigation_inflater);
        mNavigationInflaterView.setButtonDispatchers(mButtonDispatchers);
    }
    
//NavigationBarInflaterView
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        inflateChildren();
        clearViews();
        inflateLayout(getDefaultLayout());
    }

我们大体看一下这个inflateLayout方法,通过getDefaultLayout获取导航栏的布局配置,然后进行解析

//NavigationInflaterView
protected void inflateLayout(String newLayout) {
        mCurrentLayout = newLayout;
        if (newLayout == null) {
            newLayout = getDefaultLayout();
        }
        //解析left[.5W],back[1WC];home;recent[1WC],right[.5W]
        String[] sets = newLayout.split(GRAVITY_SEPARATOR, 3);
        if (sets.length != 3) {
            Log.d(TAG, "Invalid layout.");
            newLayout = getDefaultLayout();
            sets = newLayout.split(GRAVITY_SEPARATOR, 3);
        }
        
        //GRAVITY_SEPARATOR=";"
        //BUTTON_SEPARATOR=","
        //split字符串
        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, mHorizontal.findViewById(R.id.ends_group),
                false /* landscape */, true /* start */);
        inflateButtons(start, mVertical.findViewById(R.id.ends_group),
                true /* landscape */, true /* start */);

        inflateButtons(center, mHorizontal.findViewById(R.id.center_group),
                false /* landscape */, false /* start */);
        inflateButtons(center, mVertical.findViewById(R.id.center_group),
                true /* landscape */, false /* start */);

        inflateButtons(end, mHorizontal.findViewById(R.id.ends_group),
                false /* landscape */, false /* start */);
        inflateButtons(end, mVertical.findViewById(R.id.ends_group),
                true /* landscape */, false /* start */);

        updateButtonDispatchersCurrentView();
    }
    //获取默认导航栏布局配置
    protected String getDefaultLayout() {
        final int defaultResource = QuickStepContract.isGesturalMode(mNavBarMode)
                ? R.string.config_navBarLayoutHandle
                : mOverviewProxyService.shouldShowSwipeUpUI()
                        ? R.string.config_navBarLayoutQuickstep
                        : R.string.config_navBarLayout;
        return getContext().getString(defaultResource);
    }
    

下图中的三个绿框分别对应start、center、end
在这里插入图片描述

    <!-- Nav bar button default ordering/layout -->
    <string name="config_navBarLayout" translatable="false">left[.5W],back[1WC];home;recent[1WC],right[.5W]</string>
    <string name="config_navBarLayoutQuickstep" translatable="false">back[1.7WC];home;contextual[1.7WC]</string>
    <string name="config_navBarLayoutHandle" translatable="false">back[40AC];home_handle;ime_switcher[40AC]</string>

在看inflateButtons方法,调用createView创建相应View

    
    private void inflateButtons() {
        for (int i = 0; i < buttons.length; i++) {
            inflateButton(buttons[i], parent, landscape, start);
        }
    }
    
    protected View inflateButton() {
        //创建相应布局
        View v = createView(buttonSpec, parent, inflater);
        //调整大小
        v = applySize(v, buttonSpec, landscape, start);
        parent.addView(v);
        //加到mButtonDispatchers中,设置图标
        addToDispatchers(v);
        //...
        return v;
    }
    
    private View createView() {
        View v = null;
        //提炼字符串,去掉[],根据home,back,recent等对应不同的布局
        String button = extractButton(buttonSpec);
        if (LEFT.equals(button)) {
            button = extractButton(NAVSPACE);
        } else if (RIGHT.equals(button)) {
            button = extractButton(MENU_IME_ROTATE);
        }
        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_ROTATE.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);
        //...
        return v;
    }

以HOME键为例, inflater.inflate(R.layout.home)就是解析的home.xml,返回KeyButtonView对象

//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"
    android:scaleType="center"
    android:contentDescription="@string/accessibility_home"
    android:paddingStart="@dimen/navigation_key_padding"
    android:paddingEnd="@dimen/navigation_key_padding"
    />

applySize主要是控制所占布局的大小,[.5W]表示0.5个weight

    private static final String WEIGHT_SUFFIX = "W";
    private static final String WEIGHT_CENTERED_SUFFIX = "WC";
    private static final String ABSOLUTE_SUFFIX = "A";
    private static final String ABSOLUTE_VERTICAL_CENTERED_SUFFIX = "C";

    private View applySize(View v, String buttonSpec, boolean landscape, boolean start) {
        String sizeStr = extractSize(buttonSpec);
        if (sizeStr == null) return v;

        if (sizeStr.contains(WEIGHT_SUFFIX) || sizeStr.contains(ABSOLUTE_SUFFIX)) {
            // To support gravity, wrap in RelativeLayout and apply gravity to it.
            // Children wanting to use gravity must be smaller then the frame.
            ReverseRelativeLayout frame = new ReverseRelativeLayout(mContext);
            LayoutParams childParams = new LayoutParams(v.getLayoutParams());

            // Compute gravity to apply
            int gravity = (landscape) ? (start ? Gravity.TOP : Gravity.BOTTOM)
                    : (start ? Gravity.START : Gravity.END);
            if (sizeStr.endsWith(WEIGHT_CENTERED_SUFFIX)) {
                gravity = Gravity.CENTER;
            } else if (sizeStr.endsWith(ABSOLUTE_VERTICAL_CENTERED_SUFFIX)) {
                gravity = Gravity.CENTER_VERTICAL;
            }

            // Set default gravity, flipped if needed in reversed layouts (270 RTL and 90 LTR)
            frame.setDefaultGravity(gravity);
            frame.setGravity(gravity); // Apply gravity to root

            frame.addView(v, childParams);

            if (sizeStr.contains(WEIGHT_SUFFIX)) {
                // Use weighting to set the width of the frame
                float weight = Float.parseFloat(
                        sizeStr.substring(0, sizeStr.indexOf(WEIGHT_SUFFIX)));
                frame.setLayoutParams(new LinearLayout.LayoutParams(0, MATCH_PARENT, weight));
            } else {
                int width = (int) convertDpToPx(mContext,
                        Float.parseFloat(sizeStr.substring(0, sizeStr.indexOf(ABSOLUTE_SUFFIX))));
                frame.setLayoutParams(new LinearLayout.LayoutParams(width, MATCH_PARENT));
            }

            // Ensure ripples can be drawn outside bounds
            frame.setClipChildren(false);
            frame.setClipToPadding(false);

            return frame;
        }

        float size = Float.parseFloat(sizeStr);
        ViewGroup.LayoutParams params = v.getLayoutParams();
        params.width = (int) (params.width * size);
        return v;
    }

布局和大小都已确定,那相应的图标是怎么设置的呢?
我们来看这个addToDispatchers(v)方法,v是createView生成的KeyButtonView

    private void addToDispatchers(View v) {
        if (mButtonDispatchers != null) {
            final int indexOfKey = mButtonDispatchers.indexOfKey(v.getId());
            if (indexOfKey >= 0) {
                mButtonDispatchers.valueAt(indexOfKey).addView(v);
            }
            if (v instanceof ViewGroup) {
                final ViewGroup viewGroup = (ViewGroup)v;
                final int N = viewGroup.getChildCount();
                for (int i = 0; i < N; i++) {
                    addToDispatchers(viewGroup.getChildAt(i));
                }
            }
        }
    }

首先这个mButtonDispatchers是在NavigationBarView的构造方法中初始化的,然后在onFinishInflate时设置到NavigationBarInflaterView里,上面我们说过,我们以HOME为例,初始化的代码是这样的mButtonDispatchers.put(R.id.home, new ButtonDispatcher(R.id.home));
当我们addToDispatchers一个HOME View的时候,通过v.getId正好匹配到R.id.home,获取这个new ButtonDispatcher(R.id.home)),并调用它的addView方法,在方法中KeyButtonView正好 extends ImageView implements ButtonInterface,setImageDrawable其实就是调用的ImageView的setImageDrawable方法设置图片

    void addView(View view) {
        mViews.add(view);
        
        if (view instanceof ButtonInterface) {
            final ButtonInterface button = (ButtonInterface) view;
            if (mImageDrawable != null) {
                button.setImageDrawable(mImageDrawable);
            }
        }
    }

在看这个mImageDrawable是哪来的,发现是ButtonDispatcher的setImageDrawable方法设置的,这个时候我们再回到NavigationBarView中搜索setImageDrawable方法,还是以HOME为例


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

    public void updateNavButtonIcons() {
        KeyButtonDrawable homeIcon = mHomeDefaultIcon;
        getHomeButton().setImageDrawable(homeIcon);
    }

getHomeButton获得HOME对应的ButtonDispatcher对象,设置mHomeDefaultIcon。
对应图片资源为R.drawable.ic_sysbar_home,所以想要修改虚拟按键样式,直接替换相应资源即可

    mHomeDefaultIcon = getHomeDrawable();

    public KeyButtonDrawable getHomeDrawable() {
        final boolean quickStepEnabled = mOverviewProxyService.shouldShowSwipeUpUI();
        KeyButtonDrawable drawable = quickStepEnabled
                ? getDrawable(R.drawable.ic_sysbar_home_quick_step)
                : getDrawable(R.drawable.ic_sysbar_home);
        orientHomeButton(drawable);
        return drawable;
    }

注意:为了方便多分辨适配,保证在高分辨率下也能显示精细画面,系统中大部分的图标资源都是矢量图,类似这样的,在替换的时候最好要求客户也提供这样的资源

ic_sysbar_home.xml
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="28dp"
    android:height="28dp"
    android:viewportWidth="28"
    android:viewportHeight="28">

    <path
        android:fillColor="?attr/singleToneColor"
        android:pathData="M 14 7 C 17.8659932488 7 21 10.1340067512 21 14 C 21 17.8659932488 17.8659932488 21 14 21 C 10.1340067512 21 7 17.8659932488 7 14 C 7 10.1340067512 10.1340067512 7 14 7 Z" />
</vector>

而实现实际HOME按键的功能逻辑其实很简单。
当布局都加载好,我们触摸BACK键的相应区域,触发对应KeyButtonView的onTouchEvent方法

//KeyButtonView
public boolean onTouchEvent(MotionEvent ev) {
        final int action = ev.getAction();

        switch (action) {
            case MotionEvent.ACTION_DOWN:
                mDownTime = SystemClock.uptimeMillis();
                
                if (mCode != KEYCODE_UNKNOWN) {
                    sendEvent(KeyEvent.ACTION_DOWN, 0, mDownTime);
                } 
                break;
            //...
        }

        return true;
    }

然后调用InputManager.injectInputEvent方法注入input事件,这里的mCode其实就是根据home.xml中的systemui:keyCode="3"得来的,对应KeyEvent.java中HOME键值的定义
public static final int KEYCODE_HOME = 3;

//KeyButtonView

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

private void sendEvent(int action, int flags, long when) {

        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);

        mInputManager.injectInputEvent(ev, InputManager.INJECT_INPUT_EVENT_MODE_ASYNC);
    }
  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值