/ 今日科技快讯 /
近日,据外媒报道,字节跳动少数美国投资者正在与该公司的高层管理人员进行讨论,有意联合收购短视频平台TikTok(抖音海外版)的多数股权。这个想法只是字节跳动正在研究的一种可能情况,该公司正在探索如何应对美国可能对该应用实施的封禁或强制剥离。之前有报道称,字节跳动创始人兼首席执行官张一鸣表示,如果对TikTok的未来有利,他愿意出售该应用。
/ 作者简介 /
本篇文章来自关关雎鸠在河之洲_的投稿,教大家如何实现自定义密码输入框,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章!
关关雎鸠在河之洲_的博客地址:
https://juejin.im/user/598a7c17f265da3e3a0bf3f2
/ 前言 /
首先感谢点开文章的您!在下只是从事Android开发的一名小菜鸡,这是小的我第一次写技术文章,第一次学习着发布一个开源库(在看文章的您,您应该感到荣幸,因为我的“第一次”给了您,哈哈哈!),希望各路大神大佬别嫌弃,请给予在下多多的鼓励......在学习的道路上永无止境!感觉很多东西自己亲身经历了实践了,才是一种财富。感谢!感谢!万分感谢!!
/ 正文 /
废话就不多说,不管怎么样先给地址,先上效果图......哈哈哈
https://github.com/Chen-keeplearn/SplitEditTextView
看完效果图后,此时可能读者大人心里一万只什么马飞驰而过。的确,这个这么简单的东西,就是一个单纯的绘制矩形框、绘制线条、绘制圆、绘制文字。就像前言所说,大佬不喜勿喷,完全可以忽略,与此同时小的我也很乐意接受批评和指导,感谢!其实就比如很久以前的我,以及还有没怎么用过自定义view的朋友,看到这个效果也不明白怎么实现(透露句真话,在写这个之前,我还真不知道有一个叫InputFilter的东西,对不起,让大家见笑了)......
/ 效果分析 /
首先自定义时,继承EditText应该是最合适的,我想大家都会认为这种输入效果继承其它View,甚至ViewGroup都是不合适的。 其中输入框效果样式分为3种:
有分割线的连接在一起的输入框;
分开的单个输入框;
分开的下划线;
输入内容的显示分为2种:
明文的文本内容;
内容显示为实心小圆圈;
在自定义的属性文件中定义了以上两种的枚举类型的属性。
就像面前所说,这个自定义无非就是绘制线条、绘制圆角矩形、绘制文字等,其它属性也无非就是边框大小、边框颜色、线条宽度、线条颜色、圆角大小、字体大小、字体颜色、间距等等,这里就不一一列举。
/ 具体实现 /
初始化与获取自定义属性就不说了,这里说下我定义了不同的Paint画笔对象,各自负责各自的绘制工作,各司其职。当绘制多了之后,颜色、宽度等等的改变可能什么时候该设置什么样的颜色和画笔宽度都不清楚了,也会造成代码冗余。
@Override
protected void onDraw(Canvas canvas) {
//绘制输入框
switch (mInputBoxStyle) {
case INPUT_BOX_STYLE_SINGLE:
drawSingleStyle(canvas);
break;
case INPUT_BOX_STYLE_UNDERLINE:
drawUnderlineStyle(canvas);
break;
case INPUT_BOX_STYLE_CONNECT:
default:
drawConnectStyle(canvas);
break;
}
//绘制输入框内容
drawContent(canvas);
//绘制光标
drawCursor(canvas);
}
在onDraw方法里面通过自定义的输入框样式的属性来绘制不同的输入框。
绘制分割线相连样式的输入框
private void drawConnectStyle(Canvas canvas) {
//每次重新绘制时,先将rectF重置下
mRectFConnect.setEmpty();
//需要减去边框的一半
mRectFConnect.set(
mBorderSize / 2,
mBorderSize / 2,
getWidth() - mBorderSize / 2,
getHeight() - mBorderSize / 2
);
canvas.drawRoundRect(mRectFConnect, mCornerSize, mCornerSize, mPaintBorder);
//绘制分割线
drawDivisionLine(canvas);
}
通过canvas.drawRoundRect()绘制圆角矩形。
绘制圆角和非圆角矩形并没有通过canvas.drawRoundRect()和canvas.drawRect()这个两个函数分别来绘制,而是直接在canvas.drawRoundRect()传入的圆角大小参数时决定,mCornerSize为0则是没有带圆角的。
同时mRectFConnect是分割线相连矩形输入框的RectF对象,在初始化的时候已经创建(后面绘制单个矩形输入框也是一样),这里并没有在onDraw里面创建。
我们应该知道在onDraw里面创建对象是不被建议的,说不定你的内存抖动就是与onDraw里面创建对象有关。
另外绘制矩形时的l,t,r,b四个参数都是去掉了边框宽度的一半(mBorderSize/2),不然会有误差,那是因为在绘制矩形以及其它图形的时候,矩形(图形)的边界是边框的中心,不是边框的边界。
绘制分割线
/**
* 分割线条数为内容框数目-1
*/
private void drawDivisionLine(Canvas canvas) {
float stopY = getHeight() - mBorderSize;
for (int i = 0; i < mContentNumber - 1; i++) {
//对于分割线条,startX = stopX
float startX = (i + 1) * getContentItemWidth() + i * mDivisionLineSize + mBorderSize + mDivisionLineSize / 2;
canvas.drawLine(startX, mBorderSize, startX, stopY, mPaintDivisionLine);
}
}
调用canvas.drawLine()即可,分割线数目应当是输入内容框数目mContentNumber-1,那么就在一个循环里面绘制线条。
对于canvas.drawLine()中的startY和stopY,分割线是紧贴View内部的,所以应该减去一个边框的宽度mBorderSize,而不是mBorderSize/2。
canvas.drawLine()中的startX和stopX,对于分割线来说 startX = stopX,绘制后面的分割线时,还应该+前面的输入框宽度,+左侧的边框宽度,+分割线宽度的一半,即:float startX = (i + 1) * getContentItemWidth() + i * mDivisionLineSize + mBorderSize + mDivisionLineSize / 2;
计算字符输入框item的宽度
/**
* 计算3种样式下,相应每个字符item的宽度
*/
private float getContentItemWidth() {
//计算每个密码字符所占的宽度,每种输入框样式下,每个字符item所占宽度也不一样
float tempWidth;
switch (mInputBoxStyle) {
case INPUT_BOX_STYLE_SINGLE:
//单个输入框样式:宽度-间距宽度(字符数-1)*每个间距宽度-每个输入框的左右边框宽度
tempWidth = getWidth() - (mContentNumber - 1) * mSpaceSize - 2 * mContentNumber * mBorderSize;
break;
case INPUT_BOX_STYLE_UNDERLINE:
//下划线样式:宽度-间距宽度(字符数-1)*每个间距宽度
tempWidth = getWidth() - (mContentNumber - 1) * mSpaceSize;
break;
case INPUT_BOX_STYLE_CONNECT:
//矩形输入框样式:宽度-左右两边框宽度-分割线宽度(字符数-1)*每个分割线宽度
default:
tempWidth = getWidth() - (mDivisionLineSize * (mContentNumber - 1)) - 2 * mBorderSize;
break;
}
return tempWidth / mContentNumber;
}
每个内容输入框itemWidth的宽度就是View的宽度-边框宽度-间距宽度or-分割线宽度。
绘制单个输入框样式
/**
* 绘制单框输入模式
* 这里计算left、right时有点饶,
* 理解、计算时最好根据图形、参照drawConnectStyle()绘制带边框的矩形
*/
private void drawSingleStyle(Canvas canvas) {
for (int i = 0; i < mContentNumber; i++) {
mRectFSingleBox.setEmpty();
float left = i * getContentItemWidth() + i * mSpaceSize + i * mBorderSize * 2 + mBorderSize / 2;
float right = i * mSpaceSize + (i + 1) * getContentItemWidth() + (i + 1) * 2 * mBorderSize - mBorderSize / 2;
//为避免在onDraw里面创建RectF对象,这里使用rectF.set()方法
mRectFSingleBox.set(left, mBorderSize / 2, right, getHeight() - mBorderSize / 2);
canvas.drawRoundRect(mRectFSingleBox, mCornerSize, mCornerSize, mPaintBorder);
}
}
首先按照前面所讲canvas.drawRoundRect()函数中,mRectFSingleBox矩形的startY和stopY没什么可以说的,减去边框的一半,即:t = mBorderSize / 2,b = getHeight() - mBorderSize / 2 这里主要是l和r的计算对于l,绘制带边框的矩形等图形时,去掉边框的一半即 + mBorderSize /2,同时加上每个字符item的间距 + i * mSpaceSize另外,每个字符item的宽度 + i * itemWidth最后,绘制时都是以整个view的宽度计算,绘制第N个时,都应该加上以前的边框宽度。
即第一个:i = 0 ,边框的宽度为0,第二个:i = 1,边框的宽度 2 * mBorderSize,左右两个的边框宽度。以此......最后应该 + i * 2 * mBorderSize。即 left = i * getContentItemWidth() + i * mSpaceSize + i * mBorderSize * 2 + mBorderSize / 2;
同理right:
去掉边框的一半:-mBorderSize/2,还应该加上前面一个item的宽度:+(i+1)itemWidth同样,绘制时都是以整个view的宽度计算,绘制后面的,都应该加上前面的所有宽度,即 间距:+i * mSpaceSize。边框:(注意是计算整个view)
第一个:i = 0,2个边框2 * mBorderSize;
第二个:i = 1,4个边框,即 (1+1) * 2 * mBorderSize
所以算上边框 +(i+1) * 2 * mBorderSize。
即 right = i * mSpaceSize + (i + 1) * getContentItemWidth() + (i + 1) * 2 * mBorderSize - mBorderSize / 2;
绘制下划线输入框样式
/**
* 绘制下划线输入框样式
*/
private void drawUnderlineStyle(Canvas canvas) {
for (int i = 0; i < mContentNumber; i++) {
//计算绘制下划线的startX
float startX = i * getContentItemWidth() + i * mSpaceSize;
//stopX
float stopX = getContentItemWidth() + startX;
//对于下划线这种样式,startY = stopY
float startY = getHeight() - mBorderSize / 2;
canvas.drawLine(startX, startY, stopX, startY, mPaintBorder);
}
}
canvas.drawLine()函数中,线条起点startX:每个字符所占宽度itemWidth + 每个字符item之间的间距mSpaceSize;线条终点stopX:stopX与startX之间就是一个itemWidth的宽度。
绘制输入内容
/**
* 根据输入内容显示模式,绘制内容是圆心还是明文的text
*/
private void drawContent(Canvas canvas) {
int cy = getHeight() / 2;
String password = getText().toString().trim();
if (mContentShowMode == CONTENT_SHOW_MODE_PASSWORD) {
mPaintContent.setColor(Color.BLACK);
for (int i = 0; i < password.length(); i++) {
float startX = getDrawContentStartX(i);
canvas.drawCircle(startX, cy, mCircleRadius, mPaintContent);
}
} else {
mPaintContent.setColor(mTextColor);
//计算baseline
float baselineText = getTextBaseline(mPaintContent, cy);
for (int i = 0; i < password.length(); i++) {
float startX = getDrawContentStartX(i);
//计算文字宽度
String text = String.valueOf(password.charAt(i));
float textWidth = mPaintContent.measureText(text);
//绘制文字x应该还需要减去文字宽度的一半
canvas.drawText(text, startX - textWidth / 2, baselineText, mPaintContent);
}
}
}
根据自定义属性mContentShowMode来决定是绘制实心圆心还是文字内容;另外都需要先计算cx和startX,然而不同输入框样式下,计算出来的cx和startX是有区别的,绘制圆的cy和绘制文字的baseline不用说了,cy = View高度的一半 getHeight() / 2,计算baseline有很多好的文章可以去理解,可以当作固定公式来计算,大家可以自行搜索。
这里提醒下,当绘制文字的startX计算出来时,应该还需要减去文字一半的宽度。计算文字宽度时,也有几种方式,这里用了比较简便的一种。
计算绘制圆和文字的startX坐标
/**
*
* 计算三种输入样式下绘制圆和文字的x坐标
*
* @param index 循环里面的下标 i
*/
private float getDrawContentStartX(int index) {
switch (mInputBoxStyle) {
case INPUT_BOX_STYLE_SINGLE:
//单个输入框样式下的startX
//即 itemWidth/2 + i*itemWidth + i*每一个间距宽度 + 前面所有的左右边框
// i = 0,左侧1个边框
// i = 1,左侧3个边框(一个完整的item的左右边框+ 一个左侧边框)
// i = ..., (2*i+1)*mBorderSize
return getContentItemWidth() / 2 + index * getContentItemWidth() + index * mSpaceSize + (2 * index + 1) * mBorderSize;
case INPUT_BOX_STYLE_UNDERLINE:
//下划线输入框样式下的startX
//即 itemWidth/2 + i*itemWidth + i*每一个间距宽度
return getContentItemWidth() / 2 + index * mSpaceSize + index * getContentItemWidth();
case INPUT_BOX_STYLE_CONNECT:
//矩形输入框样式下的startX
//即 itemWidth/2 + i*itemWidth + i*分割线宽度 + 左侧的一个边框宽度
default:
return getContentItemWidth() / 2 + index * getContentItemWidth() + index * mDivisionLineSize + mBorderSize;
}
}
绘制时的startX肯定都是从每个item输入框的正中心开始,只是需要注意的是加上相应的间距、item宽度、边框宽度、分割线宽度,这里不做过多解释,可以看下代码里面的注释。
绘制光标
/**
* 绘制光标
* 光标只有一个,所以不需要根据循环来绘制,只需绘制第N个就行
* 这里光标的长度默认就是 height/2
*/
private void drawCursor(Canvas canvas) {
if (mCursorHeight > getHeight()) {
throw new InflateException("cursor height must smaller than view height");
}
String content = getText().toString().trim();
float startX = getDrawContentStartX(content.length());
//如果设置得有光标高度,那么startY = (高度-光标高度)/2+边框宽度
if (mCursorHeight == 0) {
mCursorHeight = getHeight() / 2;
}
int sy = (getHeight() - mCursorHeight) / 2;
float startY = sy + mBorderSize;
float stopY = getHeight() - sy - mBorderSize;
//此时的绘制光标竖直线,startX = stopX
canvas.drawLine(startX, startY, startX, stopY, mPaintCursor);
}
绘制光标也就是绘制一线条,光标就只需绘制一个,所以也没有循环了。
当输入内容长度为0,光标在第0个位置;当输入内容长度为1,光标应在第1个位置;所以光标所在位置为输入内容的长度。
这里绘制光标和前面绘制圆和文字同出一辙,计算startX时,直接调用前面getDrawContentStartX()方法,当然这里的光标线条startX和stopX相等的。
光标的高度默认是View高度的一半,首先,需要算边框的宽度,然后还要去掉,View高度减去光标高度只有的一半。
到这里绘制工作基本上是完成了,但是还有一些问题没有处理,比如光标的闪烁,内容输入完成之后的处理(不然用户输入完就输入完,然后就没有然后了么)
完善存在问题
光标的闪烁
光标闪烁的这个问题,其实应该最容易想到的就是每隔多少秒时间进行重绘......似乎好像官方也是这么操作的?
接下来我们看看 setCursorVisible(fasle);方法,这个方法其实我是在初始化的时候设置的,把默认的光标给关闭。
/**
* Set whether the cursor is visible. The default is true. Note that this property only
* makes sense for editable TextView.
*
* @see #isCursorVisible()
*
* @attr ref android.R.styleable#TextView_cursorVisible
*/
@android.view.RemotableViewMethod
public void setCursorVisible(boolean visible) {
if (visible && mEditor == null) return; // visible is the default value with no edit data
createEditorIfNeeded();
if (mEditor.mCursorVisible != visible) {
mEditor.mCursorVisible = visible;
invalidate();
mEditor.makeBlink();
// InsertionPointCursorController depends on mCursorVisible
mEditor.prepareCursorControllers();
}
}
接下来再进入mEditor.makeBlink();方法。
void makeBlink() {
if (shouldBlink()) {
mShowCursor = SystemClock.uptimeMillis();
if (mBlink == null) mBlink = new Blink();
mTextView.removeCallbacks(mBlink);
mTextView.postDelayed(mBlink, BLINK);
} else {
if (mBlink != null) mTextView.removeCallbacks(mBlink);
}
}
private class Blink implements Runnable {
private boolean mCancelled;
public void run() {
if (mCancelled) {
return;
}
mTextView.removeCallbacks(this);
if (shouldBlink()) {
if (mTextView.getLayout() != null) {
mTextView.invalidateCursorPath();
}
mTextView.postDelayed(this, BLINK);
}
}
void cancel() {
if (!mCancelled) {
mTextView.removeCallbacks(this);
mCancelled = true;
}
}
void uncancel() {
mCancelled = false;
}
}
然后读者大人您就会看到上面这个两个方法;Blink?翻译为眨眼、闪烁,是一个Runnable,所以我同样好不犹豫的也定义了一个Runnable。
毕竟,“上天定的,最大嘛”,“上天定的,还不够你臭美啊”。
/**
* 光标Runnable
* 通过Runnable每500ms执行重绘,每次runnable通过改变画笔的alpha值来使光标产生闪烁的效果
*/
private class CursorRunnable implements Runnable {
@Override
public void run() {
//获取光标画笔的alpha值
int alpha = mPaintCursor.getAlpha();
//设置光标画笔的alpha值
mPaintCursor.setAlpha(alpha == 0 ? 255 : 0);
invalidate();
postDelayed(this, mCursorDuration);
}
}
@Override
protected void onAttachedToWindow() {
super.onAttachedToWindow();
cursorRunnable = new CursorRunnable();
postDelayed(cursorRunnable, mCursorDuration);
}
@Override
protected void onDetachedFromWindow() {
removeCallbacks(cursorRunnable);
super.onDetachedFromWindow();
}
同时复写了onAttachedToWindow()和onDetachedFromWindow()这个两个方法,分别在里面发送 Runnable 移除 Runnable。
再来说说Runnable里面的实现,一开始我是没有通过设置Paint的alpha值,直接调用invalidate();但是你可能会发现一样不能实现光标闪烁的问题,当时的我也不知道怎么搞了,前面源码中Blink的invalidateCursorPath()跟进去看了也没看个明白,最后我选择了百度 “自定义光标闪烁”类似这样的关键词,找到了一篇文章,里面就是说利用Piant的alpha值来实现。
输入完之后的处理
其实一开始我定义了个接口,然后在drawContent的时候用过输入的长度和输入框数量判断来调取,读者大人们肯定会发现这样的问题。这样处理不好之处就是onDraw多次调用,接口定义的方法也是会多次不停的调用,还好EditText本身就有对内容的监听的方法onTextChanged,当然这个是TextView里面的。
/**
* 通过复写onTextChanged来实现对输入的监听
* 如果在onDraw里面监听text的输入长度来实现,会重复的调用该方法,就不妥当
*/
@Override
protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter) {
super.onTextChanged(text, start, lengthBefore, lengthAfter);
String content = text.toString().trim();
if (inputListener != null) {
if (content.length() == mContentNumber) {
inputListener.onInputFinished(content);
} else {
inputListener.onInputChanged(content);
}
}
}
然后我这边自己定义了一个OnInputListener的抽象类。
/**
* 输入的监听抽象类
* 没定义接口的原因是可以在抽象类里面定义空实现的方法,可以让用户根据需求选择性的复写某些方法
*/
public abstract class OnInputListener {
/**
* 输入完成的抽象方法
* @param content 输入内容
*/
public abstract void onInputFinished(String content);
/**
* 输入的内容
* 定义一个空实现方法,让用户选择性的复写该方法,需要就复写,不需要就不用重写
*/
public void onInputChanged(String text){
}
}
没有定义为一个interface的原因,注释里面也说了。
确保每个内容输入框为正方形
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (mInputBoxSquare) {
int width = MeasureSpec.getSize(widthMeasureSpec);
//计算view高度,使view高度和每个item的宽度相等,确保每个item是一个正方形
float itemWidth = getContentItemWidthOnMeasure(width);
switch (mInputBoxStyle) {
case INPUT_BOX_STYLE_UNDERLINE:
setMeasuredDimension(width, (int) (itemWidth + mBorderSize));
break;
case INPUT_BOX_STYLE_SINGLE:
case INPUT_BOX_STYLE_CONNECT:
default:
setMeasuredDimension(width, (int) (itemWidth + mBorderSize * 2));
break;
}
}
}
当然也可以不是正方形的,在onMeasure的时候通过自定义的属性加了个判断,这里将通过getContentItemWidthOnMeasure()计算3种输入框样式下itemWidth,然后将高度设置成itemWidth。
对外暴露的setInputBoxStyle()方法
/**
* 设置输入框样式
*/
public void setInputBoxStyle(int inputBoxStyle) {
if (inputBoxStyle != INPUT_BOX_STYLE_CONNECT
&& inputBoxStyle != INPUT_BOX_STYLE_SINGLE
&& inputBoxStyle != INPUT_BOX_STYLE_UNDERLINE
) {
throw new IllegalArgumentException(
"the value of the parameter must be one of" +
"{1:INPUT_BOX_STYLE_CONNECT}, " +
"{2:INPUT_BOX_STYLE_SINGLE} or " +
"{3:INPUT_BOX_STYLE_UNDERLINE}"
);
}
mInputBoxStyle = inputBoxStyle;
// 这里没有调用invalidate因为会存在问题
// invalidate会重绘,但是不会去重新测量,当输入框样式切换的之后,item的宽度其实是有变化的,所以此时需要重新测量
// requestLayout,调用onMeasure和onLayout,不一定会调用onDraw,当view的l,t,r,b发生改变时会调用onDraw
requestLayout();
//invalidate();
}
重点是后面调用的invalidate();因为在切换了输入框样式后,每种输入框样式下,每个内容输入框的itemWidth的是不一样的,所以在切换的时候调用invalidate()会存在问题,因为invalidate()只负责重绘,并没有对View再进行测量,所以这里调用了requestLayout();
InputFilter
setFilters(new InputFilter[]{new InputFilter.LengthFilter(mContentNumber)});
设置InputFilter,设置输入的最大字符长度为设置的长度。
/ 写在最后 /
其实就像文章开头说的,主要就是绘制矩形、线条、文字的过程,最重要的就是细节的计算、主要是绘制时的startX坐标,包括边框、分割线、间距等等。
小的我也是推翻自己一次又一次认为正确的计算方式,哈哈哈当在计算时,比如边框什么的,有时候1dp,2dp这样是看不出效果的,当设置个10dp,20dp较大的值可能会更容易一些,还有个重要的因为就是要多结合图形。
啰嗦了那么多,终于可以算结束了,读者大人有什么建议、批评、指导、交流,欢迎留言,小的我感谢万分!经过这第一次的文章编写,发现真的写篇文章还是不容易的,但是其中的收获是否只有自己才明白。就像文章前言说的,很多东西似乎当自己亲身经历和实践了,就是一种财富。
最后真心地谢谢看过本文的各位大佬,像小的我这种第一次写文,发布开源库的小菜鸡真的很希望得到大家的支持。
谢谢郭神,看了郭神《第一行代码》第三版的内容,跟着学习发布了一个开源库。
推荐阅读:
我的新书,《第一行代码 第3版》已出版!
看完这篇JVM内存管理机制,面试再也不慌了!
重学Kotlin中那些你没注意到的细节
欢迎关注我的公众号
学习技术或投稿
长按上图,识别图中二维码即可关注