实现目标:实现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);
}
同时编写布局代码:
<?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();
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;
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() ,用来获取view 的padding 数据,返回的是像素值,所以我们用到了将我们熟悉的dp 转换成像素的方法。
package com.example.mypractice;
import android.content.Context;
import android.util.TypedValue;
public class DisplayUtil {
public static int dip2px(Context context, float dpValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (dpValue * scale + 0.5f);
}
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);
int width;
int height;
if (widthMode == MeasureSpec.EXACTLY) {
width = widthSize;
} else {
width = (int) (getPaddingLeft() + textWidth + getPaddingRight());
}
if (heightMode == MeasureSpec.EXACTLY) {
height = heightSize;
} else {
float textHeight = mBound.height();
height = (int) (getPaddingTop() + textHeight + getPaddingBottom());
}
setMeasuredDimension(width, height);
}
再运行一次,你应该会看到wrap_content 达到了它应该的效果。
自动换行
思路
我们首先分析这个效果的实质,其实它就是在测量控件大小的时候,感知到文本长度大于屏幕宽度,导致文本显示不全,需要我们将其调整到下一行显示。 那么这个问题的核心就在于,如何进行感知,如何处理控件的高宽度以及如何将文本分割。
如何感知
这个问题需要我们拿到文本的长度,以及屏幕的宽度,对其进行比较即可。
如何处理控件的大小
首先,我们需要拿到文本长度与屏幕宽度,计算得出我们需要将文本分成几行显示,然后将控件宽度设为屏幕宽度,再拿到每行文本的高度,经过计算,得出控件应有的高度。最后,保存宽度与高度。
如何文本分割
简单的文本分割,我们都知道可以使用String 的split 或者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;
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);
splitText(widthSize);
int width;
int height;
if (widthMode == MeasureSpec.EXACTLY) {
width = widthSize;
} else {
if (isOneLine) {
width = (int) (getPaddingLeft() + textWidth + getPaddingRight());
} else {
width = widthSize;
}
}
if (heightMode == MeasureSpec.EXACTLY) {
height = heightSize;
} else {
float textHeight = mBound.height();
if (isOneLine) {
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
同样的一段文本,让我们看看我们的AutoTextView 和Textview 有什么区别。 可以看到,我们的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)));
}
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;
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
|