Android 自定义View实现文本流布局

一.前言

一个项目中有一个需求:很多个TextView按照从左到右,从上到下依次排列,如下图所示:

Android 自定义View实现文本流布局_第1张图片

这种效果叫做流式布局,在网上查了一下流布局,发现基本都是通过继承ViewGroup来实现的,所以想通过自定义View来实现一个流式布局效果的TextView。

二.要点

观察我们的效果图,发现有如下要点:
1. 流布局的实现
2. 添加点击事件
3. 点击文本块后有水波纹效果

三.实现

1.流布局的实现:

自定义一个View,名为FlowTextView,继承自View。首先需要计算View的大小,宽度很简单,这里就不多过描述,麻烦的是高度的计算。看效果图,不难发现每块文本的高度是一样的,所以View的高度应该是:

height = textHeight * row + spacingVertical * (row-1) + paddingTop + paddingBottom
其中:
row : 行数
textHeight:文本高度;
spacingVertical:文本垂直间距;
paddingTop、paddingBottom:View的上下Padding

<1>.计算文字的高度

这里是将画笔paint设置好字体大小和字体后,通过Paint的FontMetrics来计算文字的高度。

    private void calculateTextHeightAndBaselineY() {
        mPaint.setTextSize(mTxtSize);
        if (mTypeface != null)
            mPaint.setTypeface(mTypeface);
        Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
        mTxtHeightMax = (float) Math.ceil(fontMetrics.descent - fontMetrics.ascent) 
                + mTxtPaddingTop + mTxtPaddingBottom;
    }

<2>.计算view的高度、文本块坐标

复写onMeasure方法。获取view的宽度,遍历所有文本,获取文本的宽度,累积相加,超过View的宽度时换行,记录行数和文本坐标。重复以上动作,直到文本遍历完毕,进而计算view高度。需要注意的是当单个文本的长度超过View的宽度时,需要对该个文本做特殊处理。如,截取文本,使文本在文本后添加“…”等。

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        calculateTextHeightAndBaselineY();
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int height = 0;
        int actualWidth = widthSize - getPaddingRight();                        //获取可绘制的最大宽度
        if (actualWidth > 0) {
            //计算总高度
            height = initTextListPoint(actualWidth);
        }
        setMeasuredDimension(widthSize, height);
    }

     /**
     * 初始化文本坐标区间,计算View高度
     *
     * @param maxX 最大横坐标
     * @return view的高度
     */
    private int initTextListPoint(int maxX) {
        int x = getPaddingLeft();
        int height = 0;
        int rows = 1;
        int drawMaxWidth = (int) (maxX - getPaddingLeft() - mTxtPaddingLeft - mTxtPaddingRight);     //绘制的最大宽度
        mRow.append(0, 0);
        mTextPoints = new Rect[mTextList.length];
        for (int i = 0; i < mTextList.length; i++) {
            String text = mTextList[i];
            mPaint.getTextBounds(text, 0, text.length(), mBound);
            int w = (int) mPaint.measureText(text);    //计算该文本的长度

            if (w > drawMaxWidth) {
                mTextList[i] = getSuitableText(w, drawMaxWidth, text);
                w = drawMaxWidth;
            } else {
                w += mTxtPaddingLeft + mTxtPaddingRight;
            }

            if (i == 0) {                      //第一个文本不计算水平间距
                x += w;
            } else {
                x += w + mTxtSpacingHorizontal;
            }
            if (x > maxX) {              //如果累积横坐标大于最大横坐标则换行
                x = w + getPaddingLeft();
                rows++;
                mRow.append(rows - 1, i);   //记录换行位置
            }

            height = (int) (rows * mTxtHeightMax + (rows - 1) * mTxtSpacingVertical + getPaddingTop());
            //记录文本块的坐标
            Rect rect = new Rect(
                    x - w,
                    height - mTxtHeightMax,
                    x,
                    height);
            mTextPoints[i] = rect;
        }
        rowHeight = (height - getPaddingTop()) / rows;  //平均每行的高度
        height += getPaddingBottom();   //View的高度
        return height;
    }

    /**
     * 当文本长度超出可绘制最大宽度时,截取文本,并在文本后添加...
     *
     * @param currentWidth 当前文本长度
     * @param maxWidth     最大长度
     * @param text         文本
     * @return 截取后的文本
     */
    private String getSuitableText(int currentWidth, int maxWidth, String text) {
        int drawLength = (int) (((float) maxWidth) / ((float) currentWidth) * text.length());
        while (currentWidth > maxWidth) {
            drawLength--;
            text = text.substring(0, drawLength) + "...";
//            BaseLog.i("TEXT:" + text + " | LENGHT:" + drawLength);
            mPaint.getTextBounds(text, 0, text.length(), mBound);
            currentWidth = (int) (mPaint.measureText(text));
        }
        return text;
    }

<3>.view的绘制

复写onDraw方法绘制文本和背景色块。使用canvas.drawText(String text, float x, float y,
Paint paint)方法绘制文本。值得注意的是绘制文本时,y表示的文字的下边,而且y参数需要减去一个文本偏移量,才会使文本垂直居中于文本背景色块。参考居中绘制文本内容的正确方法。
而绘制的顺序如下:循环所有文本,先绘制文本背景色块,然后绘制文本,直至绘制完所有文本。

    /**
     * 绘制文本
     *
     * @param canvas    画布
     * @param rect 绘制坐标区间
     * @param position  文本位置
     */
    private void drawText(Canvas canvas, Rect rect, int position) {
        if (mColorList != null) {                    //设置文本字体颜色
            Integer color = mColorList[position];
            if (color != null) {
                mPaint.setColor(color);
            } else {
                mPaint.setColor(mTxtColor);
            }
        } else {
            mPaint.setColor(mTxtColor);
        }
        String text = mTextList[position];
        canvas.drawText(
                text,
                rect.pointLeftTop.x + mTxtPaddingLeft,
                rect.pointRightBottom.y - mTextCenterVerticalBaselineY,
                mPaint);
    }

     /**
     * 绘制文本块背景色
     *
     * @param canvas    画布
     * @param rect 绘制坐标区间
     */
    private void drawTextBG(Canvas canvas, Rect rect) {
        mPaint.setColor(mTxtBGColor);
        canvas.drawRect(
                rect.pointLeftTop.x,
                rect.pointLeftTop.y,
                rect.pointRightBottom.x,
                rect.pointRightBottom.y,
                mPaint);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        if (mTextPoints != null) {
            for (int i = 0; i < mTextPoints.length; i++) {
                Rect rect = mTextPoints[i];
                if (rect != null) {
                    drawTextBG(canvas, rect);
                    drawText(canvas, rect, i);
                }
            }
        }
    }

到这里,我们的自定义View效果就已经出来了,只是还没有实现点击和水波纹效果。

2.设置点击事件,和绘制水波纹效果

点击事件比较简单,重写onTouchEvent方法处理Touch动作,之前我们已经记录下每一块文本的坐标,所以我们只需要获取触摸view的坐标,然后和我们记录的坐标比较,就能知道点击的是哪块。
而水波纹效果,实际上就是在背景色块之上,文本之下绘制了一个圆,这个圆有两种情况,长按时圆慢慢变大、松开时快速变大。

<1>定义用于点击事件的接口

public interface OnFlowItemClickListener {
    void onItemClick(int position);
}

private OnFlowItemClickListener mOnFlowItemClickListener;

public void setOnFlowItemClickListener(OnFlowItemClickListener onFlowItemClickListener) {
    this.mOnFlowItemClickListener = onFlowItemClickListener;
}

<2>处理onTouchEvent

我们只需要处理三种情况,按下(ACTION_DOWN)、移动(ACTION_MOVE)、松开(ACTION_UP)
按下:判断触摸点是否在某块文本上,如果不在,什么都不用处理;否则,需要开始绘制水波纹效果
移动:如果有某块文本被触摸,需要判断移动时触摸点是否还在该文本块上,继续绘制水波纹,否则停止绘制水波纹
松开:如果有某块文本被触摸,响应点击事件,并停止绘制水波纹

    /**
     * 触摸文本块时
     *
     * @param point 触摸的点
     * @return
     */
    private void touchTextForDownEvent(Point point) {
        touchPosition = DEFAULT_TOUCH_POSITION;                     //初始化触摸位置
        int row = (int) (point.y - getPaddingTop()) / rowHeight;    //获取触摸点所在的行数
        Integer touchIndexMin = mRow.get(row);                      //获取该行处于最左边的文本块的索引
        if (touchIndexMin != null) {
            float touchYMin = mTextPoints[touchIndexMin].pointLeftTop.y;        //该行最小纵坐标
            float touchYMax = mTextPoints[touchIndexMin].pointRightBottom.y;    //该行最大纵坐标
            if (point.y >= touchYMin && point.y <= touchYMax) {     //判断触摸点纵坐标是否处于该行
                Integer touchIndexMax = mRow.get(row + 1);          //获取下行处于最左边的文本块的索引
                if (touchIndexMax == null)
                    touchIndexMax = mTextPoints.length;
                for (int i = touchIndexMin; i < touchIndexMax; i++) {//循环判断触摸点所在的文本块
                    Rect rect = mTextPoints[i];
                    if (point.x >= rect.pointLeftTop.x
                            && point.x <= rect.pointRightBottom.x) {
                        this.touchPosition = i;//记录触摸位置
                        this.touchPoint = point;//记录触摸坐标
                        downTime = System.currentTimeMillis();
                        startRipple(rect);//开始绘制水波纹
                        break;
                    }
                }
            }
        }
    }

    /**
     * 按下后移动手指时
     *
     * @param point
     */
    private void touchTextForMoveEvent(@NonNull Point point) {
        if (touchPosition != DEFAULT_TOUCH_POSITION) {
            Rect rect = mTextPoints[touchPosition];
            if (rect != null && rect.isInRect(touchPoint)) {
                this.touchPoint = point;
            } else {
                isDown = false;
            }
        }
    }

    /**
     * 松开手指时
     */
    private void touchTextForUpEvent() {
        if (touchPosition != DEFAULT_TOUCH_POSITION) {
            if (System.currentTimeMillis() - downTime <= DOWN_TIME_OUT)    //长按时不触发
                mOnFlowItemClickListener.onItemClick(touchPosition);       //执行点击事件
            stopRipple();                                                  //停止绘制水波效果
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (mOnFlowItemClickListener != null && mTextPoints != null) {
            Point touchPoint = new Point(event.getX(), event.getY());
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    touchTextForDownEvent(touchPoint);
                    return true;
                case MotionEvent.ACTION_MOVE:
                    touchTextForMoveEvent(touchPoint);
                    return true;
                case MotionEvent.ACTION_UP:
                    touchTextForUpEvent();
                    return true;
                default:
                    break;
            }
        }
        return super.onTouchEvent(event);
    }

<3>绘制水波纹

我们需要先绘制一个圆,值得注意的是,我们的绘制的圆的范围不能超过响应点击事件的文本块的大小。

    /**
     * 绘制圆
     *
     * @param canvas    画布
     * @param rect 绘制坐标区间
     */
    private void drawCircle(Canvas canvas, Rect rect) {
        //指定绘制范围
        canvas.clipRect(
                rect.pointLeftTop.x, rect.pointLeftTop.y,
                rect.pointRightBottom.x, rect.pointRightBottom.y);
        mPaint.setColor(rippleColor);
        canvas.drawCircle(touchPoint.x, touchPoint.y, circleR, mPaint);
        canvas.drawColor(rippleColorBg);
    }

绘制水波纹时,分两种,一种是圆慢慢变大,一种是快速变大。这里使用一个标志isDown用于区别。

    /**
     * 绘制水波纹
     *
     * @param canvas    画布
     * @param rect 绘制坐标区间
     */
    private void drawRipple(Canvas canvas, Rect rect) {
        if (touchPoint != null) {
            if (circleR < circleRMax) {             //当前半径小于最大半径
                circleR += isDown ? circleRMax / 120 : circleRMax / 20;
                drawCircle(canvas, rect);
                postInvalidateDelayed(16);
            } else {
                if (isDown) {                   //保持按下状态时
                    drawCircle(canvas, rect);
                    postInvalidateDelayed(16);
                }
            }
        }
    }

这些方法总归需要在重写的onDraw方法中调用,但是使用的时候遇到一个问题,就是后面绘制的覆盖前面绘制的问题,所以绘制的顺序比较重要,而且这里需要借助图层的功能。绘制过程如下:
首先绘制文本块背景,因为它处于最底层;
然后有两种情况,1.如果当前绘制文本块触发点击事件时,需要绘制文本和水波纹,这时需要新建一个图层,在新图层上绘制文本,然后使用图层合成的功能(使上下两层都显示,下层居上显示),接着绘制水波纹,这样水波纹才会在文本之下;2.如果没有触发点击事件,则直接绘制文本。
所以onDraw方法修改如下:

    @Override
    protected void onDraw(Canvas canvas) {
        if (mTextPoints != null) {
            for (int i = 0; i < mTextPoints.length; i++) {
                Rect rect = mTextPoints[i];
                if (rect != null) {
                    drawTextBG(canvas, rect);
                    if (touchPosition == i) {
                        int txtLayer = canvas.saveLayer(0, 0, getWidth(), getHeight(), null, Canvas.CLIP_TO_LAYER_SAVE_FLAG);   //新建一个图层
                        drawText(canvas, rect, i);

                        mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OVER));  //设置图层合成,上下两层都显示,下层居上显示
                        drawRipple(canvas, rect);
                        mPaint.setXfermode(null);           //还原图层合成
                        canvas.restoreToCount(txtLayer);    //恢复到txtLayer图层
                    } else {
                        drawText(canvas, rect, i);
                    }
                }
            }
        }
    }

如有错误,敬请指教。

四.参考

1 : http://kf.tutusoso.com/kf_mobile/article/9_31376_30207.asp
2 : http://www.tuicool.com/articles/u2eEbe
3 : http://www.111cn.net/sj/android/117381.htm
4 : http://www.android100.org/html/201605/19/238626.html

你可能感兴趣的:(Android)