1. 背景说明
联系人字母导航条已经出来很多年都交互了,其UI组合无非是悬浮字母列表 侧边栏都字母选择(PinnedHeadList siderBar)。这里炒一下现饭,做了如下几个方面的事情。
- 实现基本的字母导航控件;
- 总结一下现有的开源导航控件的实现方案
- 最后,实现一个有意思的动画效果。
2. 实现原理分析
2.1 基本问题
这里右侧的导航条可能放到一个高度有限的界面,这样导航条的高度可能很矮。
这样字母占用空间的大小就需要提前设置好或者动态计算。
(1)提前设置好,Android的机型适配又是一个不小的工作;
一般从需求出发,界面上字体的大小需要提前设计好,不然用户也看不清,也不好操作,你乱设置一个自己认为靠谱的界面设计师也不答应。
(2)动态计算,这里需要有个靠谱的计算方法,而且不影响性能;
这里Android O中出了一个Autosizing特性,可以参考其使用:文字太多?控件太小?试试 TextView 的新特性 Autosizing 吧!
深入了解一下源码,实际上就是用二分法试出大小然后动态设置上去。参考,TextView 的新特性,Autosizing 到底是如何实现的? | 源码分析
这里,建议如果界面是全屏幕的导航化,直接使用第一种方案,效率会高一点,没有必要做这么多复杂的操作;这里,我们实现两套方案对比一下。
如图看出,字母导航条实际上就是一个自定义View的实现。要画的好看,主要要解决的问题如下:
- 画字母
计算整个字母导航条的高度;
计算每个字母的位置
合理设置字母的字号大小
- 处理触摸事件
计算触摸位置
设置回调
- 画放大的字母效果
计算绘制高宽
计算放大的字母位置
2.2 额外的需求
导航条中可能设置非字符,可能是图标,这就需要额外的绘制图片,我们循序渐进,先使用绘制字母的,后面逐步扩展。
3. 具体实现
根据上面的分析,分步骤实现对应的部分。
3.1 画字母
3.1.1 静态设置字母的大小
首先,要确定View的大小,一些开源的实现是将View直接布满整个屏幕(match_parent属性),然后在右侧进行绘制,事件处理在字母区域返回true,其他地方返回false,感觉没有必要,这里还是将wrap_content属性利用起来。
重写View的onMeasure方法,先看下super的默认实现
代码语言:javascript复制protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
代码语言:javascript复制public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}
如果自定义View没有重写onMeasure函数,MeasureSpec.AT_MOST跟MeasureSpec.AT_EXACTLY
的表现是一样的。也就是wrap_content就跟match_parent一个效果,占满全屏幕。
这里根据属性来设置字体的大小,后面讨论动态改变的方式。
代码语言:javascript复制@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
Rect rectBound = new Rect();
mPaint.getTextBounds("W",0,1,rectBound);
int w = rectBound.width() (int)padding;
int h = rectBound.height() (int)padding;
int defaultWidth = getPaddingLeft() w getPaddingRight();
int lettersHeight = ((mLetters==null) ? 0 : h * mLetters.length);
int defaultHeight = getPaddingTop() lettersHeight getPaddingBottom();
int width = measureHandler(widthMeasureSpec,defaultWidth);
int height = measureHandler(heightMeasureSpec,defaultHeight);
setMeasuredDimension(width,height);
}
private int measureHandler(int measureSpec,int defaultSize){
int result = defaultSize;
int measureMode = MeasureSpec.getMode(measureSpec);
int measureSize = MeasureSpec.getSize(measureSpec);
if(measureMode == MeasureSpec.EXACTLY){
result = measureSize;
}else if(measureMode == MeasureSpec.AT_MOST){
result = Math.min(defaultSize,measureSize);
}
return result;
}
其中,mPaint为Paint对象初始化view的时候通过属性设置了字号的大小,将字母“W”作为参考字母,计算他的宽和高度。加一个padding的属性,这样得到一个默认的高度(defaultHeight)和宽度(defaultWidth)。
然后MeasureSpec得到父亲控件的高和宽,根据类型,EXACTLY(match_parent或者精确值),AT_MOST
(wrap_content)设置为View正真的高和宽。
然后就可以画出字母,其中mHeight为实际View高度,在onLayout中(mHeight = getHeight())
代码语言:javascript复制private void drawLetters(Canvas canvas){
if(mLetters == null){
return;
}
//字母的个数
int len = mLetters.length;
//单个字母的高度
int singleHeight = mHeight/len;
for (int i = 0; i < len; i ) {
//计算位置
Paint tempPaint = null;
if(i == mChoose && isTouch){
tempPaint = mSelectedPaint;
}else{
tempPaint = mPaint;
}
//要画的字母的x,y坐标
float x = mWidth / 2;
float y = singleHeight*(i 1)- tempPaint.measureText(mLetters[i])/2;
//画字母
canvas.drawText(mLetters[i], x, y, tempPaint);
}
}
3.1.2 动态设置字母的大小
3.2 触摸事件处理
确定点击的位置是否在字母导航条的上面,(int)(y/mHeight * mLetters.length)即可得到选中字母的位置。其中,y为action的坐标。
代码语言:javascript复制@Override
public boolean onTouchEvent(MotionEvent event) {
final int action = event.getAction();
final float y = event.getY();
final int oldChoose = mChoose;
//当前选中字母的索引
final int newChoose = (int)(y/mHeight * mLetters.length);
switch (action) {
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
isTouch = false;
mChoose = -1;
if (listener != null) {
listener.onLetterTouching(false);
}
invalidate();
return true;
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
if (oldChoose != newChoose) {
if (mLetters != null
&& newChoose >= 0 && newChoose < mLetters.length) {
mChoose = newChoose;
if (listener != null) {
//计算位置
Rect rect = new Rect();
mPaint.getTextBounds(mLetters[mChoose], 0, mLetters[mChoose].length(), rect);
//字母的个数
int len = mLetters.length;
//单个字母的高度
int singleHeight = mHeight / len;
float yPos = singleHeight * (mChoose 1) - mPaint.measureText(mLetters[mChoose]) / 2;
listener.onLetterChanged(mLetters[newChoose], action, yPos);
}
}
invalidate();
}
if (event.getAction() == MotionEvent.ACTION_DOWN) {//按下调用 onLetterDownListener
isTouch = true;
if (listener != null) {
listener.onLetterTouching(true);
}
}
return true;
default:
return false;
}
}
3.3 选中放大效果
选中效果可以简单到画出来,当然这样效率比较高。设计师一般要给你切一个他喜欢到图,没办法,绘制图上去吧。如下图,添加放大选图效果,这样整体高度需要加上图片的高度,字母起始的位置也要变化。
代码语言:javascript复制@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
Rect rectBound = new Rect();
mPaint.getTextBounds("W",0,1,rectBound);
letterWidth = rectBound.width() (int)padding;
letterHeight = rectBound.height() (int)padding;
int defaultWidth = getPaddingLeft() letterWidth getPaddingRight() mTipsWidth;
int lettersHeight = ((mLetters==null) ? 0 : letterHeight * mLetters.length);
int defaultHeight = getPaddingTop() lettersHeight getPaddingBottom() mTipsHeight;
int width = measureHandler(widthMeasureSpec,defaultWidth);
int height = measureHandler(heightMeasureSpec,defaultHeight);
setMeasuredDimension(width,height);
}
于是,这里默认的高和宽加上图片的高度和宽度。这样背景的显示无论如何都放得进去。startY要向下偏移半个字母都高度
绘制提示字母到背景上,这个图片不是标准到左右对称,所以中心要偏左一点
代码语言:javascript复制//draw tips letter
Rect rect = new Rect();
mTipsTextPaint.getTextBounds(mLetters[mChoose], 0, mLetters[mChoose].length(), rect);
float startTipsX = (int)(mTipsWidth* 4.0 / 7.0f - rect.width()/2.0);
float startTipsY = (int) (startTop mTipsHeight / 2.0f rect.height()/2.0);
canvas.drawText(mLetters[mChoose], startTipsX, startTipsY, mTipsTextPaint);
代码语言:javascript复制
private boolean isValid(float x, float y){
if(x >= mTipsWidth && x <= mWidth
&& y > mTipsHeight/2f && (y < mHeight - mTipsHeight/2f)){
return true;
}else {
return false;
}
}
代码语言:javascript复制case MotionEvent.ACTION_DOWN:
boolean isValid = isValid(x, y);
if(!isValid){
return super.onTouchEvent(event);
}
这里触摸事件加上位置都判断,防止误触;
以上就是字母导航基本的实现的几个方面。
4. 开源的一些实现分析
我们看下开源的实现,基本上都逃不过上面几个方面,我们稍微分析一下:
(1)https://github.com/zaaach/CityPicker start(2446 )
- 按照字母个数均分屏幕高度,计算topMargin的计算代码很奇怪。
- 没有画放大效果,而是在外面的view上设置
- 代码一般,星星多可能是因为pinHead效果
(2)https://github.com/saiwu-bigkoo/Android-QuickSideBar start(601 )
- 右侧垂直居中绘制字母,字母大小由属性设置
- 放大效果的提示由额外的布局实现,这里用path绘制了一个特殊的圆角矩形(addRoundRect(RectF rect, float[] radii, Direction dir))
- tipsView用addView的方式,绘制效率没那么高,右侧字母设置和位置摆放比较固定
(3)https://github.com/gjiazhe/WaveSideBar (start 1126)
- 字母垂直居中显示,字母大小为属性设置,默认14sp,根据字体大小计算每个字母高度;
- 凸显放大效果,根据位置计算上下各4个,计算偏移位置和缩放,根据点击位置然后绘制;
- 整体代码还比较工整。
(4)https://github.com/Solartisan/WaveSideBar (start 1063)
- 字母整体高度为右侧垂直居中,通过属性获取字体大小计算高度,可以设置Padding
- drawRoundRect画了一个字母的背景,感觉好看
- 选中的波浪用了三段二次贝塞尔曲线(Path类的 quadTo方法),通过控制贝塞尔控制点的x坐标的宽度来展示动画效果,用Path绘制放大的小球
- 加入动画效果ValueAnimator
- 代码还是比较工整
这些开源实现,都没有考虑到在有限的高度内,自适应字母的高度;当然一般字母导航的界面需要在全屏幕来展示,在半个屏幕来展示全屏的情况很少,比如我就遇到了。参考一下源码的二分法
代码语言:javascript复制private boolean updateTextSize(int heightMeasureSize, int needSize){
if(mLetters == null){
return false;
}
if(heightMeasureSize >= needSize){
return false;
}
float fitTextSize = findLargestTextSizeWhichFits(heightMeasureSize, needSize);
mPaint.setTextSize(fitTextSize);
return true;
}
private float findLargestTextSizeWhichFits(int heightMeasureSize, int needSize){
float curSize = mPaint.getTextSize();
float minSize = 1;
float maxSize = 100;
int measuredHeight = heightMeasureSize - (getPaddingTop() getPaddingBottom());
while (minSize < maxSize) {
curSize = (minSize maxSize) / 2;
if (calHeightOnMeasure(curSize) <= measuredHeight) {
minSize = curSize 1;
} else {
maxSize = curSize - 1;
}
}
return curSize;
}
private int calHeightOnMeasure(float textSize) {
int result = 0;
int indexSize = mLetters.length;
final Paint textPaint = mPaint;
textPaint.setTextSize(textSize);
textPaint.setAntiAlias(true);
Rect bound = new Rect();
for (int i = 0; i < indexSize; i ) {
textPaint.getTextBounds(mLetters[i], 0, mLetters[i].length(), bound);
int height = (bound.bottom - bound.top);
result = height;
result = (int)padding;
}
result = result mTipsHeight;
return result;
}
修改后看下代码,确实自适应了,当然这里源码的功能是在一个预设值列表里面进行二分,性能要好点,功能更强大点,后面有需要可以自己加上,这里只是实现最小功能,取个最大和最小值1和100
具体见源代码:https://github.com/xiaopengs/IndexBar
5. 加个自己的动画
加个流行的粘性贝塞尔动画