联系人字母导航集大成者

2019-08-09 17:22:24 浏览数 (1)

1. 背景说明

联系人字母导航条已经出来很多年都交互了,其UI组合无非是悬浮字母列表 侧边栏都字母选择(PinnedHeadList siderBar)。这里炒一下现饭,做了如下几个方面的事情。

  • 实现基本的字母导航控件;
  • 总结一下现有的开源导航控件的实现方案
  • 最后,实现一个有意思的动画效果。

最终效果最终效果

2. 实现原理分析

2.1 基本问题

这里右侧的导航条可能放到一个高度有限的界面,这样导航条的高度可能很矮。

这样字母占用空间的大小就需要提前设置好或者动态计算。

(1)提前设置好,Android的机型适配又是一个不小的工作;

一般从需求出发,界面上字体的大小需要提前设计好,不然用户也看不清,也不好操作,你乱设置一个自己认为靠谱的界面设计师也不答应。

(2)动态计算,这里需要有个靠谱的计算方法,而且不影响性能;

这里Android O中出了一个Autosizing特性,可以参考其使用:文字太多?控件太小?试试 TextView 的新特性 Autosizing 吧!

android O Autosizingandroid O Autosizing

深入了解一下源码,实际上就是用二分法试出大小然后动态设置上去。参考,TextView 的新特性,Autosizing 到底是如何实现的? | 源码分析

这里,建议如果界面是全屏幕的导航化,直接使用第一种方案,效率会高一点,没有必要做这么多复杂的操作;这里,我们实现两套方案对比一下。

图1 导航绘制图图1 导航绘制图

如图看出,字母导航条实际上就是一个自定义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 )

https://github.com/zaaach/CityPicker/blob/master/citypicker/src/main/java/com/zaaach/citypicker/view/SideIndexBar.javahttps://github.com/zaaach/CityPicker/blob/master/citypicker/src/main/java/com/zaaach/citypicker/view/SideIndexBar.java
  • 按照字母个数均分屏幕高度,计算topMargin的计算代码很奇怪。
  • 没有画放大效果,而是在外面的view上设置
  • 代码一般,星星多可能是因为pinHead效果

(2)https://github.com/saiwu-bigkoo/Android-QuickSideBar start(601 )

https://github.com/saiwu-bigkoo/Android-QuickSideBar/blob/master/quicksidebar/src/main/java/com/bigkoo/quicksidebar/QuickSideBarView.javahttps://github.com/saiwu-bigkoo/Android-QuickSideBar/blob/master/quicksidebar/src/main/java/com/bigkoo/quicksidebar/QuickSideBarView.java
  • 右侧垂直居中绘制字母,字母大小由属性设置
  • 放大效果的提示由额外的布局实现,这里用path绘制了一个特殊的圆角矩形(addRoundRect(RectF rect, float[] radii, Direction dir))
  • tipsView用addView的方式,绘制效率没那么高,右侧字母设置和位置摆放比较固定

(3)https://github.com/gjiazhe/WaveSideBar (start 1126)

https://github.com/gjiazhe/WaveSideBar/blob/master/wavesidebar/src/main/java/com/gjiazhe/wavesidebar/WaveSideBar.javahttps://github.com/gjiazhe/WaveSideBar/blob/master/wavesidebar/src/main/java/com/gjiazhe/wavesidebar/WaveSideBar.java
  • 字母垂直居中显示,字母大小为属性设置,默认14sp,根据字体大小计算每个字母高度;
  • 凸显放大效果,根据位置计算上下各4个,计算偏移位置和缩放,根据点击位置然后绘制;
  • 整体代码还比较工整。

(4)https://github.com/Solartisan/WaveSideBar (start 1063)

https://github.com/Solartisan/WaveSideBar/blob/master/wave/src/main/java/cc/solart/wave/WaveSideBarView.javahttps://github.com/Solartisan/WaveSideBar/blob/master/wave/src/main/java/cc/solart/wave/WaveSideBarView.java
  • 字母整体高度为右侧垂直居中,通过属性获取字体大小计算高度,可以设置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

修改indexBar高度为400dp修改indexBar高度为400dp

具体见源代码:https://github.com/xiaopengs/IndexBar

5. 加个自己的动画

加个流行的粘性贝塞尔动画

动画示意图动画示意图
最终效果最终效果

0 人点赞