仿夸克瀏覽器底部工具欄

大頭呆發表於2017-12-08

夸克瀏覽器是我非常喜歡的一款瀏覽器,使用起來簡潔流暢,UI做的也很精緻。今天我就來仿寫主頁底部的工具欄。先來看看原本的效果:

仿夸克瀏覽器底部工具欄

效果比較簡單,從外表看就是一個彈框,特別之處就是可以收縮伸展布局,再來看看我實現的效果:

仿夸克瀏覽器底部工具欄
怎麼樣?效果是不是已經非常接近。先整體說下思路吧,底部對話方塊用DialogFragment來實現,裡面的可伸縮佈局採用自定義ViewGroup。看了本文你將能學到(鞏固)以下知識點:

  • DialogFragment的用法;
  • 自定義ViewGroup的用法,包括onMeasureonLayout方法;
  • ViewDragHelper的用法,包括處理手勢和事件衝突

聽起來內容挺多的,但只要一步步去解析,其實實現過程也不算複雜。

底部對話方塊

底部對話方塊我採用了DialogFragment,因為相比傳統的AlertDialog實現起來更簡單,用法也幾乎和普通的Fragment沒有什麼區別。 主要工作就是指定顯示位置:

public class BottomDialogFragment extends DialogFragment {

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        return inflater.inflate(R.layout.fragment_bottom, null);
    }

    public void onStart() {
        super.onStart();
        Dialog dialog = getDialog();
        if (dialog != null && dialog.getWindow() != null) {
            Window window = dialog.getWindow();
            //指定顯示位置
            dialog.getWindow().setGravity(Gravity.BOTTOM);
            //指定顯示大小
            dialog.getWindow().setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
            //顯示消失動畫
            window.setWindowAnimations(R.style.animate_dialog);
            //設定背景透明
            window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));
            //設定點選外部可以取消對話方塊
            setCancelable(true);
        }
    }
}
複製程式碼

點選顯示彈框:

FragmentManager fm = getSupportFragmentManager();
BottomDialogFragment bottomDialogFragment = new BottomDialogFragment();
bottomDialogFragment.show(fm, "fragment_bottom_dialog");
複製程式碼

仿夸克瀏覽器底部工具欄

自定義摺疊佈局

這裡主要用到的就是自定義ViewGroup的知識了。先大致梳理一下:我們需要包含兩個子view,在上面的topView,在下面的bottomViewtopView往下滑的時候要覆蓋bottomView。但是ViewGroup的顯示的層次順序和新增順序是反過來的,後面新增的view如果和前面新增的View有重疊的話會覆蓋前面會覆蓋新增的view,而我們預想的佈局檔案應該是這樣的:

<ViewGroup>
    <topView/>
    <bottom/>
</ViewGroup>
複製程式碼

所以我們需要在程式碼中手動對換兩者順序:

 @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        if (getChildCount() != 2) {
            throw new RuntimeException("必須是2個子View!");
        }
            topView = getChildAt(0);
            bottomView = getChildAt(1);
            bringChildToFront(topView);
    }
複製程式碼

這樣之後getChildAt(0)取到的就是bottomView了。接下來是onMeasure(),計算自身的大小:

    /**
     * 計算所有ChildView的寬度和高度 然後根據ChildView的計算結果,設定自己的寬和高
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        /**
         * 獲得此ViewGroup上級容器為其推薦的寬和高,以及計算模式
         */
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
        int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);

        // 計算出所有的childView的寬和高
        measureChildren(widthMeasureSpec, heightMeasureSpec);
      
        int width = 0;
        int height = 0;

        /**
         * 根據childView計算的出的寬和高,以及設定的margin計算容器的寬和高,主要用於容器是warp_content時
         */
        for (int i = 0; i < getChildCount(); i++) {
            View childView = getChildAt(i);
            MarginLayoutParams cParams = (MarginLayoutParams) childView.getLayoutParams();
            int cWidthWithMargin = childView.getMeasuredWidth() + cParams.leftMargin + cParams.rightMargin;
            int cHeightWithMargin = childView.getMeasuredHeight() + cParams.topMargin + cParams.bottomMargin;
            //高度為兩個子view的和
            height = height + cHeightWithMargin;
            //寬度取兩個子view中的最大值
            width = cWidthWithMargin > width ? cWidthWithMargin : width;
        }
        /**
         * 如果是wrap_content設定為我們計算的值
         * 否則:直接設定為父容器計算的值
         */
        setMeasuredDimension((widthMode == MeasureSpec.EXACTLY) ? sizeWidth
                : width, (heightMode == MeasureSpec.EXACTLY) ? sizeHeight
                : height);
    }
複製程式碼

然後自定義onLayout(),放置兩個子View的位置:

 @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        /**
         * 遍歷所有childView根據其寬和高,以及margin進行佈局
         */
        for (int i = 0; i < getChildCount(); i++) {
            View childView = getChildAt(i);
            int cWidth = childView.getMeasuredWidth();
            int cHeight = childView.getMeasuredHeight();
            MarginLayoutParams cParams = (MarginLayoutParams) childView.getLayoutParams();
            int cl = 0, ct = 0, cr = 0, cb = 0;
            switch (i) {
                case 0://bottomView放下面
                    cl = cParams.leftMargin;
                    ct = getHeight() - cHeight - cParams.bottomMargin;
                    cb = cHeight + ct ;
                    childView.setPadding(0, extendHeight, 0, 0);
                    cr = cl + cWidth;
                    break;
                case 1://topView放上面
                    cl = cParams.leftMargin;
                    ct = cParams.topMargin;
                    cb = cHeight + ct;
                    cr = cl + cWidth;
                    break;
            }
            childView.layout(cl, ct, cr, cb);
        }
    }
複製程式碼

這樣之後,就可以顯示佈局了,但還是不能滑動。處理滑動我採用了ViewDragHelper,這個工具類可謂自定義ViewGroup神器。有了它,ViewGroup可以很容易的控制各個子View的滑動。什麼事件分發,滑動衝突都不需要我們操心了。

mDragger = ViewDragHelper.create(this, 1.0f, new ViewDragHelperCallBack())

建立例項需要3個引數,第一個就是當前的ViewGroup,第二個是sensitivity(敏感係數,聯想下滑鼠靈敏度就知道了)。第三個引數就是Callback,會在觸控過程中會回撥相關方法,也是我們主要需要實現的方法。

   private class ViewDragHelperCallBack extends ViewDragHelper.Callback {
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            return topView == child;//限制只有topView可以滑動
        }

        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            return 0;//橫向可滑動範圍,因為不可以橫向滑動直接返回0就行
        }

        @Override
        public int getViewVerticalDragRange(View child) {
            return getMeasuredHeight() - child.getMeasuredHeight();
        }

        @Override
        public int clampViewPositionVertical(View child, int top, int dy){
        //豎向可滑動範圍,top是child即將滑動到的top值,限制top的範圍在topBound和bottomBound之間。
            final int topBound = getPaddingTop();
            final int bottomBound = getHeight() - child.getHeight() -  getPaddingBottom();
            return Math.min(Math.max(top, topBound), bottomBound);
        }

        @Override
        public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
            float percent = (float) top / (getHeight() - changedView.getHeight());
            //處理topView動畫
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                changedView.setElevation(percent * 10);
            }
            //處理bottomView動畫
            bottomView.setScaleX(1 - percent * 0.03f);
        }

        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
        //手指釋放時,滑動距離大於一半直接滾動到底部,否則返回頂部
            if (releasedChild == topView) {
                float movePercentage = (float) (releasedChild.getTop()) / (getHeight() - releasedChild.getHeight() - elevationHeight);
                int finalTop = (movePercentage >= .5f) ? getHeight() - releasedChild.getHeight() - elevationHeight : 0;
                mDragger.settleCapturedViewAt(releasedChild.getLeft(), finalTop);
                invalidate();
            }
        }
    }
複製程式碼

至於處理事件分發,處理滾動全都交給ViewDragHelper做就行了:

 @Override
    public void computeScroll() {
        if (mDragger.continueSettling(true)) {
            invalidate();
        }
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent event) {
        return mDragger.shouldInterceptTouchEvent(event);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mDragger.processTouchEvent(event);
        return true;
    }
複製程式碼

總結

好了實現大致分析完了,還有一些小細節的處理和自定義View常用的回撥、get/set方法就不說了,大家如果有興趣的話就直接去看原始碼吧。個人覺得以上實現通用性還是不足吧,現在只能實現一層摺疊,摺疊方向也是固定的。作為對比,我們來看下Android系統通知欄的流式摺疊佈局。怎麼樣,是不是比上面這個不知道高到哪裡去了!Excited!

仿夸克瀏覽器底部工具欄
最近我也在琢磨如何實現(recyclerView+自定義layoutManager???)。有實現方法或原始碼的同學請在下方留言,感激不盡!如果我琢磨出來了也會第一時間分享出來。 最後貼下本慄的Github地址:

Github地址

12月12日更新:以上的效果我已經實現啦,請關注後續部落格:

RecyclerView進階之層疊列表(上)

相關文章