Android 自定义ToggleButton实践二

244 阅读5分钟

一、前言

上一篇我们处理论述过相关绘制难点,本篇其实同样的需要克服上一篇的困难,唯一不同的是本篇内容中添加了文本绘制,温习一下文本绘制技巧。

二、绘制文本基本知识

1、文本绘制基线测量

文本绘制的方法是 Canvas 类的 drawText,对于 x 点坐标其实和正常流程类似,但 Y 坐标的确定需要考虑 Baseline 问题

@param text The text to be drawn 
@param x X方向的坐标,开始绘制的左上角横轴坐标点
@param y Y坐标,该坐标是Y轴方向上的”基线”坐标
@param paint 画笔工具
*/ 
public void drawText(@NonNull String text, float x, float y, @NonNull Paint paint)

基线到中线的距离 =(Descent+Ascent)/2-Descent ,Android 中,实际获取到的 Ascent 是负数。

公式推导过程如下:
中线到 BOTTOM 的距离是 (Descent+Ascent)/2,这个距离又等于 Descent + 中线到基线的距离,即 (Descent+Ascent)/2 = 基线到中线的距离 + Descent。
有了基线到中线的距离,我们只要知道任何一行文字中线的位置,就可以马上得到基线的位置,从而得到 Canvas 的 drawText 方法中参数 y 的值。

    /**
     * 计算绘制文字时的基线到中轴线的距离,Android获取中线到基线距离的代码,Paint需要设置文字大小textsize。
     * 
     * @param p
     * @param centerY
     * @return 基线和centerY的距离
     */
    public static float getBaseline(Paint p) {
        FontMetrics fontMetrics = p.getFontMetrics();
        return (fontMetrics.descent - fontMetrics.ascent) / 2 -fontMetrics.descent;
    }

说道这里我们只是计算出了基线高度,Y 坐标一般区文本高度的中点位置。比如竖直方向,公式为。

Y = centerY + getBaseline(paint); 

此外,对于宽度的测量,一般使用如下方法

 mPaint.getTextBounds(text, 0, text.length(), mBounds);
 float textwidth = mBounds.width();

2、Path 闭合区域填充问题

在常见的绘制 View 的过程中,我们通过 Path 对象构建复杂的闭合图像,最后一般来通过 Paint 设置 Style.FILL 填充区域,但是对于闭合的 Path 填充,在 Android 某些版本中不支持填充 Path 的区域。实际上 Path 同样提供了填充方法,可以做到很好的兼容。

Android 的 Path.FillType 除了支持上面两种模式外,还支持了上面两种模式的反模式,一共定义了 EVEN_ODD, INVERSE_EVEN_ODD, WINDING, INVERSE_WINDING 四种模式。

3、Path 图像合成

一般情况下我们图像是将 Bitmap 合成,合成时使用 Xfermodes,当然 Path 也可以转为 Bitmap 图像数据。

但是 Path 同样提供了一系列合成方法

DIFFERENCE:从 path1 中减去 path2

INTERSECT:取 path1 和 path2 重合的部分

REVERCE_DIFFERENCE:从 path2 中减去 path1

UNION:联合 path1 和 path2

XOR:取 path1 和 path2 不重合的部分

4、StrokeWidth 与区域大小问题

对于带边框的 View,StrokeWidth 在很多情况下被认为不挤占区域大小,实际上,与此相反,我们计算坐标时一定要计算线宽问题。比如绘制线宽 StrokeWidth 的起点矩形,如果不这样计算,绘制将会出现边框宽度不一致的情况。

startX = StrokeWidth;

startY = StrokeWidth;

endX = getWidth() - StrokeWidth;

endY = getHeight- StrokeWidth;

5、触摸 MOVE 事件问题

很多时候绘制 View 我们需要处理 TouchEvent 事件,然而,Android 中 View 默认无法监听,需要设置一个莫名其妙的参数。

setClickable(true);

5、事件状态转移问题

很多时候,我们判断到某一区域时达到某种条件需要主动结束事件事务,或者改变事件状态如下然后在传递出去,方法如下

  MotionEvent actionUP = MotionEvent.obtain(event); //增量式拷贝,比如修修改开始时间、修改修改时间序列
  actionUP.setAction(MotionEvent.ACTION_UP);

  dispatchTouchEvent(actionUP); //传递事件,注意不要造成死循环问题

7、变色算法

        float maxSlideWidth = outline.width() - mSlideBarWidth - lineWidthPixies;
        if (maxSlideWidth > 0) {
            float x = animSlideBarX - lineWidthPixies - lineWidthPixies / 2F;
            float ratio = x / maxSlideWidth;

            int Alpha = (int) (Color.alpha(COLOR_TABLE[0]) * (1 - ratio) + Color.alpha(COLOR_TABLE[1]) * ratio);
            int Red = (int) (Color.red(COLOR_TABLE[0]) * (1 - ratio) + Color.red(COLOR_TABLE[1]) * ratio);
            int Green = (int) (Color.green(COLOR_TABLE[0]) * (1 - ratio) + Color.green(COLOR_TABLE[1]) * ratio);
            int Blue = (int) (Color.blue(COLOR_TABLE[0]) * (1 - ratio) + Color.blue(COLOR_TABLE[1]) * ratio);
            currentFillColor = Color.argb(Alpha, Red, Green, Blue);

            Alpha = (int) (Color.alpha(COLOR_TABLE[1]) * (1 - ratio) + Color.alpha(COLOR_TABLE[0]) * ratio);
            Red = (int) (Color.red(COLOR_TABLE[1]) * (1 - ratio) + Color.red(COLOR_TABLE[0]) * ratio);
            Green = (int) (Color.green(COLOR_TABLE[1]) * (1 - ratio) + Color.green(COLOR_TABLE[0]) * ratio);
            Blue = (int) (Color.blue(COLOR_TABLE[1]) * (1 - ratio) + Color.blue(COLOR_TABLE[0]) * ratio);

            outLineColor = Color.argb(Alpha, Red, Green, Blue);

            Log.d("Colors", "ratio = " + ratio + ",animSlideBarX=" + animSlideBarX + ",maxSlideWidth=" + maxSlideWidth);
        } else {
            currentFillColor = COLOR_TABLE[0];
            outLineColor = COLOR_TABLE[1];
        }

基于以上问题的解决,实现了一个 SwitchButton,虽然没用到 Path,但还是考虑了很多问题。

public class SwitchButtonView extends View {

    // 实例化画笔
    private TextPaint mPaint = null;
    private int lineWidth = 5;

    private final int STATUS_LEFT = 0x00;
    private final int STATUS_RIGHT = 0x01;
    private volatile int mStatus = STATUS_LEFT;
    private RectF outline = new RectF();
    private Rect mBounds = new Rect();
    private RectF rect = new RectF();
    private int textSize = 18;

    private volatile float startX = 0; //触摸开始位置
    private volatile boolean isTouchState = false;
    private volatile float currentX = 0;
    private final String[] STATUS = {"开", "关"};
    private OnStatusChangedListener mOnStatusChangedListener;
    private int mSlideInMiddleSpace = 20;
    private float mSlideBarWidth = 0;
    private boolean shouldAnimation = false;
    private ValueAnimator mSlideAnimator = null;

    private final int[] COLOR_TABLE = {0xFF394038, 0xFFf20a7e};

    private int currentFillColor = COLOR_TABLE[0];
    private int outLineColor;

    public void setLeftText(String text) {
        STATUS[0] = text;

    }

    public void setRightText(String text) {
        STATUS[1] = text;
    }

    public void setLeftColor(int Color) {
        COLOR_TABLE[0] = Color;

    }

    public void setRightColor(int color) {
        COLOR_TABLE[1] = color;
    }

    private float animSlideBarX = 0;

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

    public SwitchButtonView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public SwitchButtonView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initPaint();
        setClickable(true); //设置此项true,否则无法滑动
    }

    private void initPaint() {
        // 实例化画笔并打开抗锯齿
        mPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setAntiAlias(true);
        mPaint.setStrokeWidth(dpTopx(lineWidth));
        mPaint.setTextSize(dpTopx(textSize));

        mSlideInMiddleSpace = (int) dpTopx(8);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int width = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int height = MeasureSpec.getSize(heightMeasureSpec);

        if (widthMode != MeasureSpec.EXACTLY) {
            width = (int) dpTopx(105 * 2);
        }
        if (heightMode != MeasureSpec.EXACTLY) {
            height = (int) dpTopx(35 * 2);
        }
        setMeasuredDimension(width, height);

    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int width = getWidth();
        int height = getHeight();
        float lineWidthPixies = dpTopx(lineWidth);
        if (width <= lineWidthPixies || height <= lineWidthPixies) return;

        float centerX = width / 2F;
        float centerY = height / 2F;

        float R = Math.min(width / 2F, height / 2F);

        float maxSlideWidth = outline.width() - mSlideBarWidth - lineWidthPixies;
        if (maxSlideWidth > 0) {
            float x = animSlideBarX - lineWidthPixies - lineWidthPixies / 2F;
            float ratio = x / maxSlideWidth;

            int Alpha = (int) (Color.alpha(COLOR_TABLE[0]) * (1 - ratio) + Color.alpha(COLOR_TABLE[1]) * ratio);
            int Red = (int) (Color.red(COLOR_TABLE[0]) * (1 - ratio) + Color.red(COLOR_TABLE[1]) * ratio);
            int Green = (int) (Color.green(COLOR_TABLE[0]) * (1 - ratio) + Color.green(COLOR_TABLE[1]) * ratio);
            int Blue = (int) (Color.blue(COLOR_TABLE[0]) * (1 - ratio) + Color.blue(COLOR_TABLE[1]) * ratio);
            currentFillColor = Color.argb(Alpha, Red, Green, Blue);

            Alpha = (int) (Color.alpha(COLOR_TABLE[1]) * (1 - ratio) + Color.alpha(COLOR_TABLE[0]) * ratio);
            Red = (int) (Color.red(COLOR_TABLE[1]) * (1 - ratio) + Color.red(COLOR_TABLE[0]) * ratio);
            Green = (int) (Color.green(COLOR_TABLE[1]) * (1 - ratio) + Color.green(COLOR_TABLE[0]) * ratio);
            Blue = (int) (Color.blue(COLOR_TABLE[1]) * (1 - ratio) + Color.blue(COLOR_TABLE[0]) * ratio);

            outLineColor = Color.argb(Alpha, Red, Green, Blue);

            Log.d("Colors", "ratio = " + ratio + ",animSlideBarX=" + animSlideBarX + ",maxSlideWidth=" + maxSlideWidth);
        } else {
            currentFillColor = COLOR_TABLE[0];
            outLineColor = COLOR_TABLE[1];
        }

        drawRectOutline(canvas, width, height, lineWidthPixies, R);

        //中间分割线
        mPaint.setStrokeCap(Paint.Cap.ROUND);
        mPaint.setColor(Color.LTGRAY);
        canvas.drawLine(centerX - mPaint.getStrokeWidth() / 2F, (float) (height / 2F - height / 2 * 0.4F - lineWidthPixies / 2F), centerX - mPaint.getStrokeWidth() / 2F, (float) (height / 2F + height / 2F * 0.4F) - lineWidthPixies / 2F, mPaint);
        mPaint.setStrokeCap(Paint.Cap.BUTT);

        drawSlider(canvas, width, height, lineWidthPixies);

        drawText(canvas, width, centerY);


    }


    private void drawRectOutline(Canvas canvas, int width, int height, float lineWidthPixies, float r) {
        float startX = lineWidthPixies;
        float startY = lineWidthPixies;
        float endX = width - 2 * lineWidthPixies; //宽度应该减去左右两边的线宽
        float endY = height - 2 * lineWidthPixies; //宽度应该减去上下两边的线宽
        mPaint.setStyle(Paint.Style.FILL);
        int color = mPaint.getColor();

        mPaint.setColor(currentFillColor);
        outline.set(startX, startY, endX, endY);
        canvas.drawRoundRect(outline, r, r, mPaint);
        mPaint.setStyle(Paint.Style.STROKE);

        float strokeWidth = mPaint.getStrokeWidth();
        mPaint.setStrokeWidth(lineWidthPixies/2);
        mPaint.setColor(Color.LTGRAY);
        canvas.drawRoundRect(outline, r, r, mPaint);
        mPaint.setColor(color);
        mPaint.setStrokeWidth(strokeWidth);
    }

    private void drawText(Canvas canvas, int width, float centerY) {

        mPaint.getTextBounds(STATUS[0], 0, STATUS[0].length(), mBounds);
        float textWidth = mBounds.width();
        float textBaseline = centerY + getTextPaintBaseline(mPaint);
        Paint.Style style = mPaint.getStyle();
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setTextSize(52);

        int color = mPaint.getColor();
        mPaint.setColor(Color.WHITE);

        if (mStatus == STATUS_LEFT) {
            mPaint.setFakeBoldText(true);
        } else {
            mPaint.setFakeBoldText(false);
        }
        canvas.drawText(STATUS[0], width / 4F - textWidth / 2F, textBaseline, mPaint);
        if (mStatus == STATUS_RIGHT) {
            mPaint.setFakeBoldText(true);
        } else {
            mPaint.setFakeBoldText(false);
        }
        canvas.drawText(STATUS[1], width * 3 / 4F - textWidth / 2F, textBaseline, mPaint);//文本位置以基线为准
        mPaint.setStyle(style);
        mPaint.setColor(color);
    }

    /**
     * 基线到中线的距离=(Descent+Ascent)/2-Descent
     * 注意,实际获取到的Ascent是负数。公式推导过程如下:
     * 中线到BOTTOM的距离是(Descent+Ascent)/2,这个距离又等于Descent+中线到基线的距离,即(Descent+Ascent)/2=基线到中线的距离+Descent。
     */
    public static float getTextPaintBaseline(Paint p) {
        Paint.FontMetrics fontMetrics = p.getFontMetrics();
        return (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent;
    }


    private void drawSlider(Canvas canvas, float outWidth, float outheight, float lineWidthPixies) {
        int color = mPaint.getColor();
        mPaint.setColor(outLineColor);
        mPaint.setStyle(Paint.Style.FILL);
        float width = outWidth - 2 * lineWidthPixies;
        float height = outheight - 2 * lineWidthPixies;

        float slideBarX = lineWidthPixies + lineWidthPixies / 2F;
        float slideBarY = lineWidthPixies + lineWidthPixies / 2F;
        mSlideBarWidth = (width / 2F - slideBarX - mSlideInMiddleSpace);
        float R = Math.min(width / 2F, height / 2F);

        if (isTouchState) {
            animSlideBarX = currentX;
            rect.set(animSlideBarX, slideBarY, currentX + mSlideBarWidth, height - lineWidthPixies / 2F);
            canvas.drawRoundRect(rect, R, R, mPaint);
        } else {
            if (mStatus == STATUS_RIGHT) {
                slideBarX = (width / 2F + mSlideInMiddleSpace + lineWidthPixies);
                if (!shouldAnimation) {
                    animSlideBarX = slideBarX;
                    rect.set(animSlideBarX, slideBarY, slideBarX + mSlideBarWidth, height - lineWidthPixies / 2F);
                    canvas.drawRoundRect(rect, R, R, mPaint);
                } else {
                    rect.set(animSlideBarX, slideBarY, animSlideBarX + mSlideBarWidth, height - lineWidthPixies / 2F);
                    canvas.drawRoundRect(rect, R, R, mPaint);
                }
            } else {
                if (!shouldAnimation) {
                    animSlideBarX = slideBarX;
                    rect.set(slideBarX, slideBarY, slideBarX + mSlideBarWidth, height - lineWidthPixies / 2F);
                    canvas.drawRoundRect(rect, R, R, mPaint);
                } else {
                    rect.set(animSlideBarX, slideBarY, animSlideBarX + mSlideBarWidth, height - lineWidthPixies / 2F);
                    canvas.drawRoundRect(rect, R, R, mPaint);
                }
            }
        }
        mPaint.setColor(color);
    }

    TimeInterpolator interpolator = new AccelerateDecelerateInterpolator();

    public void startSlideBarAnimation(float from, float to) {
        if (mSlideAnimator != null) {
            shouldAnimation = false;
            mSlideAnimator.cancel();
        }
        mSlideAnimator = ValueAnimator.ofFloat(from, to).setDuration(300);
        mSlideAnimator.setInterpolator(interpolator);
        mSlideAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                animSlideBarX = (float) animation.getAnimatedValue();
                float fraction = animation.getAnimatedFraction();
                if (fraction == 1.0f) {
                    shouldAnimation = false;
                }
                postInvalidate();
            }
        });
        mSlideAnimator.start();
    }

    private boolean isMoveTouch = false;

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        float lineWidthPixies = dpTopx(lineWidth);

        float width = (getWidth() - 2 * lineWidthPixies);
        float sliderWidth = mSlideBarWidth;
        int actionMasked = event.getActionMasked();
        switch (actionMasked) {
            case MotionEvent.ACTION_DOWN: {
                isTouchState = true;
                startX = event.getX();
                if (startX > (width / 2F) && startX < (width - lineWidthPixies) && mStatus == STATUS_LEFT) {
                    MotionEvent actionUP = MotionEvent.obtain(event);
                    actionUP.setAction(MotionEvent.ACTION_UP);
                    dispatchTouchEvent(actionUP);
                } else if (startX > lineWidthPixies && (startX < width / 2 && mStatus == STATUS_RIGHT)) {
                    MotionEvent actionUP = MotionEvent.obtain(event);
                    actionUP.setAction(MotionEvent.ACTION_UP);
                    dispatchTouchEvent(actionUP);
                } else if (startX < lineWidthPixies || startX > (width - lineWidthPixies)) {
                    MotionEvent actionOUTSIDE = MotionEvent.obtain(event);
                    actionOUTSIDE.setAction(MotionEvent.ACTION_OUTSIDE);
                    dispatchTouchEvent(actionOUTSIDE);
                }
            }
            return true;
            case MotionEvent.ACTION_MOVE:
                currentX = event.getX() - sliderWidth / 2F;
                //滑块移动位置应该相对于中心位置为基准
                float maxRight = (width / 2F + mSlideInMiddleSpace + lineWidthPixies);

                if (currentX < (lineWidthPixies)) {
                    currentX = lineWidthPixies + lineWidthPixies / 2; //最左边
                    mStatus = STATUS_LEFT;
                    onStatusChanged(mStatus);

                } else if (currentX > maxRight) { //最右边
                    mStatus = STATUS_RIGHT;
                    onStatusChanged(mStatus);
                    currentX = maxRight;
                }
                isMoveTouch = true;
                postInvalidate();
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_OUTSIDE:

                isTouchState = false;

                float xPos = event.getX();
                if (mStatus == STATUS_LEFT) {

                    if (xPos > width / 2F) {
                        mStatus = STATUS_RIGHT;
                        onStatusChanged(mStatus);

                        if (!isMoveTouch) {
                            float to = (width / 2F + mSlideInMiddleSpace + lineWidthPixies);
                            float from = (lineWidthPixies + lineWidthPixies / 2F);
                            startSlideBarAnimation(from, to);
                            shouldAnimation = true;
                        } else {
                            animSlideBarX = (width / 2F + mSlideInMiddleSpace + lineWidthPixies);
                        }
                    } else {
                        animSlideBarX = (lineWidthPixies + lineWidthPixies / 2F);
                    }

                } else if (mStatus == STATUS_RIGHT) {
                    if (xPos > lineWidthPixies && xPos < width / 2) {
                        mStatus = STATUS_LEFT;
                        onStatusChanged(mStatus);
                        if (!isMoveTouch) {
                            float from = (width / 2F + mSlideInMiddleSpace + lineWidthPixies);
                            float to = (lineWidthPixies + lineWidthPixies / 2F);
                            startSlideBarAnimation(from, to);
                            shouldAnimation = true;
                        } else {
                            animSlideBarX = (lineWidthPixies + lineWidthPixies / 2F);
                        }
                    } else {
                        animSlideBarX = (width / 2F + mSlideInMiddleSpace + lineWidthPixies);
                    }
                }

                if (!shouldAnimation) {
                    postInvalidate();

                }
                isMoveTouch = false;
                break;
        }

        return super.onTouchEvent(event);
    }


    private void onStatusChanged(int status) {

        if (this.mOnStatusChangedListener != null) {
            this.mOnStatusChangedListener.onStatusChanged(status);
        }
    }

    private float dpTopx(float dp) {
        return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, getResources().getDisplayMetrics());
    }

    public void setOnStatusChangedListener(OnStatusChangedListener l) {
        this.mOnStatusChangedListener = l;
    }

    interface OnStatusChangedListener {
        void onStatusChanged(int status);
    }
}

三、总结

本篇相比上一篇,完善程度更高,不仅处理了滑动问题,还通过自定义事件的方式成功实现了点击侧滑。