前言
在项目中我们常常继承AppCompatEditText
或EditText
自定义验证码输入框来代替系统输入框,以满足UI设计需求,如:
直线形输入框 | 方形输入框 |
---|---|
|
本文主要分析自定义验证码输入框过程中常被忽视的光标问题及个人的一点经验总结
onDraw方法一直被调用
我们在onDraw
方法中添加Log日志,发现onDraw
方法每间隔500ms左右被调用一次
此处先给出解决办法:
*当我们继承EditText自定义验证码输入框后,EditText自带的光标对我们来说不可见,已经没有意义,因此需要将其隐藏掉,防止onDraw方法一直被调用
代码语言:javascript复制isCursorVisible = false
问题分析
问题1:是什么方法一直在不停的调用onDraw方法呢?
我们知道invalidate
方法会触发页面重绘进而调用onDraw
方法,EditText
又继承TextView
,在TextView
源码中搜索invalidate
关键字然后加断点调试运行,最后将代码锁定在invalidateCursorPath
方法,发现此方法不停被调用,代码如下:
void invalidateCursorPath() {
if (mHighlightPathBogus) {
invalidateCursor();
} else {
final int horizontalPadding = getCompoundPaddingLeft();
final int verticalPadding = getExtendedPaddingTop() getVerticalOffset(true);
if (mEditor.mDrawableForCursor == null) {
synchronized (TEMP_RECTF) {
/*
* The reason for this concern about the thickness of the
* cursor and doing the floor/ceil on the coordinates is that
* some EditTexts (notably textfields in the Browser) have
* anti-aliased text where not all the characters are
* necessarily at integer-multiple locations. This should
* make sure the entire cursor gets invalidated instead of
* sometimes missing half a pixel.
*/
float thick = (float) Math.ceil(mTextPaint.getStrokeWidth());
if (thick < 1.0f) {
thick = 1.0f;
}
thick /= 2.0f;
// mHighlightPath is guaranteed to be non null at that point.
mHighlightPath.computeBounds(TEMP_RECTF, false);
invalidate((int) Math.floor(horizontalPadding TEMP_RECTF.left - thick),
(int) Math.floor(verticalPadding TEMP_RECTF.top - thick),
(int) Math.ceil(horizontalPadding TEMP_RECTF.right thick),
(int) Math.ceil(verticalPadding TEMP_RECTF.bottom thick));
}
} else {
final Rect bounds = mEditor.mDrawableForCursor.getBounds();
invalidate(bounds.left horizontalPadding, bounds.top verticalPadding,
bounds.right horizontalPadding, bounds.bottom verticalPadding);
}
}
}
此方法又调用了invalidateCursor
方法,代码如下:
void invalidateCursor() {
int where = getSelectionEnd();
invalidateCursor(where, where, where);
}
private void invalidateCursor(int a, int b, int c) {
if (a >= 0 || b >= 0 || c >= 0) {
int start = Math.min(Math.min(a, b), c);
int end = Math.max(Math.max(a, b), c);
invalidateRegion(start, end, true /* Also invalidates blinking cursor */);
}
}
接着看代码,invalidateCursor
方法又调用了invalidateRegion
方法,代码如下:
/**
* Invalidates the region of text enclosed between the start and end text offsets.
*/
void invalidateRegion(int start, int end, boolean invalidateCursor) {
if (mLayout == null) {
invalidate();
} else {
int lineStart = mLayout.getLineForOffset(start);
int top = mLayout.getLineTop(lineStart);
// This is ridiculous, but the descent from the line above
// can hang down into the line we really want to redraw,
// so we have to invalidate part of the line above to make
// sure everything that needs to be redrawn really is.
// (But not the whole line above, because that would cause
// the same problem with the descenders on the line above it!)
if (lineStart > 0) {
top -= mLayout.getLineDescent(lineStart - 1);
}
int lineEnd;
if (start == end) {
lineEnd = lineStart;
} else {
lineEnd = mLayout.getLineForOffset(end);
}
int bottom = mLayout.getLineBottom(lineEnd);
// mEditor can be null in case selection is set programmatically.
if (invalidateCursor && mEditor != null && mEditor.mDrawableForCursor != null) {
final Rect bounds = mEditor.mDrawableForCursor.getBounds();
top = Math.min(top, bounds.top);
bottom = Math.max(bottom, bounds.bottom);
}
final int compoundPaddingLeft = getCompoundPaddingLeft();
final int verticalPadding = getExtendedPaddingTop() getVerticalOffset(true);
int left, right;
if (lineStart == lineEnd && !invalidateCursor) {
left = (int) mLayout.getPrimaryHorizontal(start);
right = (int) (mLayout.getPrimaryHorizontal(end) 1.0);
left = compoundPaddingLeft;
right = compoundPaddingLeft;
} else {
// Rectangle bounding box when the region spans several lines
left = compoundPaddingLeft;
right = getWidth() - getCompoundPaddingRight();
}
invalidate(mScrollX left, verticalPadding top,
mScrollX right, verticalPadding bottom);
}
}
invalidateRegion
方法中调用了invaldate方法,用于在指定位置绘制光标,invalidateCursorPath->invalidateCursor->invalidateRegion->invalidate
,此时可以解答问题1了:是什么方法一直在不停的调用onDraw方法呢?
答案1:invalidateCursorPath方法一直被调用,最后导致onDraw方法被调用
问题2:什么方法在一直调用invalidateCursorPath方法呢?
继续分析,发现TextView中有一个setCursorVisible
方法,代码如下:
/**
* 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);
}
}
Blink实现了Runnable接口,对应的代码如下:
代码语言:javascript复制static final int BLINK = 500;
/**
* @return True when the TextView isFocused and has a valid zero-length selection (cursor).
*/
private boolean shouldBlink() {
if (!isCursorVisible() || !mTextView.isFocused()) return false;
final int start = mTextView.getSelectionStart();
if (start < 0) return false;
final int end = mTextView.getSelectionEnd();
if (end < 0) return false;
return start == end;
}
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;
}
}
在上面的代码里,我们惊喜的发现了mTextView.invalidateCursorPath()
这句代码,分析以上代码,重点关注 mTextView.postDelayed(this, BLINK);
这句代码,作用就是每间隔500ms就会执行TextView中的invalidateCursorPath
方法,此时我们大概明白了,EditText默认会显示光标,每间隔500ms就会绘制光标,造成光标不停闪烁的效果,哦,原来是这样,现在可以解答问题2了
答案2:Editor中Blink类的run方法每隔500ms会调用TextView中的invalidateCursorPath
方法
问题3:如何自定义验证码输入框光标?
虽然EditText自带的光标已经不能满足我们的需求,但我们可以参考其光标闪烁的源码,然后修改一下来满足我们的需求,重点是修改光标绘制时的显示位置
- 在控件可见时开启光标闪烁,控件不可见时取消光标闪烁
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
super.onWindowFocusChanged(hasWindowFocus)
if (hasWindowFocus) {
mBlink?.uncancel()
makeBlink()
} else {
mBlink?.cancel()
}
}
override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
super.onFocusChanged(focused, direction, previouslyFocusedRect)
if (focused) {
makeBlink()
}
}
makeBlink
等方法可以直接从android.widget.Editor
类中copy过来,此处不再贴代码了
- 在
onDraw
方法里绘制光标,重点是计算光标显示位置
private fun drawCursor(canvas: Canvas) {
if (!mCursorVisible) return
mCursorFlag = !mCursorFlag
if (mCursorFlag) {
if (mCursorDrawable == null && mCursorDrawableRes != 0) {
mCursorDrawable = context.getDrawable(mCursorDrawableRes)
}
mCursorDrawable?.apply {
val currentIndex = 0.coerceAtLeast(editableText.length)
val count = canvas.save()
val line = layout.getLineForOffset(selectionStart)
val top = layout.getLineTop(line)
val bottom = layout.getLineBottom(line)
val mTempRect = Rect()
getPadding(mTempRect)
bounds = Rect(0, top - mTempRect.top, intrinsicWidth, bottom mTempRect.bottom)
canvas.translate(
(mCodeWidth mCodeMargin) * currentIndex mCodeWidth / 2f - intrinsicWidth / 2f,
(mCodeHeight - bounds.height()) / 2f
)
draw(canvas)
canvas.restoreToCount(count)
}
}
}
答案3:参考android.widget.Editor
类中光标闪烁代码,修改光标显示位置相关代码,即可实现光标闪烁效果
GitHub
本文相关代码可在GitHub上获取,地址如下:
https://github.com/kongpf8848/ViewWorld
Android高级开发系统进阶笔记、最新面试复习笔记PDF,我的GitHub
文末
您的点赞收藏就是对我最大的鼓励! 欢迎关注我,分享Android干货,交流Android技术。 对文章有何见解,或者有何技术问题,欢迎在评论区一起留言讨论!