项目需求讨论-自定义滚轮

1,255 阅读12分钟

大家好,这次又是到了实际的项目需求讨论时间,我的一些文章下面,有时候有人评论,求源码,求Demo,我的主张是仔细看文章,自己理解了再写一遍,会懂得更多。大部分人都习惯直接拿过来用,能用了都不会去看为什么能实现需求。所以我还是写以如何实现为主。(这B装的我太累了。我TM就是懒啊。不想写Demo,自觉性还是有待提高。)

这次是关于滚轮方面需求,美工又出难题了。叫开发做一个滚轮,实现的效果如下GIF图所示:

需求:

  1. 滚轮进行滚动,并且要求是循环滚动。就是比如从A滑到了G,继续滑动又到了A。
  2. 比如A开始滑动,滑到B,但是你其实只滑动了一点点,那放手后当然是重新弹回A处,只有当你滑动的距离超过每项的一半的时候,才能让那一项滚到中间。
  3. 比如A项已经滚到了中间了,然后要再点击中间那一项,然后滚轮上面空白界面相应的界面会被更新,只能点击滚轮中间那项部分,其他的点击没效果。

开始起航:

我们就一步步来,先做一个滚轮,我们知道,滚轮具有滚动效果,所以我们就直接让我们自定义滚轮继承ScrollView。从上面的GIF图可知,我们的滚轮显示在界面上的是有五项,也就是我们比如规定我们的每项的高度是50dp,那我们的自定义滚轮就是每项的高度乘以你要显示在界面的个数(50dp X 5 = 250dp)。

我们先来继承ScrollView:

public class WheelView extends ScrollView {
    public WheelView(Context context) {
        super(context);
        init(context);
    }

    public WheelView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context);

    }

    public WheelView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        init(context);
    }
}

然后在init()方法中我们初始化滚轮具备的一些功能,比如我们上面的<需求2>

private LinearLayout views;
Runnable scrollerTask;
int initialY;

private void init(Context context) {
    this.context = context;

    this.setVerticalScrollBarEnabled(false);

    views = new LinearLayout(context);
    views.setOrientation(LinearLayout.VERTICAL);
    this.addView(views);

    scrollerTask = new Runnable() {

        public void run() {

            int newY = getScrollY();
            if (initialY - newY == 0) { // stopped
                final int remainder = initialY % itemHeight;
                final int divided = initialY / itemHeight;

                if (remainder == 0) {
                    selectedIndex = divided + offset;

                    onSeletedCallBack();
                } else {
                    if (remainder > itemHeight / 2) {
                        WheelView.this.post(new Runnable() {
                            @Override
                            public void run() {
                                WheelView.this.smoothScrollTo(0, initialY - remainder + itemHeight);
                                selectedIndex = divided + offset + 1;
                                onSeletedCallBack();
                            }
                            });
                    } else {
                        WheelView.this.post(new Runnable() {
                            @Override
                            public void run() {
                                WheelView.this.smoothScrollTo(0, initialY - remainder);
                                selectedIndex = divided + offset;
                                onSeletedCallBack();
                            }
                        });
                    }
                }
            } else {
                initialY = getScrollY();
                WheelView.this.postDelayed(scrollerTask, newCheck);
            }
        }
    };
}

我们来分析下我们的init()方法,首先我们都知道ScrollView中只能有一个子控件,但我们滚轮里面有很多一项项的item,那怎么弄呢。 先在ScrollView中放一个LinearLayout,然后把我们要显示的滚轮中的每一项再加入到这个LinearLayout中即可。同时大家也知道ScrollView本身在右边会有一个显示的滚动条,我们还要把这个滚动条去除掉。代码如下:

this.setVerticalScrollBarEnabled(false);

views = new LinearLayout(context);
views.setOrientation(LinearLayout.VERTICAL);
this.addView(views);

好了,我们再来看,我们如何能实现<需求2>


如何一格一格的滚动:

我们先来知道一个东西,如何让他每次滚动是滚一个Item呢,而不是说直接卡在一半,就是说我直接划动一部分距离,然后ScrollView中的内容就显示成下面这个图:


因为我们知道ScrollView的滚动不是特定一格一格滚动的,所以我们要用到了ScrollView中的smoothScrollTo方法了(可能有人会问,为啥不用ScrollTo,也可以,但是用smoothScrollTo滚动的时候是平缓的而不是立即滚动到某处)。我们比如滑动一部分。我们就让ScrollView直接smoothScrollTo相应的item的Y值处。这样,就相当于滑动是一格一格的。

假设我们现在这个滚轮是只显示3项,假设每个的高度都是100,然后我们比如往上滑,比如让C居于中间,我们只要smoothScrollTo(0,100),比如再往上移动一格呢,就是smoothScrollTo(0,200),再往上移动一格就是smoothScrollTo(0,300),也就是相当于smoothScrollTo(0,(当前的Y值)+(偏移的格数 * 每格的高度)),如果是向下移动,里面的偏移的格数就为负数,当前与当前的Y值减去相应的高度即可。


如何计算偏移格数:

所以我们已经解决了每次移动的时候一定是一格一格的移动,而不会说滑动了后,在二根红线内显示一半的Item。接下去我们要处理如何定位我滑动的时候来确定上述公式里面的偏移格数。

我们先来获取你滚动到哪里了:使用getScrollY(),所以当我们滑动了,我们就能获取到我们这次滚动到哪里了,这里我要分二块来讲:

  1. 慢慢的滑动 :
    慢慢滑动的时候,我们获取到的移动距离就直接是getScrollY(),我们只需要监听onTouch事件,然后在监听MotionEvent.ACTION_UP事件,当监听到手指抬起来了。我们就通过getScrollY()获取到这时候的滚动的到哪里了。

  2. 用力的滑动后放开:
    这时候在监听MotionEvent.ACTION_UP事件的时候,你如果获取了getScrollY的值,判定当前滑动到了这个位置是不准确的,为什么,因为ScrollView还有因为惯性在滑动,所以这时候我们要不停的获取getScrollY的值,比如第一次取跟第二次取相比较,如果不同,再取一次,每次都取了之后与前面一次取得值相比较,如果相同,才能说明ScrollView已经停下来了。这时候的距离才是真正的滚轮停止的位置。

所以我们在根据上面说的,我们重写onTouch监听MotionEvent.ACTION_UP的事件:

@Override
public boolean onTouchEvent(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_UP) {
        startScrollerTask();
    }
    return super.onTouchEvent(ev);
}

int newCheck = 50;
public void startScrollerTask() {
    initialY = getScrollY();
    this.postDelayed(scrollerTask, newCheck);
}

我们获取了手指放开时候的当前ScrollView 所处的位置,然后延迟一点点时间后运行了上面我们在init方法中自定义的Runnable,因为等会这个Runnable里面会再次获取ScrollView 的滚动位置,要用来比较,所以要延迟一点点时间。

我们来看下我们的自定义的Runnable的内容:

scrollerTask = new Runnable() {

    public void run() {

        int newY = getScrollY();
        if (initialY - newY == 0) { // stopped
            final int remainder = initialY % itemHeight;
            final int divided = initialY / itemHeight;

            if (remainder == 0) {
                selectedIndex = divided + offset;

                onSeletedCallBack();
            } else {
                if (remainder > itemHeight / 2) {
                    WheelView.this.post(new Runnable() {
                        @Override
                        public void run() {
                            WheelView.this.smoothScrollTo(0, initialY - remainder + itemHeight);
                            selectedIndex = divided + offset + 1;
                            onSeletedCallBack();
                        }
                        });
                } else {
                    WheelView.this.post(new Runnable() {
                        @Override
                        public void run() {
                            WheelView.this.smoothScrollTo(0, initialY - remainder);
                            selectedIndex = divided + offset;
                            onSeletedCallBack();
                        }
                    });
                }
            }
        } else {
            initialY = getScrollY();
            WheelView.this.postDelayed(scrollerTask, newCheck);
        }
    }
};

我们发现,我们在Runnable中再次调用了int newY = getScrollY();,然后获取了新的位置,然后跟刚才在onTouch中获取到的进行比较,如果相同,说明ScrollView已经停止了。如果不同,说明ScrollView还在滚动中,我们要再次调用:

initialY = getScrollY();
WheelView.this.postDelayed(scrollerTask, newCheck);

然后再次重复执行Runnable,直到我们发现后面获取到的getScrollY的值和延迟后获取到的getScrollY的值相同,说明了我们的ScrollView已经停止滚动了。这时候获取到的getScrollY的值就是当前这次滑动后的ScrollView 真正处于的位置了。

到了我们核心的部分了:通过获取到的ScrollView的滚动位置来计算出当前处于是哪个Item,然后我们要来通过smoothScrollTo移动这个到指定的Item项即可。

我们还是分二种情况分析:

  1. 第一种情况:

    我们还是来举例子,绿色的框是我们的手机屏幕,二根红线就是我们中间项的分割线,比如我们原来屏幕上显示的是A,B,C 三项,我轻轻的往上移动了80的距离,这时候我们获取到的getScrollY是80,
    我们通过拿到的getScrollY的值与每项的Item的高度做除法及求余算法。
    final int remainder = initialY % itemHeight;
    final int divided = initialY / itemHeight;
    通过divided我们就知道了当前屏幕上的能显示的第一个是哪个Item了。通过remainder我们就知道了屏幕的顶部处于这个Item的哪个位置。
    比如当前因为我们的getScrollY是80,所以我们divided = 80 / 100 = 0;所以当前屏幕上的显示的第一个就是index为0的那项,也就是A;remainder = 80 % 100 = 80,所以我们知道显示的第一项的大部分的都在屏幕外面了,只留下了(itemHeight - remainder ,即 100- 80 = 20)在屏幕里面。

这时候我要问大家了,我放开手,这时候想要的效果应该是什么,是不是A完全移出界面,然后B变成第一个,C变中间,D变最后一个,这时候理论上调用的代码应该是smoothScrollTo(0,1 * itemHeight);也就是smoothScrollTo(0,100)。那这个1是怎么来的呢。

if (remainder > itemHeight / 2) {
    WheelView.this.post(new Runnable() {
        @Override
            public void run() {
            WheelView.this.smoothScrollTo(0, initialY - remainder + itemHeight);
            ....
            ....
            ....
        }
    });
}

是不是因为是我们求余数得到的remainder大于了50(itemHeight/2),所以本来第一个明明是A,我们却让B变成了第一个,所以我们只需要把这个余数变为itemHeight就行了。我们给他补上20,不就等于把A成功的移出屏幕了吗? 所以不就是initialY - remainder + itemHeight

  1. 第二种情况:

如果我们只是滚动了一点点,比如我们这里只往上滚动了20的距离,这时候getScrollY为20,我们这时候获取到的

final int remainder = initialY % itemHeight;
final int divided = initialY / itemHeight;

remainder = 20,divided = 0,说明还是index为0的处于屏幕第一个,那这时候因为remainder 小于 itemHeight/2 ,所以我们的期望是让A往下滚动,然后屏幕上显示为A,B,C ,
所以我们期望的是smoothScrollTo(0,0),也就是:

WheelView.this.post(new Runnable() {
    @Override
    public void run() {
        WheelView.this.smoothScrollTo(0, initialY - remainder);
        ....
        ....
        ....
    }
});

没错。既然多了20,而且我们因为不需要滚动,直接把这个20减掉不就好了么。所以直接就是initailY - remainder


中间的红线部分:

大家看见我上面的图中,有二根红线。我不是故意把中间的一项给标记出来,用二根红线给提示下,而是因为美工设计的时候说,默认中间的是第一项,而且是中间红线包裹的地方才表示这一项处于选中状态。因为本来默认的肯定是:

默认的ScrollView 显示
默认的ScrollView 显示

项目需求
项目需求

不过既然原理我们上面都懂了,我们还慌啥,其实很简单,比如这个需求,有屏幕上有三个Item,默认是中间那个,我们只需要在A的前面多加一个空数据,在尾巴处也多加一个空数据,即:[空数据,A,B,C,D,E,空数据]。这样ScrollView刚初始化好的时候,我们的A就处于中间位置了。有人会问为什么最后一个还要一个空数据,因为不然你最后一项E就不能显示到中间红线部分,就无法处于被选中状态。

那如果一个屏幕显示五项,然后中间是选中的项,那就添加二个:
[空数据,空数据,A,B,C,D,E,空数据,空数据]
哈哈,是不是很简单。

所以你们会看到我上面的init方法中的代码:
selectedIndex = divided + offset;类似的有出现过offset的这个值,也就是偏移值,因为我们当前默认的选中的项不是第一项,而是中间这项,比如我们本来的divided是0,也就是屏幕第一个是A,但是当前A要处于中间才是选中装填,我们只要设置offset = 1就可以了。

往ScrollView里面加具体的Item:

上面我们已经讲了原理了。现在我们就要往ScrollView中的LinearLayout里面加具体的Item,其实这个更简单了。
我们在外界往我们的自定义ScrollView中传入列表数据,我这里用了普通的字符串:
(代码里面的数据头和尾巴补上偏移值上面刚讲过,大家应该还记得 )

public void setItems(List list) {
    if (null == items) {
        items = new ArrayList();
    }
    items.clear();
    items.addAll(list);

    // 前面和后面补全
    for (int i = 0; i < offset; i++) {
        items.add(0, "");
        items.add("");
    }

    initData();
}


int displayItemCount; // 每页显示的数量
private void initData() {
    displayItemCount = offset * 2 + 1;

    for (String item : items) {
        views.addView(createView(item));
    }
}


private TextView createView(String item) {

    TextView tv = new TextView(context);
    tv.setLayoutParams(new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,dip2px(74)));
    tv.setSingleLine(true);
    tv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 20);
    tv.setText(item);
    tv.setGravity(Gravity.CENTER);
    int padding = dip2px(15);
    tv.setPadding(padding, padding, padding, padding);
    if (0 == itemHeight) {
        itemHeight = dip2px(74);
        Log.d(TAG, "itemHeight: " + itemHeight);
        views.setLayoutParams(new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, itemHeight * displayItemCount));
        LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) this.getLayoutParams();
        this.setLayoutParams(new LinearLayout.LayoutParams(lp.width, itemHeight * displayItemCount));
    }
    return tv;
}

中间的二根红线怎么画出来:

其实很简单,只要对ScrollView设置背景即可,

然后背景的画布上,在Y值为100处画一根线,然后在Y值为200的地方画一根线,也就是:
一根在Y值为:itemHeight offset;
一根在Y值为: itemHeight
(offset+1)即可;

@Override
public void setBackgroundDrawable(Drawable background) {
    if (viewWidth == 0) {
        viewWidth = ((Activity) context).getWindowManager().getDefaultDisplay().getWidth();
    }

    if (null == paint) {
        paint = new Paint();
        paint.setColor(Color.parseColor("#83cde6"));
        paint.setStrokeWidth(dip2px(1f));
    }

    background = new Drawable() {
        @Override
        public void draw(Canvas canvas) {

            canvas.drawLine(viewWidth * 1 / 6, itemHeight * offset, viewWidth * 5 / 6, obtainSelectedAreaBorder()[0], paint);
            canvas.drawLine(viewWidth * 1 / 6, itemHeight * (offset+1), viewWidth * 5 / 6, obtainSelectedAreaBorder()[1], paint);

        }

        @Override
        public void setAlpha(int alpha) {

        }

        @Override
        public void setColorFilter(ColorFilter cf) {

        }

        @Override
        public int getOpacity() {
            return PixelFormat.UNKNOWN;
        }
    };

    super.setBackgroundDrawable(background);
}

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    viewWidth = w;
    setBackgroundDrawable(null);
}

循环滚动:

其实我做的是一个假循环,因为我想到了循环的Banner广告,其实就是在头部加一个最后一页的数据,然后在尾部加第一个的数据,滑到最后的时候,再滑动,然后会跳到第一个。我也是用了这个思路。

还记不记得我们前面为了偏移值,所以多加了空数据,我们就不弄空数据了,直接加真的数据。
[A,B,C,D,E, A,B,C,D,E, A,B,C,D,E]
默认显示的是中间的A-E ,然后每次滑到不是中间的A-E,就默认移动回到中间的A-E就可以了。

然后把滚动的速度减慢:

@Override
public void fling(int velocityY) {
    super.fling(velocityY / 5);
}

结语:

当然我觉得这个假循环也不是最优解。。希望大家能提供思路。谢谢大家,不要喷我。如果不喷。晚些时候我整理下,放上GIF图所示的Demo上来。哈哈。不要喷我。。。