Android自定义LinearLayout实现侧滑布局--SwipeLinearLayout

Posted lankton

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android自定义LinearLayout实现侧滑布局--SwipeLinearLayout相关的知识,希望对你有一定的参考价值。

描述

这周做了一个自定义侧滑布局, 继承自LinearLayout。
代码地址:android-SwipeLinearLayout

效果

可以单独使用,也可以在ListView等可滑动的父组件中使用。以在ListView中使用为demo:

解决了item和ListView的滑动冲突, 同时每个item及其上面的控件可以正常点击。

代码比较简单,就不上传到JCenter了。 控件本身就只有一个文件: SwipeLinearLayout.java, 有需要可以直接复制或者修改。

使用

和普通LinearLayout一样使用,内部包含2个子元素即可。
示例:

<xx.SwipeLinearLayout
  xxxx>
  <LinearLayout
    android:layout-width="match_parent"
    xxxx
    xxxx>
    ... ...
  </LinearLayout>
  <LinearLayout
    android:layout-width="30dp"
    xxxx>
    ... ...
  </LinearLayout>
</xx.SwipeLinearLayout>

第一个子元素是未侧滑时就显示的部分, 第二个子元素是会被侧滑出来的部分。
SwipeLinearLayout的orientation随便设置,反正都会当成horizontal处理。

public SwipeLinearLayout(Context context) 
    this(context, null);


public SwipeLinearLayout(Context context, AttributeSet attrs) 
    this(context, attrs, 0);


public SwipeLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) 
    super(context, attrs, defStyleAttr);
    mScroller = new Scroller(context);
    this.setOrientation(HORIZONTAL);

实现

如何进行滑动

这个问题思路很简单。滑动分为2个阶段, 一个阶段就是跟手滑动,另外一个阶段,就是当手指离开后,布局继续滑动。
跟手滑动,那么我们很容易就想到重写onTouchEvent方法,在ACTION_MOVE事件中实现。那手指离开之后呢?首先要明确一点,开始处理的判断,是放在ACTION_UP事件中的。我们可以通过此时布局展开的程度,决定布局是要完全展开,还是缩回初始状态。为了让这种自动的滚动显得自然,我们需要借助Scroller。
Scroller可以看作一种类似插值器一样的东西,可以在系统调用的回调中,为我们提供一个起、终值之间的值。随着时间的增长,这个值逐渐从起点值变成终点值。通过这个值随时间的变化,可以帮助我们实现布局的平滑滚动。
处理滑动的代码如下:

@Override
public boolean onTouchEvent(MotionEvent event) 
    switch (event.getAction()) 
        case MotionEvent.ACTION_DOWN:
        case MotionEvent.ACTION_POINTER_DOWN:
            lastX = event.getX();
            lastY = event.getY();
            startScrollX = getScrollX();
            break;
        case MotionEvent.ACTION_MOVE:
            if (ignore) 
                ignore = false;
                break;
            
            float curX = event.getX();
            float dX = curX - lastX;
            lastX = curX;
            if (hasJudged) 
                int targetScrollX = getScrollX() + (int)(-dX);
                if (targetScrollX > width_right) 
                    scrollTo(width_right, 0);
                 else if (targetScrollX < 0) 
                    scrollTo(0, 0);
                 else 
                    scrollTo(targetScrollX, 0);
                
            
            break;
        case MotionEvent.ACTION_UP:
            float finalX = event.getX();
            if (finalX < startX) 
                scrollAuto(DIRECTION_EXPAND);
              else 
                scrollAuto(DIRECTION_SHRINK);
            
            break;
        default:
            break;
    
    return true;


/**
 * 自动滚动, 变为展开或收缩状态
 * @param direction
 */
public void scrollAuto(final int direction) 
    int curScrollX = getScrollX();
    if (direction == DIRECTION_EXPAND) 
        // 展开
        mScroller.startScroll(curScrollX, 0, width_right - curScrollX, 0, 300);
     else 
        // 缩回
        mScroller.startScroll(curScrollX, 0, -curScrollX, 0, 300);
    
    invalidate();



@Override
public void computeScroll() 
    super.computeScroll();
    if (mScroller.computeScrollOffset()) 
        this.scrollTo(mScroller.getCurrX(), 0);
        invalidate();
    

可以看到我们就是在computeScroll()方法中,获得插值,进行滚动的。要注意的是,一定要调用invalidate(),computeScroll() 才会被调用。
关于hasJudged和ingnore标志位, 这两个是跟处理滑动冲突相关的。hasJudged标志位表示: 当前手指滑动的方向(水平or竖直)是否已经判断出,ignore表示是否要忽略这次被传到onTouchEvent里的事件。
我们继续往下看。

处理滑动冲突

处理滑动冲突的目的是,保证布局的左右滑动,和它父组件,如ListView等的竖直滑动,不会相互影响。如果仅仅像上文一样,只实现了onTouchEvent, 那么单独使用该布局,倒是没什么问题。但在ListView的item中使用的时候,你会发现,在你想划开子item的时候,很容易就引起了ListView的上下滑动。而且之后的所有事件, 都会被ListView拦截。这就很尴尬了,SwipeLinearLayout刚被划开一点就不动了。而且这种情况出现的非常频繁,滑动冲突必须处理,即:
touch事件被谁处理,必须由我们说了算。
本次处理滑动冲突,我采用的是内部拦截法。即,在子View的dispatchTouchEvent中,先使用父View的requestDisallowInterceptTouchEvent(true),阻止父View对后续事件进行拦截。然后再通过后续条件判断,是否让父View恢复拦截事件的能力。
在本例中,我们通过比较手指在水平方向和竖直方向移动距离的大小,判断是否调用requestDisallowInterceptTouchEvent(false)恢复父View拦截能力。为了判断更合理, 比较放在了手指移动超过一定距离的时候。

@Override
public boolean dispatchTouchEvent(MotionEvent ev) 
    switch (ev.getActionMasked()) 
        case MotionEvent.ACTION_DOWN:
            disallowParentsInterceptTouchEvent(getParent());
            hasJudged = false;
            startX = ev.getX();
            startY = ev.getY();
            break;
        case MotionEvent.ACTION_MOVE:
            float curX = ev.getX();
            float curY = ev.getY();
            if (hasJudged == false) 
                float dx = curX - startX;
                float dy = curY - startY;
                if ((dx * dx + dy * dy > MOVE_JUDGE_DISTANCE * MOVE_JUDGE_DISTANCE)) 
                    if (Math.abs(dy) > Math.abs(dx)) 
                        allowParentsInterceptTouchEvent(getParent());
                        if (null != onSwipeListener) 
                            onSwipeListener.onDirectionJudged(this, false);
                        
                     else 
                        if (null != onSwipeListener) 
                            onSwipeListener.onDirectionJudged(this, true);
                        
                        lastX = curX;
                        lastY = curY;
                    
                    hasJudged = true;
                    ignore = true;
                
            
            break;
        case MotionEvent.ACTION_UP:
            break;
        default:
            break;
    
    return super.dispatchTouchEvent(ev);

hasJudged, 很好理解,表示:当前手指滑动的方向(水平or竖直)是否已经判断出。那ignore呢,又是什么鬼?
是这样的:当我们判断手指其实是竖直方向滑动的时候,会恢复父View(如ListView)的拦截能力,那后续的滑动,其实都只是ListView的上下滑动了。这个,大家应该都能理解。但大家要注意一点,决定滑动方向的,最后一次ACTION_MOVE事件,依然被传到onTouchEvent里去了。这就会造成,虽然结果判定是对ListView进行上下滑动,但我们依然可以看见,相应的item的SwipeLinearLayout被划出来了一点。这就很难看了。于是我增加了一个ignore标志位,来表示,忽略这次的事件。即:用来决定方向的手指滑动,就只是用来决定方向的,而不会对UI产生任何影响。

你也可能发现,这里并没有直接调用parent的requestDisallowInterceptTouchEvent方法,而是调用了自定义的方法disallowParentsInterceptTouchEvent以及allowParentsInterceptTouchEvent。
看一下这两个方法:

private void disallowParentsInterceptTouchEvent(ViewParent parent) 
    if (null == parent) 
        return;
    
    parent.requestDisallowInterceptTouchEvent(true);
    disallowParentsInterceptTouchEvent(parent.getParent());


private void allowParentsInterceptTouchEvent(ViewParent parent) 
    if (null == parent) 
        return;
    
    parent.requestDisallowInterceptTouchEvent(false);
    allowParentsInterceptTouchEvent(parent.getParent());

用了递归,原因很简单:你想阻止或者恢复拦截的,并不一定是SwipeLinearLayout的直接父组件。举个例子,SwipeLinearLayout可能只是你的item布局的一个子布局, 那它的父布局就不是ListView。我们要阻止ListView,就只能通过递归的方式,向上搜索,然后调用requestDisallowInterceptTouchEvent(false)。

处理点击事件

一个View被设置了OnClickListener,其onClick方法其实是在OnTouchEvent的ACTION_UP中调用的。所以SwipeLinearLayout的子控件,如果想点击事件生效,就必须得到事件。而为了保证SwipeLinearLayout的滑动,SwipeLinearLayout的又必须对事件进行拦截。所以,可以重写SwipeLinearLayout的处理拦截方法如下:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) 
    if (hasJudged) 
        return true;
    
    return super.onInterceptTouchEvent(ev);

应该很好理解,当判定了滑动方向的时候(其实就是水平方向, 如果是竖直方向的话,直接就被上层拦截了,到不了这里),返回true, 自己消费touch事件,没判定的话,就返回父类,即LinearLayout的onInterceptTouchEvent。LinearLayou的子控件可以点击吗?当然可以。所以这样写就ok了。
看到的效果如示例动图:
拖动左边白色部分的时候,虽然手指一直在上面,也是从上面离开的,但依然不会出发click事件。但直接点击的话,则会弹出Toast,提示点击了item。

ListView中item的联动

这个描述,说的其实就是图片里显示的,竖直滑动ListView,或者滑动其他的item, 已经展开的item会复原。
这个重点其实不在SwipeLinearLayout上了,具体的逻辑是在与ListView对应的Adapter上。
SwipeLinearLayout中提供了这样一个interface:

public interface OnSwipeListener 
    /**
     * 手指滑动方向明确了
     * @param sll  拖动的SwipeLinearLayout
     * @param isHorizontal 滑动方向是否为水平
     */
    void onDirectionJudged(SwipeLinearLayout sll, boolean isHorizontal);

onDirectionJudged, 在hasJudged被置为true的时候被调用。在上面的代码中也可以看到。
下面看Adapter中是如何实现这个接口的:

@Override
public void onDirectionJudged(SwipeLinearLayout thisSll, boolean isHorizontal) 
    if (false == isHorizontal) 
        for (SwipeLinearLayout sll : swipeLinearLayouts) 
            if (null == sll) 
                continue;
            
            sll.scrollAuto(SwipeLinearLayout.DIRECTION_SHRINK);
        
     else 
        for (SwipeLinearLayout sll : swipeLinearLayouts) 
            if (null == sll) 
                continue;
            
            if (!sll.equals(thisSll)) 
                //划开一个sll, 其他收缩
                sll.scrollAuto(SwipeLinearLayout.DIRECTION_SHRINK);
            
        
    

swipeLinearLayouts是Adapter中定义的,一个保存ListView中所有item里的SwipeLinearLayout的列表(由于convertView的复用,其实这个列表的长度是很有限的,不用担心内存等问题)。
看了代码, 实现的逻辑就很清楚了:
竖直方向,直接缩起所有SwipeLinearLayout, 否则,把不是当前滑动的SwipeLinearLayout全部缩起来。

总结

如果只是考虑横向滚动,那么问题就非常简单,只需要重写OnTouchEvent,这点大家肯定都会,我也没必要写这篇博客了。然而为了处理滑动冲突(包括保证子View的点击),我们将dispatchTouchEvent和onInterceptTouchEvent也都重写了。一个是保证自己在滑动的时候,事件不会被上层粗暴拦截,另一个是保证自己在不滑动的时候,事件能够传给内部的子控件。

代码只贴了重点部分, 但其实也差不多了,毕竟代码量也不是很大,重点就在于对于事件的分发与拦截。如果需要查看项目及demo完整代码,可以访问:
android-SwipeLinearLayout

以上是关于Android自定义LinearLayout实现侧滑布局--SwipeLinearLayout的主要内容,如果未能解决你的问题,请参考以下文章

Android自定义LinearLayout实现侧滑布局--SwipeLinearLayout

Kolin 实现侧抽屉(drawer)菜单

Kolin 实现侧抽屉(drawer)菜单

Android自定义LinearLayout实现左右侧滑菜单,完美兼容ListViewScrollViewViewPager等滑动控件

LinearLayout源码解析

LinearLayout源码解析