继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

Android View 自定义 RangeSeekBar 范围选择器

牧羊人nacy
关注TA
已关注
手记 175
粉丝 5
获赞 26

原文链接

前段时间群里兄弟项目中有类似这样的需求

我看到兄弟受苦受难,于心不忍。又因事不关己,打算高高挂起。正在爱恨纠结之时,日神对我说:没事多造点轮子,你的人生会有很多收获。这波鸡汤让我深受触动,于是决定拯救兄弟于水生火热之中。

重写onMeasure 决策自身大小

显而易见当可以拖拽的范围极限为零时,也就是RangeSeeBar正常显示能够接受的极限,粗略一看:Width > 2 * Height

@Override  protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {  
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);  
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);  
    if (heightSize * 2 > widthSize) {  
        setMeasuredDimension(widthSize, widthSize / 2);  
    } else {  
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);  
    }  
}

绘制拖动条背景 凡事先从简单开始

public class RangeSeekBar extends View {  
    private Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);  

    private int lineTop, lineBottom, lineLeft, lineRight;  
    private int lineCorners;  
    private int lineWidth;  
    private RectF line = new RectF();  

    public RangeSeekBar(Context context) {  
        this(context, null);  
    }  

    public RangeSeekBar(Context context, AttributeSet attrs) {  
        super(context, attrs);  
    }  

    @Override  
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {  
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);  
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);  
        if (heightSize * 2 > widthSize) {  
            setMeasuredDimension(widthSize, (int) (widthSize / 2));  
        } else {  
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);  
        }  
    }  

    @Override  
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {  
        super.onSizeChanged(w, h, oldw, oldh);  
        int seekBarRadius = h / 2;  
        /** 
         * 属性 left right top bottom 描述了SeekBar按钮的位置 
         * 蓝后根据它们预先设置确定出 RectF line 背景的三维 
         * lineCorners 圆滑的边缘似乎会比直角更好看 
         */  
        lineLeft = seekBarRadius;  
        lineRight = w - seekBarRadius;  
        lineTop = seekBarRadius - seekBarRadius / 4;  
        lineBottom = seekBarRadius + seekBarRadius / 4;  
        lineWidth = lineRight - lineLeft;  
        line.set(lineLeft, lineTop, lineRight, lineBottom);  
        lineCorners = (int) ((lineBottom - lineTop) * 0.45f);  
    }  

    @Override  
    protected void onDraw(Canvas canvas) {  
        super.onDraw(canvas);  
        paint.setStyle(Paint.Style.FILL);  
        paint.setColor(0xFFD7D7D7);  
        canvas.drawRoundRect(line, lineCorners, lineCorners, paint);  
    }  
}

很明显这里设计seekBarRadius作为SeekBar按钮的半径,值为RangeSeekBar自身高度一半。那么为了使默认状态的SeekBar按钮圆心能压在背景条的起点和终点

背景条的起点和终点当然就分别相对于自身宽度往内部偏移一个半径咯。

拖动舞台已经备好,SeekBar按钮半径也已定好。顺水推舟,下一步就绘制SeekBar把。

SeekBar按钮 拥有对象是极好的

粗略一想:按钮有颜色、有大小、有变色、被绘制,碰撞检测、边界检测、被拖拽等,最关键的是有多个。因此SeekBar按钮可以说是一个复杂的集合体,是时候来发对象了。

private class SeekBar {  
    int widthSize;  
    int left, right, top, bottom;  
    Bitmap bmp;  

    /** 
     * 当RangeSeekBar尺寸发生变化时,SeekBar按钮尺寸随之变化  
     * 
     * @param centerX    SeekBar按钮的X中心在RangeSeekBar中的相对位置 
     * @param centerY    SeekBar按钮的Y中心在RangeSeekBar中的相对位置 
     * @param heightSize RangeSeekBar期望SeekBar所拥有的高度 
     */  
    void onSizeChanged(int centerX, int centerY, int heightSize) {  
        /** 
         * 属性 left right top bottom 描述了SeekBar按钮的位置<br> 
         * widthSize = heightSize * 0.8f 可见按钮实际区域是个矩形而非正方形 
         * 圆圈按钮为什么要占有矩形区域?因为按钮阴影效果。不要阴影不行吗?我就不 
         * 那么 onMeasure 那边说好的2倍宽度?我就不 
         */  
        widthSize = (int) (heightSize * 0.8f);  
        left = centerX - widthSize / 2;  
        right = centerX + widthSize / 2;  
        top = centerY - heightSize / 2;  
        bottom = centerY + heightSize / 2;  

        bmp = Bitmap.createBitmap(widthSize, heightSize, Bitmap.Config.ARGB_8888);  
        int bmpCenterX = bmp.getWidth() / 2;  
        int bmpCenterY = bmp.getHeight() / 2;  
        int bmpRadius = (int) (widthSize * 0.5f);  
        Canvas defaultCanvas = new Canvas(bmp);  
        Paint defaultPaint = new Paint(Paint.ANTI_ALIAS_FLAG);  
        // 绘制Shadow  
        defaultPaint.setStyle(Paint.Style.FILL);  
        int barShadowRadius = (int) (bmpRadius * 0.95f);  
        defaultCanvas.save();  
        defaultCanvas.translate(0, bmpRadius * 0.25f);  
        RadialGradient shadowGradient = new RadialGradient(bmpCenterX, bmpCenterY, barShadowRadius, Color.BLACK, Color.TRANSPARENT, Shader.TileMode.CLAMP);  
        defaultPaint.setShader(shadowGradient);  
        defaultCanvas.drawCircle(bmpCenterX, bmpCenterY, barShadowRadius, defaultPaint);  
        defaultPaint.setShader(null);  
        defaultCanvas.restore();  
        // 绘制Body  
        defaultPaint.setStyle(Paint.Style.FILL);  
        defaultPaint.setColor(0xFFFFFFFF);  
        defaultCanvas.drawCircle(bmpCenterX, bmpCenterY, bmpRadius, defaultPaint);  
        // 绘制Border  
        defaultPaint.setStyle(Paint.Style.STROKE);  
        defaultPaint.setColor(0xFFD7D7D7);  
        defaultCanvas.drawCircle(bmpCenterX, bmpCenterY, bmpRadius, defaultPaint);  
    }  

    void draw(Canvas canvas) {  
        canvas.drawBitmap(bmp, left, top, null);  
    }  
}
public class RangeSeekBar extends View {  

    private SeekBar seekBar = new SeekBar();  

    private class SeekBar {  
        ...  
    }  

    @Override  
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {  
        super.onSizeChanged(w, h, oldw, oldh);  
        int seekBarRadius = h / 2;  
        ...  
        // 在RangeSeekBar确定尺寸时确定SeekBar按钮尺寸  
        seekBar.onSizeChanged(seekBarRadius, seekBarRadius, h);  
    }  

    @Override  
    protected void onDraw(Canvas canvas) {  
        super.onDraw(canvas);  
        ...  
        // 在RangeSeekBar被绘制时绘制SeekBar按钮  
        seekBar.draw(canvas);  
    }  
}

距离成功又进了一步

onTouchEvent 触摸监听 让SeekBar按钮动起来

@Override  
public boolean onTouchEvent(MotionEvent event) {  
    switch (event.getAction()) {  
        case MotionEvent.ACTION_DOWN:  
            boolean touchResult = false;  
            // 进行检测,手指手指是否落在当前SeekBar上。即声明SeekBar时使用left、top、right、bottom属性所描述区域的内部     
            if (seekbar.collide(event)) {  
                touchResult = true;  
            }  
            return touchResult;  
        case MotionEvent.ACTION_MOVE:  
            float percent;  
            float x = event.getX();  
            if (x <= lineLeft) {  
                percent = 0;  
            } else if (x >= lineRight){  
                percent = 1;  
            } else {  
                percent = (x - lineLeft) * 1f / (lineWidth);  
            }  
            // SeekBar按钮根据当前手指在拖动条上的滑动而滑动  
            seekbar.slide(percent);  
            invalidate();  
            break;  
    }  
    return super.onTouchEvent(event);  
}
private class SeekBar {  
    int lineWidth; // 拖动条宽度 可在onSizeChanged时刻获得  
    float currPercent;  
    int left, right, top, bottom;  

    boolean collide(MotionEvent event) {  
        float x = event.getX();  
        float y = event.getY();  
        int offset = (int) (lineWidth * currPercent);  
        return x > left + offset && x < right + offset && y > top && y < bottom;  
    }  

    void slide(float percent) {  
        if (percent < 0) percent = 0;  
        else if (percent > 1) percent = 1;  
        currPercent = percent;  
    }  

    void draw(Canvas canvas) {  
        int offset = (int) (lineWidth * currPercent);  
        canvas.save();  
        canvas.translate(offset, 0);  
        canvas.drawBitmap(bmp, left, top, null);  
        canvas.restore();  
    }  
}

更好的视觉体验

到目前位置,SeekBar被按压时显得死气沉沉,接下来为其添加强烈的视觉反馈。

那么之前通过onSizeChanged预设按钮的偷懒手段就GG了,因为SeekBar的UI效果需要随触摸状态的变化而变化。

首先在onTouchEvent中拿到这个变化

@Override  public boolean onTouchEvent(MotionEvent event) {  
    switch (event.getAction()) {  
        case MotionEvent.ACTION_MOVE:  
            seekBar.material = seekBar.material >= 1 ? 1 : seekBar.material + 0.1f;  
            ...  
            invalidate();  
            break;  
        case MotionEvent.ACTION_CANCEL:  
        case MotionEvent.ACTION_UP:  
            seekBar.materialRestore();  
            break;  
    }  
    return super.onTouchEvent(event);  
}

之后在SeekBar按钮中响应这个变化

private class SeekBar {  
    float material = 0;  
    ValueAnimator anim;  
    final TypeEvaluator<Integer> te = new TypeEvaluator<Integer>() {  
        @Override  
        public Integer evaluate(float fraction, Integer startValue, Integer endValue) {  
            int alpha = (int) (Color.alpha(startValue) + fraction * (Color.alpha(endValue) - Color.alpha(startValue)));  
            int red = (int) (Color.red(startValue) + fraction * (Color.red(endValue) - Color.red(startValue)));  
            int green = (int) (Color.green(startValue) + fraction * (Color.green(endValue) - Color.green(startValue)));  
            int blue = (int) (Color.blue(startValue) + fraction * (Color.blue(endValue) - Color.blue(startValue)));  
            return Color.argb(alpha, red, green, blue);  
        }  
    };  

    void draw(Canvas canvas) {  
        int offset = (int) (lineWidth * currPercent);  
        canvas.save();  
        canvas.translate(left, 0);  
        canvas.translate(offset, 0);  
        drawDefault(canvas);  
        canvas.restore();  
    }  

    private void drawDefault(Canvas canvas) {  
        int centerX = widthSize / 2;  
        int centerY = heightSize / 2;  
        int radius = (int) (widthSize * 0.5f);  
        // draw shadow  
        defaultPaint.setStyle(Paint.Style.FILL);  
        canvas.save();  
        canvas.translate(0, radius * 0.25f);  
        canvas.scale(1 + (0.1f * material), 1 + (0.1f * material), centerX, centerY);  
        defaultPaint.setShader(shadowGradient);  
        canvas.drawCircle(centerX, centerY, radius, defaultPaint);  
        defaultPaint.setShader(null);  
        canvas.restore();  
        // draw body  
        defaultPaint.setStyle(Paint.Style.FILL);  
        defaultPaint.setColor(te.evaluate(material, 0xFFFFFFFF, 0xFFE7E7E7));  
        canvas.drawCircle(centerX, centerY, radius, defaultPaint);  
        // draw border  
        defaultPaint.setStyle(Paint.Style.STROKE);  
        defaultPaint.setColor(0xFFD7D7D7);  
        canvas.drawCircle(centerX, centerY, radius, defaultPaint);  
    }  

    private void materialRestore() {  
        if (anim != null) anim.cancel();  
        anim = ValueAnimator.ofFloat(material, 0);  
        anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {  
            @Override  
            public void onAnimationUpdate(ValueAnimator animation) {  
                material = (float) animation.getAnimatedValue();  
                invalidate();  
            }  
        });  
        anim.addListener(new AnimatorListenerAdapter() {  
            @Override  
            public void onAnimationEnd(Animator animation) {  
                material = 0;  
                invalidate();  
            }  
        });  
        anim.start();  
    }  
}

draw方法中的直接绘制bmp的逻辑被替换为drawDefault

那么drawDefault的内部逻辑基本和预制bmp一样,唯二的区别在于对阴影Shadow做了个scale处理,对按钮Body颜色做了个渐变处理
materialRestore即当用户手指抬起后开个线程将状态渐变为初始态

Range

Range的意思就是范围,但是就算知道这些似乎并没有什么卵用 _(:3 」∠)_
so为了了解其中规律,本宝宝使劲摸索。最终发现

如果分开来看它们都拥有自己的固定滑动区间,右边的SeekBar按钮就是左边SeekBar按钮向右平移了个SeekBar按钮宽度而已。

public class RangeSeekBar extends View {  
    private SeekBar leftSB = new SeekBar();  
    private SeekBar rightSB = new SeekBar();  
    /** 
     * 用来记录当前用户触摸的到底是哪个SB 
     */  
    private SeekBar currTouch;  

    @Override  
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {  
        super.onSizeChanged(w, h, oldw, oldh);  
        ...  
        // rightSB就如同分析的一样,紧紧贴在leftSB的右边而已  
        rightSB.left += leftSB.widthSize;  
        rightSB.right += leftSB.widthSize;  
    }  

    @Override  
    protected void onDraw(Canvas canvas) {  
        super.onDraw(canvas);  
        ...  
        leftSB.draw(canvas);  
        rightSB.draw(canvas);  
    }  

    @Override  
    public boolean onTouchEvent(MotionEvent event) {  
        switch (event.getAction()) {  
            case MotionEvent.ACTION_DOWN:  
                boolean touchResult = false;  
                /** 
                 * 为什么不先检测leftSB而先检测rightSB?为什么? ('') 
                 */  
                if (rightSB.collide(event)) {  
                    currTouch = rightSB;  
                    touchResult = true;  
                } else if (leftSB.collide(event)) {  
                    currTouch = leftSB;  
                    touchResult = true;  
                }  
                return touchResult;  
            case MotionEvent.ACTION_MOVE:  
                float percent;  
                float x = event.getX();  

                if (currTouch == leftSB) {  
                    if (x < lineLeft) {  
                        percent = 0;  
                    } else {  
                        percent = (x - lineLeft) * 1f / (lineWidth - rightSB.widthSize);  
                    }  

                    if (percent > rightSB.currPercent) {  
                        percent = rightSB.currPercent;  
                    }  
                    leftSB.slide(percent);  
                } else if (currTouch == rightSB) {  
                    if (x > lineRight) {  
                        percent = 1;  
                    } else {  
                        percent = (x - lineLeft - leftSB.widthSize) * 1f / (lineWidth - leftSB.widthSize);  
                    }  
                    if (percent < leftSB.currPercent) {  
                        percent = leftSB.currPercent;  
                    }  
                    rightSB.slide(percent);  
                }  

                invalidate();  
                break;  
        }  
        return super.onTouchEvent(event);  
    }  
}

通过触摸改变一些属性的值,通过这些属性的值绘制出对应的UI效果,套路一切都是套路

那么继SwitchButton后,又算是重新温习了一次该套路

那么本宝宝的RangeSeekBar还能做到什么?

支持负数

支持预留(保留)范围

什么是预留(保留)范围?比如那个,你懂得。只可意会,不可言传。(≖ ‿ ≖)

比如现在2个按钮直接就保留了一个距离,当然也可以保留n个

支持刻度模式

当然支持刻度的同时也支持预留范围

支持自定义UI按钮样式背景颜色


打开App,阅读手记
1人推荐
发表评论
随时随地看视频慕课网APP

热门评论

源码

查看全部评论