【Android View】自定义View——以模仿TextView的AutoTextView实现为例

实现目标:实现TextView的基础功能,并修复其存在的Bug

让我们开始吧!
首先,创建一个AutoTextView,继承自View类。

覆盖构造器

这里我们要重写它的四个构造方法,它们的参数不一样分别对应了不同的创建方式。
这四个参数分别代表:

  • set–属性值的基本集合。可能为空。
  • attrs–要检索的所需属性。这些属性ID必须按升序排序。
  • defStyleAttr–当前主题中的一个属性,包含对样式资源的引用,该资源为TypedArray提供默认值。可以为0以不查找默认值。
  • defStyleRes–样式资源的资源标识符,为TypedArray提供默认值,仅当defStyleAttr为0或在主题中找不到时使用。可以为0以不查找默认值。
//代码初始化控件时使用
public class AutoTextView extends View {

    //文字
    private String mText;
    //绘制范围
    private Rect mBound;
	//画笔
    private Paint mPaint;
    private Context mContext;
    
	//代码初始化控件时使用
    public AutoTextView(Context context) {
        this(context, null);
    }

    //布局文件中控件被映射成对象时调用
    public AutoTextView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public AutoTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        this(context, attrs, defStyleAttr, 0);
    }

    public AutoTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        mContext = context;
        init(context, attrs, defStyleAttr, defStyleRes);
    }
	
	//初始化数据
    private void init(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
	   	mText = "AutoTextView";
    	mPaint = new Paint();
        mPaint.setAntiAlias(true);//抗锯齿
        mPaint.setColor(Color.BLACK);
        mPaint.setTextSize(30);

        mBound = new Rect();
        mPaint.getTextBounds(mText, 0, mText.length(), mBound);
    }
}

现在,我们有了要绘制的目标,该怎么绘制到屏幕上呢?

重写onDraw

如果根据View的工作流程,绘制部分由onDraw方法承担。那么我们就要重写这部分代码。

	@Override
    protected void onDraw(Canvas canvas) {
        //绘制文字
        //根据代码,你能猜到文本会怎样绘制吗?
        canvas.drawText(mText, getWidth() / 2 - mBound.width() / 2, getHeight() / 2 + mBound.height() / 2, mPaint);
        //注意一下我们这里的getWidth()和getHeight()是获取的px
    }

同时编写布局代码:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    >
 
    <com.example.mypractice.AutoTextView
        android:layout_width="200dp"
        android:layout_height="100dp"
        />
 
</LinearLayout>

运行程序,我们可以看到我们的文字已经被显示到了画面上。
在这里插入图片描述

自定义属性

现在我们显示的文本是写死在代码中的,那么如何像TextView一样在xml中以属性的形式设置文本呢?这就涉及自定义属性相关的知识。

attrs.xml

首先,我们要在res/values下新建一个资源文件attrs.xml,存放我们的属性。
在这里插入图片描述

TypeArray

其次,我们要在代码中,获取到设置的属性,我们要用到TypeArray,在构造函数中获得设置的属性。
需要注意的是,TypeArray不是我们new出来的,而是调用了obtainStyledAttributes方法得到的对象

	//初始化数据
    private void init(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        //获取自定义属性的值
        TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.AutoTextView, defStyleAttr, defStyleRes);
        mText = typedArray.getString(R.styleable.AutoTextView_text);
        mTextColor = typedArray.getColor(R.styleable.AutoTextView_textColor, Color.BLACK);
        mTextSize = typedArray.getDimension(R.styleable.AutoTextView_textSize, 30);
        //使用完成后及时释放
        typedArray.recycle();
        
        mBound = new Rect();
        //初始化Paint数据
        mPaint = new Paint();
        mPaint.setColor(mTextColor);
        mPaint.setTextSize(mTextSize);
        mPaint.getTextBounds(mText, 0, mText.length(), mBound);  
    }
typeArray.recycle
static TypedArray obtain(Resources res, int len) {
        TypedArray attrs = res.mTypedArrayPool.acquire();
        if (attrs == null) {
            attrs = new TypedArray(res);
        }

        attrs.mRecycled = false;
        // Reset the assets, which may have changed due to configuration changes
        // or further resource loading.
        attrs.mAssets = res.getAssets();
        attrs.mMetrics = res.getDisplayMetrics();
        attrs.resize(len);
        return attrs;
    }

一步一步跟进方法,我们可以发现,这是典型的单例模式,为什么呢?

TypedArray的使用场景之一,就是自定义View,会随着Activity的每一次Create而Create,因此,需要系统频繁的创建array,对内存和性能是一个不小的开销,如果不使用池模式,每次都让GC来回收,很可能会造成OutOfMemory。

所以,TypeArray使用了单例模式。也因此,我们在每次使用结束之后,调用recycle(),将其放回池中。

typedArray.recycle();

在recycle()中,再将mRecycled设为true,并将array放回池中。

mResources.mTypedArrayPool.release(this);
在布局中设置属性

前提工作做完之后,我们就可以像使用TextView一样在布局中设置属性啦!当然,要记得在根布局中引入我们设置的属性哦~

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:autoTextView="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    >
    
    <com.example.mypractice.AutoTextView
        android:layout_width="200dp"
        android:layout_height="100dp"
        android:background="@color/black"
        autoTextView:text="AutoTextView"
        autoTextView:textColor="@color/white"
        autoTextView:textSize="25sp"
        />
        
</LinearLayout>

这里我们在调整一下绘制效果,以达到模仿TextView的效果。

	@Override
    protected void onDraw(Canvas canvas) {
        //绘制文字
        mPaint.getTextBounds(mText, 0, mText.length(), mBound);

        int singleLineHeight = mBound.height();
        
        canvas.drawText(mText, getPaddingLeft(), getPaddingTop() + singleLineHeight, mPaint);
    }

    @Override
    public int getPaddingTop() {
        return super.getPaddingTop() + DisplayUtil.dip2px(mContext, 5);
    }

    @Override
    public int getPaddingBottom() {
        return super.getPaddingBottom() + DisplayUtil.dip2px(mContext, 5);
    }

这里我们用到了getPaddingXXX(),用来获取viewpadding数据,返回的是像素值,所以我们用到了将我们熟悉的dp转换成像素的方法。

package com.example.mypractice;

import android.content.Context;
import android.util.TypedValue;

public class DisplayUtil {

    /**
     * 根据手机的分辨率从 dp 的单位 转成为 px(像素)
     */
    public static int dip2px(Context context, float dpValue) {
        final float scale = context.getResources().getDisplayMetrics().density;
        return (int) (dpValue * scale + 0.5f);
    }

    /**
     * 根据手机的分辨率从 px(像素) 的单位 转成为 dp
     */
    public static int px2dip(Context context, float pxValue) {
        final float scale = context.getResources().getDisplayMetrics().density;
        return (int) (pxValue / scale + 0.5f);
    }


    private int sp2px(Context context, int sp) {
        return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, sp,
                context.getResources().getDisplayMetrics());
    }
}

好啦,到这里我们这部分的工作终于做完了,让我们运行一下看看效果。
在这里插入图片描述

wrap_content

设置好了自定义属性,我们可以尝试将android:layout_width属性设置为wrap_content,看看会发生什么?
相信你一定不敢相信自己的眼睛,wrap_content的效果居然和match_parent相同!这可太糟糕了,我们需要立刻解决这个问题!
首先让我们明确一下问题出在哪里,这一点可以看看我的上一篇博客。
好的,我们既然知道了问题之后,就可以着手处理它了。
编写AutoTextView中的onMeasure方法如下:

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

        //下面对wrap_content这种模式进行处理
        int width;
        int height;
        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize;
        } else {
            //如果是wrap_content,我们要得到控件需要多大的尺寸
            //控件的宽度就是文本的宽度加上两边的内边距,内边距就是padding值,在构造方法执行完就被赋值
            width = (int) (getPaddingLeft() + textWidth + getPaddingRight());
        }
        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        } else {
            //如果是wrap_content,我们要得到控件需要多大的尺寸
            //首先丈量文本的宽度
            float textHeight = mBound.height();
            //控件的宽度就是文本的宽度加上两边的内边距。内边距就是padding值,在构造方法执行完就被赋值
            height = (int) (getPaddingTop() + textHeight + getPaddingBottom());
        }

        //保存丈量结果
        setMeasuredDimension(width, height);
    }

再运行一次,你应该会看到wrap_content达到了它应该的效果。

自动换行

思路

我们首先分析这个效果的实质,其实它就是在测量控件大小的时候,感知到文本长度大于屏幕宽度,导致文本显示不全,需要我们将其调整到下一行显示。
那么这个问题的核心就在于,如何进行感知,如何处理控件的高宽度以及如何将文本分割

如何感知

这个问题需要我们拿到文本的长度,以及屏幕的宽度,对其进行比较即可。

如何处理控件的大小

首先,我们需要拿到文本长度与屏幕宽度,计算得出我们需要将文本分成几行显示,然后将控件宽度设为屏幕宽度,再拿到每行文本的高度,经过计算,得出控件应有的高度。最后,保存宽度与高度。

如何文本分割

简单的文本分割,我们都知道可以使用Stringsplit或者substring方法。问题在于我们应该如何正确的分割文本以保证正确的显示效果呢?
通过用文本长度除以屏幕宽度,我们便可拿到实际显示行数,再用文本的length除以这个显示行数,即可得到每行的平均字数,最后按照这个平均字数,从文本中取出一句句文字,放到List中即可。

编码

分割文本

有了思路,我们就可以编码验证了。
首先我们来看分割文本的代码:

    private void splitText(int widthSize) {
        //文本的总长度
        textWidth = mPaint.measureText(mText);
        if (mTextList.size() == 0) {
            //将文本分段
            int padding = getPaddingLeft() + getPaddingRight();
            //剩余能够显示文本的最宽度
            int specWidth = widthSize - padding;
            if (textWidth <= specWidth) {
                //可以显示一行
                lineNum = 1;
                mTextList.add(mText);
            } else {
                if (isMarquee){
                    lineNum = 1;
                    mTextList.add(mText);
                    return;
                }
                //超过一行
                isOneLine = false;
                splineNum = textWidth / specWidth;
                //如果有小数的话则进1
                if (splineNum % 1 != 0) {
                    lineNum = (int) (splineNum + 1);
                } else {
                    lineNum = (int) splineNum;
                }
                int lineLength = (int) (mText.length() / splineNum);

                //用以当文字长度过长时通过减去一个字符来避免显示不完全
                int tempchar = 0;
                for (int i = 0; i < lineNum; i++) {
                    String lineStr;
                    //判断是否可以一行展示
                    if (mText.length() < lineLength) {
                        lineStr = mText;
                    } else {
                        lineStr = mText.substring(0, lineLength);
                        float tempTextWidth = mPaint.measureText(lineStr);
                        if (tempTextWidth>specWidth){
                            lineStr = mText.substring(0, lineLength-1);
                            tempchar = 1;
                        }
                    }
                    mTextList.add(lineStr);
                    if (!TextUtils.isEmpty(mText)) {
                        if (mText.length() >= lineLength) {
                            mText = mText.substring(lineLength-tempchar);
                            tempchar = 0;
                        }
                    } else {
                        break;
                    }
                }
            }
        }
    }

控制大小

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

        //分割文本,将过长的文本转换为存放多个string的mTextList
        splitText(widthSize);

        //下面对wrap_content这种模式进行处理
        int width;
        int height;
        if (widthMode == MeasureSpec.EXACTLY) {
            width = widthSize;
        } else {
            //如果是wrap_content,我们要得到控件需要多大的尺寸
            if (isOneLine) {
                //控件的宽度就是文本的宽度加上两边的内边距,内边距就是padding值,在构造方法执行完就被赋值
                width = (int) (getPaddingLeft() + textWidth + getPaddingRight());
            } else {
                //如果是多行,说明控件宽度应该填充父窗体
                width = widthSize;
            }
        }
        if (heightMode == MeasureSpec.EXACTLY) {
            height = heightSize;
        } else {
            //如果是wrap_content,我们要得到控件需要多大的尺寸
            //首先丈量文本的宽度
            float textHeight = mBound.height();
            if (isOneLine) {
                //控件的宽度就是文本的宽度加上两边的内边距。内边距就是padding值,在构造方法执行完就被赋值
                height = (int) (getPaddingTop() + textHeight + getPaddingBottom());
            } else {
                //如果是多行
                height = (int) (getPaddingTop() + textHeight * lineNum + getPaddingBottom());
                //控件高度需要加上行间距
                //行间距属性的设置就不再赘述,大家参考上面的自定义属性来写
                height += mLineSpacing * (lineNum - 1);
            }
        }

        //保存丈量结果
        setMeasuredDimension(width, height);
    }
    
	@Override
    protected void onDraw(Canvas canvas) {
        //绘制文字
        for (int i = 0; i < mTextList.size(); i++) {
            mPaint.getTextBounds(mTextList.get(i), 0, mTextList.get(i).length(), mBound);
            singleLineHeight = Math.max(singleLineHeight, mBound.height());
            canvas.drawText(mTextList.get(i), x, (getPaddingTop() + (singleLineHeight * (i+1)) + mLineSpacing * i), mPaint);
        }
    }

好啦,将文本设置的长一些,运行一下程序,让我们看看效果~
在这里插入图片描述

相比TextView不存在的bug

同样的一段文本,让我们看看我们的AutoTextViewTextview有什么区别。
在这里插入图片描述
可以看到,我们的AutoTextView能够正确显示两个空格,而TextView因其换行策略,无法显示出这两个空格。

跑马灯效果实现

仿照TextView的跑马灯效果,首先我们要设置一个跑马灯的属性,这里不再赘述,只给上attrs.xml的代码

<?xml version="1.0" encoding="utf-8"?>
<resources>

    <declare-styleable name="AutoTextView">
        <attr name="text" format="string"/>
        <attr name="textColor" format="color"/>
        <attr name="textSize" format="dimension"/>
        <attr name="lineSpacing" format="dimension"/>
        <attr name="marquee" format="boolean"/>
    </declare-styleable>

</resources>

好了,主要问题来到了我们面前,如何让文字出现跑马灯效果呢?
破解这个问题的秘诀在于重绘!
通过反复绘制,让文字每次绘制的位置都发生一点点移动,并定一个间隔极短的间隔时间,最终便能让文字滚动起来。
这里我们写一个线程,专门用来重置绘制位置与通知控件重绘。

	class MarqueeThread extends Thread {
        @Override
        public void run() {
            super.run();
            while (marqueeRunning) {
                x += 3;
                if (x > getWidth()){
                    x = (int) (0 - mPaint.measureText(mTextList.get(0)));
                }
                //通知控件重绘(原理是发送了一个Message)
                postInvalidate();
                try {
                    sleep(30);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

准备工作还没有结束,我们需要让跑马灯效果启用的时候免除分割文本,编辑代码如下:

private void splitText(int widthSize) {
        //文本的总长度
        textWidth = mPaint.measureText(mText);
        if (mTextList.size() == 0) {
            //将文本分段
            int padding = getPaddingLeft() + getPaddingRight();
            //剩余能够显示文本的最宽度
            int specWidth = widthSize - padding;
            if (textWidth <= specWidth) {
                //可以显示一行
                lineNum = 1;
                mTextList.add(mText);
            } else {
           		//如果启用了跑马灯效果,则不再分割文本
                if (isMarquee){
                    lineNum = 1;
                    mTextList.add(mText);
                    return;
                }
                //超过一行
                isOneLine = false;
                splineNum = textWidth / specWidth;
                //如果有小数的话则进1
                if (splineNum % 1 != 0) {
                    lineNum = (int) (splineNum + 1);
                } else {
                    lineNum = (int) splineNum;
                }
                int lineLength = (int) (mText.length() / splineNum);

                //用以当文字长度过长时通过减去一个字符来避免显示不完全
                int tempchar = 0;
                for (int i = 0; i < lineNum; i++) {
                    String lineStr;
                    //判断是否可以一行展示
                    if (mText.length() < lineLength) {
                        lineStr = mText;
                    } else {
                        lineStr = mText.substring(0, lineLength);
                        float tempTextWidth = mPaint.measureText(lineStr);
                        if (tempTextWidth>specWidth){
                            lineStr = mText.substring(0, lineLength-1);
                            tempchar = 1;
                        }
                    }
                    mTextList.add(lineStr);
                    if (!TextUtils.isEmpty(mText)) {
                        if (mText.length() >= lineLength) {
                            mText = mText.substring(lineLength-tempchar);
                            tempchar = 0;
                        }
                    } else {
                        break;
                    }
                }
            }
        }
    }

然后我们只需要在onDraw方法中启动线程即可:

	@Override
    protected void onDraw(Canvas canvas) {
	if (isMarquee && marqueeThread == null) {
            marqueeThread = new MarqueeThread();
            marqueeThread.start();
        }
    }

最后不要忘了在xml中启用跑马灯效果哦~
运行程序,我们可以看到文本已经滚动起来啦~

参考文章:
https://blog.csdn.net/CHS007chs/article/details/85852234
https://www.cnblogs.com/huihuizhang/p/7623111.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值