博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
Android自定义View之区块选择器
阅读量:7217 次
发布时间:2019-06-29

本文共 20395 字,大约阅读时间需要 67 分钟。

效果

先来看下效果吧:

我们来分析这个view需要实现哪些效果。

  • 首先它有一个刻度尺代表了时间段(也可以是别的什么),并且可以看到完整的刻度尺是比屏幕宽度大的,因此肯定需要可以左右滑动。
  • 其次,可以有不可选的区域(gif中灰色块)和选中的区域(gif中蓝色块),点击刻度的空白位置出现或者移动选中区域到点击位置。
  • 点击并拖动选中的区域可以移动,当移动到屏幕两边的时候,下层的刻度也能跟着移动。
  • 还可以点击并拖动选中区域右边的白色小圆改变选中区域的大小,同样到达屏幕边界时下层刻度跟着移动。
  • 当选中区域与不可选区域重叠时,选中区域变色。
  • 选中区域最小为1个刻度,当移动后手指抬起时,选中区域贴合刻度。
  • 最后还需要监听一些状态的变化,如是否重叠,选中区域改变的位置。

实现

刻度尺

别害怕有这么多的功能,我们一个一个来实现。首先是刻度尺,这个简单。由于完整的刻度尺是比屏幕宽度大的,因此我们先来了解几个概念:

这里手机屏幕的宽度是width,刻度尺的宽度的时maxWidth,我们其实只需要绘制手机屏幕可见的部分就可以了,这里的offset表示手机屏幕的左边与刻度尺左边的偏移量。

了解了这个概念,我们就来开始写吧,定义一个View,处理下构造都指向3个参数的那个,然后统一做初始化:

public class SelectView extends View {    private final int DEFAULT_HEIGHT = dp2px(100);//wrap_content高度    private Paint mPaint;    public int dp2px(final float dpValue) {        final float scale = getContext().getResources().getDisplayMetrics().density;        return (int) (dpValue * scale + 0.5f);    }    public SelectView(Context context) {        this(context, null);    }    public SelectView(Context context, @Nullable AttributeSet attrs) {        this(context, attrs, 0);    }    public SelectView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {        super(context, attrs, defStyleAttr);        scroller = new OverScroller(context);        init();    }    private void init() {        mPaint = new Paint();        mPaint.setAntiAlias(true);        mPaint.setTextSize(textSize);    }    @Override    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {        int widthMode = MeasureSpec.getMode(widthMeasureSpec);        int widthSize = MeasureSpec.getSize(widthMeasureSpec);        width = widthSize;        int heightMode = MeasureSpec.getMode(heightMeasureSpec);        int heightSize = MeasureSpec.getSize(heightMeasureSpec);        if (heightMode == MeasureSpec.EXACTLY) {height = heightSize;        } else {height = DEFAULT_HEIGHT;//wrap_content的高        }        setMeasuredDimension(width, height);    }}复制代码

我们在onMeasure中处理了wrap_content的高度。然后在onSizeChanged中获取尺寸参数:

private int width;//控件宽度    private int height;//控件高度    private int maxWidth;//最大内容宽度    private int totalWidth;//刻度整体宽度(最后一个刻度的文字在刻度外)    private int minOffset = 0;    private int maxOffset;    private int offset = minOffset;//可视区域左边界与整体内容左边界的偏移量    @Override    protected void onSizeChanged(int w, int h, int oldw, int oldh) {        super.onSizeChanged(w, h, oldw, oldh);        totalWidth = titles.length * space;        maxWidth = totalWidth - space;        maxOffset = totalWidth - width;        if (maxOffset < 0) {maxOffset = 0;        }        areaTop = (1 - areaRate) * height;    }复制代码

接着就开始绘制吧:

private String[] titles = {
"09:00", "09:30", "10:00", "10:30", "11:00","11:30", "12:00", "12:30", "13:00", "13:30","14:00", "14:30", "15:00", "15:30", "16:00","16:30", "17:00", "17:30", "18:00"}; private int space = dp2px(40);//刻度间隔 private int lineWidth = dp2px(1);//刻度线的宽度 private int textSize = dp2px(12); private int textMargin = dp2px(8);//文字与长刻度的margin值 private int rate = 1; //短刻度与长刻度数量的比例(>=1) private float lineRate = 0.4f;//短刻度与长刻度长度的比例(0.0~1.0) @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); drawLine(canvas); } private void drawLine(Canvas canvas) { mPaint.setStyle(Paint.Style.STROKE); mPaint.setStrokeWidth(lineWidth); mPaint.setColor(Color.BLACK); canvas.drawLine(0, height, width, height, mPaint); for (int i = 0; i < titles.length; i++) {int position = i * space;if (position >= offset && position <= offset + width) {//判断是否可以显示在屏幕中 int x = position - offset; if (i % (rate + 1) == 0) {//绘制长刻度 canvas.drawLine(x, 0, x, height, mPaint); mPaint.setStyle(Paint.Style.FILL); canvas.drawText(titles[i], x + textMargin, textSize, mPaint); mPaint.setStyle(Paint.Style.STROKE); } else {//绘制短刻度 canvas.drawLine(x, height * (1 - lineRate), x, height, mPaint); }} } }复制代码

这里的titles代表了刻度的标识,每一个元素代表一个刻度(这里我字节写死了,实际上可以通过方法set,也不一定是时间,能代表刻度的都可以)。通过rate设置长短刻度的比例,这里我设置了1:1。运行一下看看,目前仅仅能看到从0开始,看不到完整的刻度尺,我们需要实现touch事件产生移动才有效果。

实现滑动刻度尺

我们重写onTouchEvent来实现滑动效果:

private float downX, downY;    private float lastX;//滑动上一个位置    @Override    public boolean onTouchEvent(MotionEvent event) {        int action = event.getAction();        switch (action) {case MotionEvent.ACTION_DOWN:    downX = event.getX();    lastX = downX;    break;case MotionEvent.ACTION_MOVE:    float x = event.getX();    float dx = x - lastX;    changeOffsetBy(-dx);    lastX = x;    postInvalidate();    break;case MotionEvent.ACTION_UP:case MotionEvent.ACTION_CANCEL:    break;default:    break;        }        return true;    }    private void changeOffsetBy(float dx) {        offset += dx;        if (offset < minOffset) {offset = minOffset;        } else if (offset > maxOffset) {offset = maxOffset;        }    }复制代码

我们计算出每次move事件的X方向的变化量dx,然后通过这个dx改变offset,并且处理一下边界的情况。然后调用postInvalidate刷新界面。 运行一下看看!现在我们可以滑动刻度尺了。但是好像还有点问题,平时我们使用ScrollView的时候用力划一下,可以看到手指离开了屏幕,但是内容还可以继续滚动。而目前我们自定义的这个view只能通过手指滑动,如果手指离开屏幕就不能滑动了。这样的体验显然不够好,我们来实现这个惯性滑动的效果吧!

实现惯性滑动

要实现惯性滑动,我们需要用到两个类:VelocityTracker,OverScroller。 VelocityTracker简介 view滑动助手类OverScroller

private int minFlingVelocity;//最小惯性滑动速度    private VelocityTracker velocityTracker;    private OverScroller scroller;    private int lastFling;//惯性滑动上一个位置    private void init(Context context) {        ...        scroller = new OverScroller(context);        minFlingVelocity = ViewConfiguration.get(getContext()).getScaledMinimumFlingVelocity();    }    @Override    public boolean onTouchEvent(MotionEvent event) {        if (velocityTracker == null) {velocityTracker = VelocityTracker.obtain();        }        velocityTracker.addMovement(event);         int action = event.getAction();        switch (action) {case MotionEvent.ACTION_DOWN:    scroller.forceFinished(true);    downX = event.getX();    lastX = downX;    break;case MotionEvent.ACTION_MOVE:    float x = event.getX();    float dx = x - lastX;    changeOffsetBy(-dx);    lastX = x;    postInvalidate();    break;case MotionEvent.ACTION_UP:case MotionEvent.ACTION_CANCEL:     //处理惯性滑动    velocityTracker.computeCurrentVelocity(1000, 8000);    float xVelocity = velocityTracker.getXVelocity();    if (Math.abs(xVelocity) > minFlingVelocity) {        scroller.fling(0, 0, (int) xVelocity, 0, Integer.MIN_VALUE,Integer.MAX_VALUE, 0, 0);    }    velocityTracker.clear();    break;default:    break;        }        return true;    }    @Override    public void computeScroll() {        if (scroller.computeScrollOffset()) {int currX = scroller.getCurrX();float dx = currX - lastFling;//已经在边界了,不再处理惯性if ((offset <= minOffset && dx > 0) || offset >= maxOffset && dx < 0) {    scroller.forceFinished(true);    return;}changeOffsetBy(-dx);lastFling = currX;postInvalidate();        } else {lastFling = 0;//重置上一次值,避免第二次惯性滑动计算错误的dx        }    }复制代码

velocityTracker.computeCurrentVelocity方法的第二个参数表示最大惯性速度,这里我设置8000,避免刻度尺过快的滑动。通过调用scroller.fling方法将计算出的速度交给scroller,然后在computeScroll方法中获取当前值,并与上一次的值做差算出变化量dx,同样用这个dx变化offset刷新界面实现滑动效果。

不可选区域

刻度尺完成了,接下来是不可选的灰色区域。我采用两个int值表示在刻度尺的区域,刻度尺的每个刻度表示一个最小单位,前一个int表示在刻度尺的起始位置,后一个int表示占据的刻度数量。

private List
unselectableList = new ArrayList<>(); private List
unselectableRectFs = new ArrayList<>(); private RectF tempRect = new RectF(); @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); drawLine(canvas); drawUnselectable(canvas); } private void drawUnselectable(Canvas canvas) { generateUnselectableRectFs(); mPaint.setStyle(Paint.Style.FILL); mPaint.setColor(Color.parseColor("#99878787")); for (RectF rectF : unselectableRectFs) {float left = Math.max(rectF.left, offset) - offset;float right = Math.min(rectF.right, offset + width) - offset;tempRect.set(left, rectF.top, right, rectF.bottom);canvas.drawRect(tempRect, mPaint); } } private void generateUnselectableRectFs() { //避免重复生成 if (unselectableRectFs.size() > 0 && unselectableList.size() == unselectableRectFs.size()) {return; } unselectableRectFs.clear(); for (int[] ints : unselectableList) {int start = ints[0];int count = ints[1];int max = titles.length - 1;if (start > max || start + count > max) { throw new IllegalArgumentException("unselectable area has wrong start or count, " +"the total limit is" + max);}if (count > 0) { unselectableRectFs.add(new RectF(start * space, areaTop,(start + count) * space, height));} } } public void addUnseletable(int start, int count) { unselectableList.add(new int[]{start, count}); postInvalidate(); }复制代码

我用一个list存放设置的不可选区域,然后在另一个list中存放转换成RectF的位置信息。这里的RectF是在相对于整体刻度尺而言的,因此绘制到屏幕的时候需要减去offset,并且需要考虑只有部分在屏幕可见的情况。避免在onDraw方法中创建过多临时变量,我声明一个成员变量tempRect,用来保存绘制时的临时参数。

可选区域

完成了不可选区域,可选区域也是同样的。由于只能有一个可选区域,我们只需要定义一个RectF。额外需要考虑与不可选区域相交时会变色,我定了一个overlapping表示是否相交,通过RectF的intersects方法判断。

private int selectedBgColor = Color.parseColor("#654196F5");    private int selectedStrokeColor = Color.parseColor("#4196F5");    private int overlappingBgColor = Color.parseColor("#65FF6666");    private int overlappingStrokeColor = Color.parseColor("#FF6666");    private int selectedStrokeWidth = dp2px(2);    private int extendRadius = dp2px(7);//扩展圆的半径    private float extendTouchRate = 1.5f;//扩展触摸区域与视图的比率(>=1)    private boolean overlapping;//是否覆盖unselectable    private RectF selectedRectF;//选择区域位置    private RectF extendPointRectF;//扩展点位置    @Override    protected void onDraw(Canvas canvas) {        super.onDraw(canvas);        drawLine(canvas);        drawUnselectable(canvas);        drawSelected(canvas);    }    private void drawSelected(Canvas canvas) {        if (selectedRectF == null) {return;        }        overlapping = checkOverlapping();        float left = Math.max(selectedRectF.left, offset) - offset;        float right = Math.min(selectedRectF.right, offset + width) - offset;        tempRect.set(left, selectedRectF.top, right, selectedRectF.bottom);        //填充        mPaint.setStyle(Paint.Style.FILL);        mPaint.setColor(overlapping ? overlappingBgColor : selectedBgColor);        canvas.drawRect(tempRect, mPaint);        //边框        mPaint.setStyle(Paint.Style.STROKE);        mPaint.setStrokeWidth(selectedStrokeWidth);        mPaint.setColor(overlapping ? overlappingStrokeColor : selectedStrokeColor);        canvas.drawRect(tempRect, mPaint);        if ((selectedRectF.right - offset) == right) {//扩展圆边框mPaint.setColor(overlapping ? overlappingStrokeColor : selectedStrokeColor);canvas.drawCircle(tempRect.right, tempRect.centerY(), extendRadius, mPaint);//扩展圆填充mPaint.setColor(Color.WHITE);mPaint.setStyle(Paint.Style.FILL);canvas.drawCircle(tempRect.right, tempRect.centerY(), extendRadius, mPaint);//扩展圆的位置信息,处理touch事件需要extendPointRectF = new RectF(selectedRectF.right - extendRadius * extendTouchRate,        selectedRectF.centerY() - extendRadius * extendTouchRate,        selectedRectF.right + extendRadius * extendTouchRate,        selectedRectF.centerY() + extendRadius * extendTouchRate);        } else {extendPointRectF = null;        }    }    private boolean checkOverlapping() {        if (selectedRectF != null) {for (RectF rectF : unselectableRectFs) {    if (rectF.intersects(selectedRectF.left, selectedRectF.top,selectedRectF.right, selectedRectF.bottom)) {        return true;    }}        }        return false;    }复制代码

点击,移动,扩展

通过前面的分析,我们知道这个view中的事件有很多种:点击,移动刻度尺,移动选中区域,扩展选中区域。我们定义这四种类型便于后续的事件处理:

public static final int TYPE_MOVE = 1;    public static final int TYPE_EXTEND = 2;    public static final int TYPE_CLICK = 3;    public static final int TYPE_SLIDE = 4;复制代码

然后改造一下onTouchEvent:

private boolean linking;//是否正在联动    private Handler handler = new BookHandler(this);    private int boundary = space / 2;//屏幕边界范围    private static class BookHandler extends Handler {        private static final int DELAY_MILLIS = 10;//刷新率(0~16)        private WeakReference
selectViewWeakReference; BookHandler(SelectView selectView) {super();selectViewWeakReference = new WeakReference<>(selectView); } @Override public void handleMessage(Message msg) {SelectView view = selectViewWeakReference.get();if (view != null) { float dx = (float) msg.obj; view.changeOffsetBy(dx); if (msg.what == MESSAGE_EXTEND) { float r = view.selectedRectF.right + dx; view.resetSelectedRight(r); } else if (msg.what == MESSAGE_MOVE) { float l = view.selectedRectF.left + dx; float r = view.selectedRectF.right + dx; view.resetSelectedRectF(l, r); } view.postInvalidate(); if (view.linking) { sendMessageDelayed(Message.obtain(msg), DELAY_MILLIS); }} } } @Override public boolean performClick() { return super.performClick(); } @Override public boolean onTouchEvent(MotionEvent event) { if (velocityTracker == null) {velocityTracker = VelocityTracker.obtain(); } velocityTracker.addMovement(event); int action = event.getAction(); switch (action) {case MotionEvent.ACTION_DOWN: scroller.forceFinished(true); downX = event.getX(); lastX = downX; downY = event.getY(); checkTouchType(); break;case MotionEvent.ACTION_MOVE: float x = event.getX(); float dx = x - lastX; if (touchType == TYPE_EXTEND) { handleExtend(dx); } else if (touchType == TYPE_MOVE) { handleMove(dx); } else if (touchType == TYPE_SLIDE) { changeOffsetBy(-dx); } lastX = x; postInvalidate(); break;case MotionEvent.ACTION_UP:case MotionEvent.ACTION_CANCEL: float upX = event.getX(); float upY = event.getY(); if (Math.abs(upX - downX) < touchSlop && Math.abs(upY - downY) < touchSlop) { touchType = TYPE_CLICK; performClick(); } handleActionUp(upX); break;default: break; } return true; } private void checkTouchType() { RectF extend = null; if (extendPointRectF != null) {extend = new RectF(extendPointRectF.left - offset, extendPointRectF.top, extendPointRectF.right - offset, extendPointRectF.bottom);Timber.i("extend:" + extend.toString()); } RectF selected = null; if (selectedRectF != null) {selected = new RectF(selectedRectF.left - offset, selectedRectF.top, selectedRectF.right - offset, selectedRectF.bottom);Timber.i("selected:" + selected.toString()); } if (extend != null && extend.contains(lastX, downY)) {touchType = TYPE_EXTEND; } else if (selected != null && selected.contains(lastX, downY)) {touchType = TYPE_MOVE; } else {touchType = TYPE_SLIDE; } } private void handleExtend(float dx) { //如果正在联动时,避免手指抖动造成不必要停止 if (linking && Math.abs(dx) < touchSlop) {return; } float right = selectedRectF.right += dx; //下层联动 Message message = null; if (dx > 0 && width - (right - offset) < boundary //选中区域滑到屏幕右边 && offset < maxOffset) {message = handler.obtainMessage(MESSAGE_EXTEND, linkDx); } else if (dx < 0 && right > selectedRectF.left && right - offset < boundary && offset > minOffset) {message = handler.obtainMessage(MESSAGE_EXTEND, -linkDx); } if (message != null) {if (!linking) { linking = true; handler.sendMessage(message);} } else {stopLinking();resetSelectedRight(right); } } private void handleMove(float dx) { //如果正在联动时,避免手指抖动造成不必要停止 if (linking && Math.abs(dx) < touchSlop) {return; } float left = selectedRectF.left += dx; float right = selectedRectF.right += dx; Message message = null; if ((dx < 0 && left - offset < boundary && offset > minOffset)) {//选中区域滑到屏幕左边并继续向左滑动message = handler.obtainMessage(MESSAGE_MOVE, -linkDx); } else if (dx > 0 && width - (right - offset) < boundary && offset < maxOffset) {//选中区域滑到屏幕右边并且继续向右滑动message = handler.obtainMessage(MESSAGE_MOVE, linkDx); } Timber.e("message:" + message); if (message != null) {//处在两边界,需要联动if (!linking) { linking = true; handler.sendMessage(message);} } else {stopLinking();resetSelectedRectF(left, right); } } private void handleActionUp(float upX) { if (touchType == TYPE_CLICK) {int start = (int) ((upX + offset) / space);int[] area = getSelected();setSelected(start, area == null ? CLICK_SPACE : area[1]); } else if (touchType == TYPE_EXTEND) {stopLinking();int right = Math.round(selectedRectF.right / space) * space;resetSelectedRight(right);postInvalidate(); } else if (touchType == TYPE_MOVE) {stopLinking();int[] area = getSelected();if (area != null) { setSelected(area[0], area[1]);} } else if (touchType == TYPE_SLIDE) {//处理惯性滑动velocityTracker.computeCurrentVelocity(1000, 8000);float xVelocity = velocityTracker.getXVelocity();if (Math.abs(xVelocity) > minFlingVelocity) { scroller.fling(0, 0, (int) xVelocity, 0, Integer.MIN_VALUE,Integer.MAX_VALUE, 0, 0);}velocityTracker.clear(); } } private void stopLinking() { linking = false; handler.removeCallbacksAndMessages(null); } /** * 重置选择区域的位置 * * @param left * @param right */ private void resetSelectedRectF(float left, float right) { if (left < 0) {left = 0;right = selectedRectF.right - selectedRectF.left; } if (right > maxWidth) {right = maxWidth;left = maxWidth - (selectedRectF.right - selectedRectF.left); } int minSpace = minSelect * space; if (right - left < minSpace) {//最小值if (maxWidth - selectedRectF.left < minSpace) { right = maxWidth; left = maxWidth - minSpace;} else { right = selectedRectF.left + minSpace;} } selectedRectF.left = left; selectedRectF.right = right; } /** * 重置选择区域的right * * @param right */ private void resetSelectedRight(float right) { if (right > maxWidth) {right = maxWidth; } int minSpace = minSelect * space; if (right - selectedRectF.left < minSpace) {//最小值if (maxWidth - selectedRectF.left < minSpace) { right = maxWidth; selectedRectF.left = maxWidth - minSpace;} else { right = selectedRectF.left + minSpace;} } selectedRectF.right = right; } /** * 将选择内容转换成区域 * * @param start 开始位置 * @param count 数量 */ public void setSelected(int start, int count) { if (start > titles.length - 1) {throw new IllegalArgumentException("wrong start"); } int right = (start + count) * space; if (right > maxWidth) {//int cut = Math.round((right - maxWidth) * 1f / space);//start -= cut;//整体向左移动right = maxWidth; } int left = start * space; if (selectedRectF == null) {selectedRectF = new RectF(left, areaTop, right, height);if (selectChangeListener != null) { selectChangeListener.onSelected();} } else {selectedRectF.set(left, areaTop, right, height); } notifySelectChangeListener(start, count); postInvalidate(); } /** * 将选中区域转换成选择内容 * * @return [start, count] */ public int[] getSelected() { if (selectedRectF == null) {return null; } int[] area = new int[2]; float w = selectedRectF.right - selectedRectF.left; area[0] = Math.round(selectedRectF.left / space); area[1] = Math.round(w / space); return area; }复制代码

performClick会在你重写onTouchEvent时as提示你需要重写的方法,因为你可能没有考虑到如果给这个view设置OnClickListener的情况。如果你没有在onTouchEvent中调用performClick,那么setOnClickListener方法就失效了。

你可能注意到这一次比较复杂,并且还有一个linking字段,表示是否正在联动,我解释一下这个联动的概念:通过gif其实你可能注意到,当我移动或者扩展选中区域的时候,如果移动到了屏幕的边界,后面的刻度尺就会跟着移动,实际上这个时候选中区域在屏幕中的位置没有改变,只是刻度尺移动了。一开始我也是通过dx来改变offset,但是存在一个问题,移动到屏幕边缘之后,手指可以移动的区域已经很小了,不会产生足够的dx(手指不移动的话,不会有新的touch事件产生)。最好的体验是我把手机移动到屏幕边缘,刻度尺就会自己按照一定的速率移动直到最大offset或者最小offset。于是我使用了Handler,当满足条件后发送消息,表示开始进行联动,会按照固定速度产生一个dx改变offset。当然,在离开屏幕边缘的时候还需要及时取消handler的任务。

至此,功能基本已经实现了,运行一下看看效果吧~

后面需要做什么那?现在这个view只能自己玩,我需要它与其他view有交互,比如选中什么区域,状态的改变生么的。

状态变化

声明两个接口,并在适当时候回调它们的方法,这样外部就能感知view的状态变化。

public interface OverlappingStateChangeListener {        void onOverlappingStateChanged(boolean isOverlapping);    }    public interface SelectChangeListener {        void onSelected();        void onSelectChanged(int start, int count);    }复制代码

完善

后面的话就是根据业务添加一些api了,例如添加不可选区域,改变刻度范围什么,一切都看需求了。

转载地址:http://yeiym.baihongyu.com/

你可能感兴趣的文章
MySQL 用户连接与用户线程
查看>>
RabbitMq、ActiveMq、Kafka和Redis做Mq对比
查看>>
C# 图片处理(压缩、剪裁,转换,优化)
查看>>
Linux bridge-utils tunctl 使用
查看>>
Leetcode Pascal&#39;s Triangle II
查看>>
运行shell脚本报错 &#39;\357\273\277&#39;: command not found 解决的方法
查看>>
android studio 0.8.1使用和遇到问题解决
查看>>
云服务器ECS选购集锦之六区域选择帮助
查看>>
云虚机选购指南之二云虚拟主机试用帮助文档
查看>>
女友眼中的IT男
查看>>
Excel连接
查看>>
java基础-多线程学习
查看>>
WPF打印原理,自定义打印
查看>>
HTML5 5
查看>>
箭头css
查看>>
Python入门,以及简单爬取网页文本内容
查看>>
顺丰科技笔试回忆
查看>>
excel技巧
查看>>
通用防SQL注入漏洞程序(Global.asax方式)
查看>>
服务器进程为何通常fork()两次
查看>>