Android实现可拖动的尺子
2017-04-25 12:42 阅读(241)

大家好,这是我的第一篇文章。

其实写博客的想法在去年的这个时候就有了,但是一直没有实践,直到今天,我写了一份2017年规划(这规划是迟了一点),写博客就在规划之中。通过写博客记录自己的心得、练习写作能力and大家相互交流一哈。

好了,废话不多说。下面开始本篇的正题。先看效果图

这里写图片描述

效果比较简单,我没有画刻度值,我们主要讲的是其实现原理。 
我们的突破点有:

只有蓝色矩形块之内的文字是白色的,一个文字可能有一半是白色 
手指离开屏幕后,会以一定速度继续滑动 
当滑出边界时,有回弹效果 
如何通过滑动的距离获得刻度值

文字颜色的处理

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        drawLines(canvas);
        drawRect(canvas);
        drawTexts(canvas);
    }

onDraw方法中,drawLines是画两条横线,drawRect是画中间的蓝色矩形,这两个方法在这里就不多说了,比较简单。我们看以下drawTexts方法

private void drawTexts(Canvas canvas) {
        // 默认选中最小值,成员变量mTranslation维护滑动以及text显示位置
        Rect bounds = new Rect();
        float startX = getMeasuredWidth() * 1f / 2;//+ mTextSpace;
        for (int j = minValue; j <= maxValue; j++) {
            String str = j + "";
            mPaints[0].getTextBounds(str, 0, str.length(), bounds);
            float width = bounds.width();
            float height = bounds.height();
            drawText(canvas, str, startX - width / 2 - mTranslation, startX + width / 2 - mTranslation, getMeasuredHeight() * 1f / 2 + height / 2);
            startX += mTextSpace;
        }
    }

在drawTexts中,临时变量bounds作用是计算文字的长度和高度,使用方法如下:

Rect bounds = new Rect();
mPaints[0].getTextBounds(str, 0, str.length(), bounds);
int textWidth = bounds.width();
int textHeight = bounds.height();

因为默认选中最小值,所以for循环从最小值开始画,每画一次startX自加mTextSpace,mTextSpace是文字中心距。画文字调用私有方法private void drawText(Canvas canvas, String text, float textLeft, float textRight, float textBottom),在看drawText方法之前先看看如何实现一个文字两种颜色。

原理就是一个文字画两次,比如要画文字”擦”,我们想让”擦”的左半边是蓝色,右半边是红色,我们只需要先用蓝色的画笔画”擦”,并且通过调用canvas的clipRect方法让其只显示左边边,然后用红色画笔再画一遍,调用canvas的clipRect方法让其只显示右半边。

这里写图片描述

这里再贴一个简单例子:

String str = "我是谁,我来自哪里?";

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        Rect bounds = new Rect();
        mPaints[0].getTextBounds(str, 0, str.length(), bounds);

        drawLeft(canvas, 0, bounds.width() / 2);
        drawRight(canvas, bounds.width() / 2, getMeasuredWidth());
    }

    private void drawRight(Canvas canvas, int startX, int endX) {
        drawText(canvas, Color.GREEN, startX, endY);
    }

    private void drawLeft(Canvas canvas, int startX, int endY) {
        drawText(canvas, Color.RED, startX, endY);
    }

    private void drawText(Canvas canvas, int color, int startX, 
    int endX) {
        mPaints[0].setColor(color);
        canvas.save(Canvas.CLIP_SAVE_FLAG);
        canvas.clipRect(startX, 0, endX, getMeasuredHeight());
        canvas.drawText(str, 0, getMeasuredHeight() - 10, mPaints[0]);
        canvas.restore();
    }

运行结果如下: 
这里写图片描述

一个文字两种颜色的实现讲完了,回到本篇的主题,看看drawText方法

private void drawText(Canvas canvas, String text, float textLeft, float textRight, float textBottom) {
        // 画带透明度的文字
        if (textLeft >= mRect.right || textRight <= mRect.left) {
            mPaints[0].setColor(0x7dffffff);
            canvas.drawText(text, textLeft, textBottom, mPaints[0]);
        } else {
            if (textLeft < mRect.left && textRight > mRect.left) {
                canvas.save(Canvas.CLIP_SAVE_FLAG);
                mPaints[0].setColor(0x7dffffff);
                canvas.clipRect(textLeft, 0, mRect.left, getMeasuredHeight());
                canvas.drawText(text, textLeft, textBottom, mPaints[0]);
                canvas.restore();
                canvas.save(Canvas.CLIP_SAVE_FLAG);
                mPaints[0].setColor(0xffffffff);
                canvas.clipRect(mRect);
                canvas.drawText(text, textLeft, textBottom, mPaints[0]);
                canvas.restore();
            } else if (textLeft < mRect.right && textRight > mRect.right) {
                canvas.save(Canvas.CLIP_SAVE_FLAG);
                mPaints[0].setColor(0xffffffff);
                canvas.clipRect(mRect);
                canvas.drawText(text, textLeft, textBottom, mPaints[0]);
                canvas.restore();

                canvas.save(Canvas.CLIP_SAVE_FLAG);
                mPaints[0].setColor(0x7dffffff);
                canvas.clipRect(mRect.right, 0, textRight + 10, getMeasuredHeight());
                canvas.drawText(text, textLeft, textBottom, mPaints[0]);
                canvas.restore();
            } else {
                mPaints[0].setColor(0xffffffff);
                canvas.drawText(text, textLeft, textBottom, mPaints[0]);
            }
        }
    }

这里贴出了drawText的所有代码,代码比较长,但逻辑比较简单,先从第一个if看起 
第一个if (textLeft >= mRect.right || textRight <= mRect.left){…}表示文字在矩形之外,mRect是矩形的区域。如果是在矩形之外,给画笔设置颜色为非纯白,画一次text。else表示文字在矩形之内,这里面又分为三种情况。第一,文字左半边在矩形之外,右半边在矩形之内(左外右内);第二,文字左半边在矩形之内,右半边在矩形之外(左内右外),第三,文字全部在矩形之内(内内)。


左外右内:if (textLeft < mRect.left && textRight > mRect.left) {…} 
先画一遍暗色文字,裁剪区域为文字的左侧到矩形框的左侧,再画一遍亮色文字,裁剪区域为矩形框左侧到文字右侧; 
左内右外:else if(textLeft < mRect.right && textRight > mRect.right){…} 
先画一遍亮色文字,裁剪区域为文字左侧到矩形框右侧,再画一遍暗色文字,裁剪区域为矩形框右侧到文字右侧; 
内内:else{…} 文字全部在矩形框内,只需要画一遍亮色文字 


运行效果应该是这个样子的,但是还不能滑动 
这里写图片描述

滑动距离和实际值的计算

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        initRect();
        mValuePrePixel = 1f / mTextSpace;
    }

mValuePrePixel表示每个像素代表的值,在本例中,mTextSpace为文字中心距,而两个文字之前的实际值是1,所以mValuePrePixel = 1f / mTextSpace;那么计算当前值就是

mCurrValue = minValue + mTranslation * mValuePrePixel;

上面提到,mTranslation维护滑动。那么,在滑动过程中,当手指向左滑时,mTranslation = 手指滑过的距离,当手指向右滑时,mTranslation = - 手指滑过的距离,当前值通过上面的公式可以计算。

支持滑动和惯性滑动

// 手指按下时的x位置
    private float down_x;
    // 记录手指按下时mTranslation的值
    float tempTranslation = 0;

    public boolean onTouchEvent(MotionEvent event) {
        mGestureDetector.onTouchEvent(event);
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                down_x = event.getX();
                tempTranslation = mTranslation;
                break;
            case MotionEvent.ACTION_MOVE:
                float x = event.getX();
                float temp = down_x - x;
                // 这个2表示最多可以让滑动超过2个值的长度
                if (tempTranslation + temp > (maxValue - minValue + 2) / mValuePrePixel || tempTranslation + temp < + -2 / mValuePrePixel) {
                    return false;
                }
                mTranslation = tempTranslation + temp;
                mCurrValue = minValue + mTranslation * 
                mValuePrePixel;
                if (mListener != null) {
mListener.onValueChangedOnscrolling((int(mCurrValue * 10) / 10f);
                }
                invalidate();
                break;
            case MotionEvent.ACTION_UP:
                checkIsOverScan();
                break;

        }
        return super.onTouchEvent(event);
    }
mGestureDetector.onTouchEvent(event);是接管滑动事件,处理惯性滑动,这个稍后再看,先看看onTouchEvent中都做了什么

1.一般滑动

在Down(手指按下的一瞬间)中,记录手指按下的x值和当前mTranslation值;

在Move(手指滑动)中,计算滑过的距离temp,通过temp计算mTranslation的变化以及mCurrValue的值。

在Up中,检查当手指抬起时,当前选中的值如果小于最小值,或者大于最大值,就应该让尺子回滚到最小值或者最大值。在checkIsOverScan()中实现。

2.惯性滑动

public SpeedInclineView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initPaints();
        setFocusable(true);
        setLongClickable(true);
        mGestureDetector = new GestureDetector(getContext(), new GestureDetector.SimpleOnGestureListener() {
            @Override
            public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
                // 手指向左滑是负的,手指向右滑是正的
                if (Math.abs(velocityX) > 2000f) {
                    if (velocityX > 0) {
                        mVelocityX = 2000f;
                    } else {
                        mVelocityX = -2000f;
                    }
                } else {
                    mVelocityX = velocityX;
                }
                postDelayed(mScrollRunnable, 10);
                return true;
            }
        });
        mGestureDetector.setIsLongpressEnabled(false);
    }
Runnable mScrollRunnable = new Runnable() {
        @Override
        public void run() {
            float len = mVelocityX / 100;
            if (mVelocityX > 0) {
                if (mVelocityX > 30) {
                    mVelocityX -= 30f;
                } else {
                    mVelocityX = 0;
                }
            } else {
                if (mVelocityX < -30f) {
                    mVelocityX += 30f;
                } else {
                    mVelocityX = 0;
                }
            }
            mTranslation -= len;
            mCurrValue -= len * mValuePrePixel;
            if (mListener != null) {
                mListener.onValueChangedOnscrolling((int) (mCurrValue * 10) / 10f);
            }
            invalidate();
            if (mVelocityX != 0 && !mIsCheck && mCurrValue > minValue - 2 && mCurrValue < maxValue + 2) {
                postDelayed(mScrollRunnable, 10);
            } else {
                mVelocityX = 0;
                checkIsOverScan();
            }
        }
    };

在构造方法中创建GestureDetector对象,重写onFling方法,当手指离开屏幕,并且在x轴或者y轴方向速度不为0时,回调onFling方法。在onFling中,我们让速度最大值为2000,在mScrollRunnable的run方法中,len = mVelocityX/100作为惯性滑过的像素,如果mVelocityX为正值,说明手指是向右滑动,每次给mVelocityX减30,mTranslation应该变小,相反,每次执行run方法mVelocityX加30,mTranslation应该增大, mValuePrePixel为每个像素代表的值,len * mValuePrePixel即为实际值的增量。最后再刷新View并且延迟10ms继续执行mScrollRunnable,直到不满足下面这个条件

if (mVelocityX != 0 && !mIsCheck && mCurrValue > minValue - 2 && mCurrValue < maxValue + 2)

mVelocityX 表示横向速度,mIsCheck表示checkIsOverScan()是否正在执行,mCurrValue > minValue - 2 和 mCurrValue < maxValue + 2表示当前值超过边界两个值了。

当不满足这个条件时,调用checkIsOverScan()方法,检查和处理超出边界两个实际值的情况(回滚)

超出边界后回滚

/**
     * 判断是否超过边界,如果超过需要做处理
     */
    private void checkIsOverScan() {
        // 需要对 超过最大值或者小于最小值做处理,让其回弹到最大值或者最小值
        if (mTranslation > (maxValue - minValue) / mValuePrePixel) {
            mIsCheck = true;
            ValueAnimator anim = ValueAnimator.ofFloat(mTranslation, (maxValue - minValue) / mValuePrePixel);
            anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    mTranslation = (float) animation.getAnimatedValue();
                    invalidate();
                }
            });
            anim.addListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {
                    super.onAnimationEnd(animation);
                    if (mListener != null) {
                        mListener.onValueChangedOnScrollEnd(maxValue);
                    }
                    mIsCheck = false;
                }
            });
            anim.setDuration(300);
            anim.start();
        } else if (mTranslation < 0) {
            mIsCheck = true;
            ValueAnimator anim = ValueAnimator.ofFloat(mTranslation, 0);
            anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    mTranslation = (float) animation.getAnimatedValue();
                    invalidate();
                }
            });
            anim.addListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {
                    super.onAnimationEnd(animation);
                    if (mListener != null) {
                        mListener.onValueChangedOnScrollEnd(minValue);
                    }
                    mIsCheck = false;
                }
            });
            anim.setDuration(300);
            anim.start();
        } else {
            mIsCheck = false;
            if (mListener != null && mVelocityX == 0) {
                mListener.onValueChangedOnScrollEnd((int) (mCurrValue * 10) / 10f);
            }
        }
    }

这部分比较简单,第一个if表示mTranslation超过了mTranslation的最大值,开启属性动画,让mTranslation从当前值变化到最大实际值对应的mTranslation,第二个if表示mTranslation小于最小实际值对应的mTranslation,开启属性动画,让mTranslation从当前mTranslation变化到最小实际值对应的mTranslation,最后的else表示mTranslation没有超出边界,不做处理。

通过设置当前值,让尺子滚动到指定位置

比如设置的实际值为value,那么目标mTranslation的值应该为float dstTranslation = (value - minValue) * mTextSpace; 那么思路就很明确了,跟上面checkIsOverScan()方法中的原理一样。还是开启一个属性动画,让mTranslation从当前值变化到dstTranslation,下面贴出代码

public void setTranslation(float translation) {
        mTranslation = translation;
        invalidate();
    }

    public float getTranslation() {
        return mTranslation;
    }

    /**
     * 设置当前值,让数字滚动到指定位置
     *
     * @param value
     */
    public void setValue(float value) {
        if (value < minValue || value > maxValue) {
            return;
        }
        float srcTranslation = mTranslation;
        float dstTranslation = (value - minValue) * mTextSpace;
        ObjectAnimator.ofFloat(this, "translation", srcTranslation, dstTranslation).setDuration(200).start();
    }

可以看到这里我们使用ObjectAniator,使用ObjectAnimator代码简洁了许多,但是需要注意,使用ObjectAnimator需要提供对应的set属性方法,比如这里操作的属性是translation,那么必须提供public void setTranslation(float translation)方法,如果我们的setValue方法向下面这样写,还需要提供get属性方法

public void setValue(float value) {
        if (value < minValue || value > maxValue) {
            return;
        }
        float dstTranslation = (value - minValue) * mTextSpace;
        ObjectAnimator.ofFloat(this, "translation",dstTranslation).setDuration(200).start();
    }

这种写法会默认通过get方法获取当前mTranslation作为动画开始值。 
这里建议无论哪种写法,最好同时提供get、set方法。

最后,再贴上回调

public interface OnValueChangeListener {
        /**
         * 滑动时value的变化
         */
        void onValueChangedOnMove(float value);

        /**
         * 手指抬起来时 value的值
         *
         * @param value
         */
        void onValueChangedOnUp(float value);
    }
    public void setOnValueChangeListener(OnValueChangeListener l) {
        mListener = l;
    }

通过给View设置OnValueChangeListener来监听值的变化。

使用

mTextView = (TextView) findViewById(R.id.text);
        mSpeedInclineView = (SpeedInclineView) findViewById(R.id.speed_incline_view);
        mSpeedInclineView.setOnValueChangeListener(new SpeedInclineView.OnValueChangeListener() {
            @Override
            public void onValueChangedOnScrolling(float value) {
            }

            @Override
            public void onValueChangedOnScrollEnd(float value) {
                mTextView.setText(value + "");
            }
        });

回调中value的值保留小数点后一位,前面的代码中也能看得出来,使用很简单就不多说了。这里再多说一点,如何让View只选中整数呢?其实也很简单,看代码

mSpeedInclineView.setOnValueChangeListener(new SpeedInclineView.OnValueChangeListener() {
            @Override
            public void onValueChangedOnScrolling(float value) {
            }

            @Override
            public void onValueChangedOnScrollEnd(float value) {
                int inclineValue = (int) (value + 0.5f);
                mSpeedInclineView.setValue(inclineValue);
                mTextView.setText(inclineValue + "");
            }
        });

int inclineValue = (int) (value + 0.5f)取当前值最接近的整数 
mSpeedInclineView.setValue(inclineValue);让view滚动到该整数位 
mTextView.setText(inclineValue + “”);设置TextView文字

好了,断断续续终于写完了,中途Chrome一直挂不知道什么原因,重启电脑还是不行,无奈只能切换到Safari了。百度代码太长如何让代码滚动,结果搜出来的结果都是如何让代码不滚动。



作者:kriouky