SlidingPanelLayout学习与使用

SlidingPanelLayout

应用场景

SlidingPaneLayout 是Android在android-support-v4.jar中推出的一个支持测滑面板的布局。例如:头条详情页,知乎详情页…都是支持侧滑移除Activity的。通过抓取手机页面的xml,可以看到他们使用的技术也都是SlidingPaneLayout。如果你也想实现这么一个功能,那么请移步下面。

SlidingPaneLayout 出来那么长时间了,第一次使用它,汗颜~~~

常用API

  1. setSliderFadeColor :根据滑动的距离控制滑出的内容淡出的颜色亮度
  2. setCoveredFadeColor : 根据滑动的距离设置左侧面板的阴影的亮度
  3. setPanelSlideListener :设置面板的滑动监听,可以在回调中为底部面板设置效果
  4. mOverhangSize属性:滑动到边界时,内容面板与边界的距离。需要设置为0

使用方式

网上有一些说在XML中直接添加,我觉得这个扩展性不是太好,建议使用API,而不使用XML布局。

 // 截屏: 这个方法不会截取底部导航栏,但是截取到的内容底部有一块同导航栏同高的白块。
private static Bitmap captureScreen(Activity activity){
        View cv = activity.getWindow().getDecorView();
        cv.setDrawingCacheEnabled(true);
        cv.buildDrawingCache();
        Bitmap drawingCache = Bitmap.createBitmap(cv.getDrawingCache());
        if(drawingCache != null){
            drawingCache.setHasAlpha(false);
            drawingCache.prepareToDraw();
        }
        return drawingCache;
    }
private ImageView slideBackImg;
// 上一个activity的截图
public static Bitmap screenSlideBitmap;
protected void initSwipeBackFinish() {
        // 1. 若底部背景不存在,则不使用左滑
        if (screenBitmap == null) {
            return;
        }
        SlidingPaneLayout paneLayout = new SlidingPaneLayout(this);
        try {
            // 当滑动到最后时,会预留出mOverhangSize的距离到边界,使用反射将预留距离设为0
            Field overhang = SlidingPaneLayout.class.getDeclaredField("mOverhangSize");
            overhang.setAccessible(true);
            overhang.set(paneLayout, 0);
        } catch (Exception exp) {

        }
        // 设置滑动监听
        paneLayout.setPanelSlideListener(this);
        // 根据滑动的距离控制滑出的内容淡出的颜色
        paneLayout.setSliderFadeColor(Color.parseColor("#0fff0000"));
        
        // 设置底部的背景图,可以在滑动监听中,为该view设置展示效果
        slideBackImg = new ImageView(this);
        slideBackImg.setImageBitmap(screenBitmap);
        paneLayout.addView(slideBackImg);

        ViewGroup decor = (ViewGroup) getWindow().getDecorView();
        View decorChild =  decor.getChildAt(0);
        // 获取原decorChild距离底部的高度
        ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams)decorChild.getLayoutParams();
        int paddingBottom = layoutParams.bottomMargin;
        decor.removeView(decorChild);
        decorChild.setBackgroundColor(getResources().getColor(android.R.color.white));
        // 当底部的导航栏存在时:若不设置padding,会全屏展示,覆盖了底部的导航栏。
        decorChild.setPadding(0,0,0,paddingBottom);
        paneLayout.addView(decorChild, 1);
        // 将paneLayout设置为第一个view
        decor.addView(paneLayout,0);
    }
    
    @Override
    public void onPanelSlide(@NonNull View panel, float slideOffset) {
        // 滑动内容面板时,在这个回调这种做一些效果
        changeSlideBackAlpha(slideOffset);
    }
    
    // 动态改变底部面板的亮度与大小
    private void changeSlideBackAlpha(float factor) {
        slideBackImg.setScaleX(0.9f + factor * 0.1f);
        slideBackImg.setScaleY(0.9f + factor * 0.1f);
        if (factor < 0.3F) {
            factor = 0.3f;
        }
        slideBackImg.setAlpha(factor);
    }

    @Override
    public void onPanelOpened(@NonNull View panel) {
        finish();
        // 内容面板完全出去后,取消activity的转场效果,否则会产生不好的交互体验
        overridePendingTransition(0,0);
    }
    
    @Override
    public void onPanelClosed(View panel) {
    }

源码分析

// SlidingPaneLayout 是一个自定义容器,想一下:自定义ViewGroup,也许他重写了onMeasure与onLayout?
public class SlidingPaneLayout extends ViewGroup {
// BingGo,果然,你猜的没错,他重写了测量与布局。再想一下:测量大小时,他需要处理AT_MOST模式下的size吧?
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 看吧,他也处理了AT_MOST模式下的大小。
        if(widthMode != MeasureSpec,EXACTLY){
            if (widthMode == MeasureSpec.AT_MOST) {
                    widthMode = MeasureSpec.EXACTLY;}
        }
        switch (heightMode) {
            case MeasureSpec.EXACTLY:
                layoutHeight = maxLayoutHeight = heightSize - getPaddingTop() - getPaddingBottom();
                break;
            case MeasureSpec.AT_MOST:
                maxLayoutHeight = heightSize - getPaddingTop() - getPaddingBottom();
                break;
        }
        // 确定完父布局的大小后,就要去测量子View想要的大小了。
        final int childCount = getChildCount();
        // 注意他建议使用两个子View,即上下两个面板。
        if (childCount > 2) {
            Log.e(TAG, "onMeasure: More than two child views are not supported.");
        }
        // 每次都计算得到可以滑动的View
        mSlideableView = null;
        boolean canSlide = false;
        final int widthAvailable = widthSize - getPaddingLeft() - getPaddingRight();
        int widthRemaining = widthAvailable;
         // 测量子View的大小以及确定可滑动的view
        for(int i = 0; i < childCount; ++i){
           
            final View child = getChildAt(i);
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
            // 相信你,如何测量子View的代码已经不用写了~~~(允许我偷懒一下)
            child.measure(childWidthSpec, childHeightSpec);
            // 想不想知道为什么是内容面板可以滑动,下面你会得到答案
            // 是的,他每次返回的都是index=1的面板即内容面板,且做了可滑宽度的优化。
            widthRemaining -= childWidth;
            canSlide |= lp.slideable = widthRemaining < 0;
            if (lp.slideable) {
                mSlideableView = child;
            }
        }
        // 测量滑动时上下面板的view
        if(canSlide || weightSum > 0){
            // 知道为什么使用时如果不将 mOverhangSize设为0,会距离边界有mOverhangSize的距离了吧
            // 固定内容面板宽度限制值
            final int fixedPanelWidthLimit = widthAvailable - mOverhangSize;
            
            for (int i = 0; i < childCount; i++) {
                // 跳过测量view宽度的标志
                final boolean skippedFirstPass = lp.width == 0 && lp.weight > 0;
                final int measuredWidth = skippedFirstPass ? 0 : child.getMeasuredWidth();
                if(canSlide && child != mSlideableView){ // 处理底部面板
                    if(lp.width < 0 && (measuredWidth > fixedPanelWidthLimit || lp.height > 0)){
                    // 和我们看到的效果是一样的,底部面板的大小一直是那些没有变
                        final childHeightSpec = MeasureSpec.makeMeasureSpec(
                                    child.getMeasuredHeight(), MeasureSpec.EXACTLY);
                        final int childWidthSpec = MeasureSpec.makeMeasureSpec(
                                fixedPanelWidthLimit, MeasureSpec.EXACTLY);
                        child.measure(childWidthSpec, childHeightSpec);
                    }
                }else if (lp.weight > 0) { // 处理内容面板
                    // 想一下内容面板的效果,宽度一直在变,而高度不变。是不是有思路了那
                    final childHeightSpec = MeasureSpec.makeMeasureSpec(
                                child.getMeasuredHeight(), MeasureSpec.EXACTLY);
                    if(canSlide){
                        final int horizontalMargin = lp.leftMargin + lp.rightMargin;
                        // 由于widthAvailabley一直在变,因此newWidth也在变
                        final int newWidth = widthAvailable - horizontalMargin;
                        final int childWidthSpec = MeasureSpec.makeMeasureSpec(
                                newWidth, MeasureSpec.EXACTLY);
                        if (measuredWidth != newWidth) {
                            child.measure(childWidthSpec, childHeightSpec);
                        }        
                    }
                }
            }
            // 测量的最后
            final int measureWidth = widthSize;
            final int measureHeight = layoutHeight + getpaddingTop() + getPaddingBottom();
            setMeasuredDimension(measuredWidth, measuredHeight);
            // 至此,大小测量就完成了?....仔细想一下,是不是缺点啥?是的,还有滑动没处理
            mCanSlide = canSlide;
            if(mDragHelper.getViewDragState != ViewDragHelper.STATE_IDLE && !canSlide){
                mDragHelper.abort();
            }
            // 至此,测量过程就完成了。去看下布局过程吧!
        }
        
    }
    // 布局,顾名思义,他控制了View的显示位置,因此计算坐标是必须的
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        // 根据从左向右还是从右向左,获取子view距离边界的值
        final int width = r - l; // 父布局的宽度
        final int paddingStart = isLayoutRtl ? getPaddingRight() : getPaddingLeft();
        final int paddingEnd = isLayoutRtl ? getPaddingLeft() : getPaddingRight();
        final int paddingTop = getPaddingTop();

        final int childCount = getChildCount();
        int xStart = paddingStart;
        int nextXStart = xStart;
        
        // 滑动的偏移量
        if (mFirstLayout) {
            mSlideOffset = mCanSlide && mPreservedOpenState ? 1.f : 0.f;
        }
        
        for (int i = 0; i < childCount; i++) {
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
            if(lp.slideable){
                // 获取子view与父布局的margin和
                final int margin = lp.leftMargin + lp.rightMargin;
                // 确定滑动了的区间(即内容面板距离开始边界的范围)
                final int range = Math.min(nextStart , width - paddingEnd - mOverhangSize) - xStart - margin;
                mSlideRange = range;
                // 子view距离父布局的margin
                final int lpMargin = isLayoutRtl?lp.rightMargin:lp.leftMargin;
                // 判断可滑动区域是否超出了width
                lp.dimWhenOffset = xStart + lpMargin + range + childWidth / 2 > width - paddingEnd;
                // 计算每次移动的x坐标
                final int pos = (int)(range * mSlideOffset)
                // 计算内容布局下一次开始滑动的x坐标
                xStart += pos + lpMargin;
                // 计算下一次的偏移量
                mSlideOffset = (float) pos / mSlideRange;
            } else if (mCanSlide && mParallaxBy != 0) {
                offset = (int) ((1 - mSlideOffset) * mParallaxBy);
                xStart = nextXStart;
            } else { // 底部面板的计算
                xStart = nextXStart;
            }
            // 进行布局
            final int childLeft = xStart - offset;
            final int childRight = childLeft + childWidth;
            final int childTop = paddingTop;
            final int childBottom = childTop + child.getMeasuredHeight();
            child.layout(childLeft, paddingTop, childRight, childBottom);
            
            nextXStart += child.getWidth();nextXStart += child.getWidth();
        }
        **** 
        mFirstLayout = false;
        // 好了,布局也分析完了。可是既然他是支持滑动的自定义view,那么事件机制肯定要重写。那我们去看下事件吧
    }
    // 事件拦截只拦截down和move
    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        final int action = ev.getMaskAction();
        ****
        boolean interceptTap = false;
        final float x = ev.getX();
        final float y = ev.getY();
        switch(action){
            case MotionEvent.ACTION_DOWN: {
                mIsUnableToDrag = false;
                mInitialMotionX = x;
                mInitialMotionY = y;

                if (mDragHelper.isViewUnder(mSlideableView, (int) x, (int) y)
                        && isDimmed(mSlideableView)) {
                    interceptTap = true;
                }
                break;
            }
            case MotionEvent.ACTION_MOVE: {
                final float adx = Math.abs(x - mInitialMotionX);
                final float ady = Math.abs(y - mInitialMotionY);
                // 获取触摸启动的最小距离
                final int slop = mDragHelper.getTouchSlop();
                // 如果是垂直方向,则不拦截
                if (adx > slop && ady > adx) {
                    mDragHelper.cancel();
                    mIsUnableToDrag = true;
                    return false;
                }
            }
        }
        final boolean interceptForDrag = mDragHelper.shouldInterceptTouchEvent(ev);
        return interceptForDrag || interceptTap;
    }
    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        if(!mCanSlide){
            return super.onTouchEvent(ev);
        }
        // 将事件交由DragHelper处理
        mDragHelper.processTouchEvent(ev);
        boolean wantTouchEvents = true;
        int action = ev.getMaskAction();
        final float x = ev.getX();
        final float y = ev.getY();
        switch(action){
            case MotionEvent.ACTION_DOWN: {
                mInitialMotionX = x;
                mInitialMotionY = y;
                break;
            }

            case MotionEvent.ACTION_UP: {
            // 抬起时,处理是否隐藏内容面板
                if (isDimmed(mSlideableView)) {
                    final float dx = x - mInitialMotionX;
                    final float dy = y - mInitialMotionY;
                    final int slop = mDragHelper.getTouchSlop();
                    if (dx * dx + dy * dy < slop * slop
                            && mDragHelper.isViewUnder(mSlideableView, (int) x, (int) y)) {
                        // Taps close a dimmed open pane.
                        closePane(mSlideableView, 0);
                        break;
                    }
                }
                break;
            }
        }
        return wantTouchEvents;
    }
    // 有没有一种恍然大明白的感觉,别慌,咱们还有一个难啃的骨头在后面,滑动时候是如何将偏移量传递到之前注册的监听上的?仔细想一下,咱们的事件是谁处理的?没错,是DragHelper!!!
    public SlidingPaneLayout(){
        // 在构造方法中,创建一个ViewDragHelper,并将该view与ViewDragHelper绑定。
        mDragHelper = ViewDragHelper.create(this, 0.5f, new DragHelperCallback());
        mDragHelper.setMinVelocity((400 * context.getResources().getDisplayMetrics().density));
    }
    // 看没看到Callback,没错他也是通过回调,来让view处理响应的。
    private class DragHelperCallback extends ViewDragHelper.Callback{
        // 当状态改变时调用
        @Override
         public void onViewDragStateChanged(int state) {
         // 只处理IDLE状态
             if (mDragHelper.getViewDragState() == ViewDragHelper.STATE_IDLE) {
                // 调用OnPanelOpened
                 dispatchOnPanelOpened(mSlideableView);
             }
         }
         // 当拖拽view时触发
         @Override
        public void onViewCaptured(View capturedChild, int activePointerId) {
            // 设置上下面板可见
            setAllChildrenVisible();
        }
        // 当每个view的位置发生改变时调用
        @Override
         public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {
            // 根据距离边界的大小计算偏移量,并调用OnPanelSlide
            onPanelDragged(left);
            invalidate();   
         }
         ***
    }
    // 好了,整个的流程大致走了一遍,希望有所帮助。
}

总结

  • 建议使用API的形式使用SlidingPaneLayout;
  • 注意将内容面板滑动到边界时的距离(mOverhangSize)设置为0;
  • 注意处理底部导航栏的问题;(将paneLayout设置为decorView的第一个View)
  • 注意内容全屏显示的问题。(设置paddingBottom解决)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值