现在很多App的地区选择或者联系人列表都包含了一个位于最右侧的字母导航栏,通过点击侧边字母导航栏就可以快速定位到列表中的选中字母开头的Item位置了。Android并没有提供这一控件,那么我们就只能通过自定义一个View来实现了。完成后的效果如下图所示:
图中的实现效果有两种状态,一种是点击或者滑动后,View的背景是灰色,字母是白色的,选中项的字母是绿色的,并会在PopupWindow中显示字母;一种是默认状态,就是没选中状态,背景是透明的,字母是深灰色的。分析完成后,下面就可以开始实现了。
创建自定义View,命名为LetterView,继承View。
public class LetterView extends View
首先在values/attrs文件中进行属性的声明,然后才可以在xml布局文件中进行使用。分析LetterView的属性:
xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="LetterView">
<attr name="textSize" format="dimension"/>
<attr name="textSpace" format="dimension"/>
<attr name="selectColor" format="color"/>
<attr name="dWidth" format="dimension"/>
<attr name="defaultTextColor" format="color"/>
<attr name="touchTextColor" format="color"/>
<attr name="touchBgColor" format="color"/>
</declare-styleable>
</resources>
textSize:字母字体大小
textSpace:字母相隔间距
selectColor:选中的字母颜色
dWidth:view的宽度,因为需要绘制上下两个半圆,半径为dWidth/2
defaultTextColor:默认不选中的字体颜色
touchTextColor:点击或滑动后view中字体颜色
touchBgColor:点击或滑动后view背景颜色
在LetterView中的构造函数中获取这些自定义属性:
public LetterView(Context context) {
this(context, null);
}
public LetterView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public LetterView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initLetters();
TypedArray attributes = context.obtainStyledAttributes(attrs, R.styleable.LetterView);
int attrCount = attributes.getIndexCount();
for (int i = 0; i < attrCount; i++) {
int attr = attributes.getIndex(i);
switch (attr) {
case R.styleable.LetterView_textSize:
textSize = attributes.getDimensionPixelSize(R.styleable.LetterView_textSize, 40);
break;
case R.styleable.LetterView_textSpace:
textSpace = attributes.getDimensionPixelSize(R.styleable.LetterView_textSpace, 10);
break;
case R.styleable.LetterView_dWidth:
dWidth = attributes.getDimensionPixelSize(R.styleable.LetterView_dWidth, 60);
break;
case R.styleable.LetterView_selectColor:
selectColor = attributes.getColor(R.styleable.LetterView_selectColor, green);
break;
case R.styleable.LetterView_touchBgColor:
touchBgColor=attributes.getColor(R.styleable.LetterView_touchBgColor,grey);
break;
case R.styleable.LetterView_touchTextColor:
touchTextColor=attributes.getColor(R.styleable.LetterView_touchTextColor,white);
break;
case R.styleable.LetterView_defaultTextColor:
defaultTextColor=attributes.getColor(R.styleable.LetterView_defaultTextColor,grey);
break;
}
}
attributes.recycle();
init();
}
initLetters方法就是来初始化字母表中的所有字母,创建一个ArrayList< Character>来存储字母:
private List<Character> letters;
public void initLetters() {
letters = new ArrayList<>();
for (char c = 'A'; c <= 'Z'; c++) {
letters.add(c);
}
}
init()方法中进行初始化画笔Paint;使用Rect来获取字体高度,因为需要测量LetterView的高度;初始化上下两个半圆的轮廓。
private Paint mPaint;
private RectF oval1;
private RectF oval2;
private Rect bound;
private void init() {
mPaint = new Paint();
mPaint.setAntiAlias(true);//开启抗锯齿
bound = new Rect();
mPaint.setTextSize(textSize);
mPaint.getTextBounds(String.valueOf(letters.get(0)), 0, 1, bound);//测量字母的高度
textHeight = bound.height();//获取测量后的字体高度
oval1 = new RectF(0, 0, dWidth, dWidth);// 画圆弧或者扇形外面的方形轮廓,扫描测量 这里需要绘制半圆所以轮廓是正方形
oval2 = new RectF(0, 26 * textHeight + textSpace * 25, dWidth, dWidth + 26 * textHeight + textSpace * 25);//下面半圆的绘制左上角坐标为(0,26个字母高度+25个间隔高度),右下角坐标为(dWidth,26个字母高度+25个间隔高度+2个半圆高度)
}
在构造函数中初始化各个属性之后就可以进行测量步骤了。自定义View的测量需要重写View的onMeasure方法:
@Override
public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int width;
int height;
if (widthMode == MeasureSpec.EXACTLY) {
width = widthSize;
} else {
width = dWidth;
}
if (heightMode == MeasureSpec.EXACTLY) {
height = heightSize;
} else {
height = 26 * textHeight + dWidth + textSpace * 25;//设置字母高度*26+字母间隔高度*25+上下2个半圆
}
setMeasuredDimension(width, height);//设置高度
}
使用MeasureSpec.getSize来获取在布局文件android:width或height中声明的值,使用MeasureSpec.getMode来获取测量的方式。如果在android:width或height中声明了详细的大小也就是多少dp或者为match_parent,那么mode此时对应的就是MeasureSpec.EXACTLY,所以获取getSize中的值并进行赋值设置view的大小。如果在android:width或height中声明为wrap_content,那么就自己测量view进行赋值设置view的大小。最后调用setMeasuredDimension使测量生效。最后一步最重要。
测量完成后就需要进行绘制了,也就是onDraw()方法的重写了。我们在上面分析了LetterView有两种状态,那么我们就需要在onDraw()方法中分两种状态进行绘制了。
首先声明一个枚举进行状态的声明:
private enum STATE {//状态枚举类
TOUCH, DEFAULT
}
然后重写View中的onDraw进行绘制:
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
int x;
int y;
switch (state) {
case DEFAULT://默认状态 透明背景+灰色字母
mPaint.setColor(defaultTextColor);
mPaint.setTextSize(textSize);
y = dWidth/2 + textHeight;//绘制字母的y方向起始位置
for (int i = 0; i < 26; i++) {
mPaint.getTextBounds(Character.toString(letters.get(i)), 0, 1, bound);//测量字母 获取字母的宽度
x = (getMeasuredWidth() - bound.width()) / 2;//绘制字母的x方向起始位置
canvas.drawText(Character.toString(letters.get(i)), x, y, mPaint);
y += textSpace + textHeight;
}
break;
case TOUCH://选中状态 灰色背景+白色字母+选中绿色字母
mPaint.setColor(touchBgColor);
canvas.drawArc(oval1, 180, 180, true, mPaint);// 画弧,参数1是RectF,参数2是角度的开始,参数3是多少度,参数4为true时画扇形,为false时画弧线
canvas.drawRect(0, dWidth/2, dWidth, 26 * textHeight + dWidth/2 + textSpace * 25, mPaint);
canvas.drawArc(oval2, 0, 180, true, mPaint);
mPaint.setColor(touchTextColor);
mPaint.setTextSize(textSize);//设置字母的大小
y = dWidth/2 + textHeight;
for (int i = 0; i < 26; i++) {
mPaint.getTextBounds(Character.toString(letters.get(i)), 0, 1, bound);
x = (getMeasuredWidth() - bound.width()) / 2;
if (i == currentPos) {
mPaint.setColor(selectColor);//如果当前字母被选中 显示绿色
} else {
mPaint.setColor(white);//如果当前字母未被选中 显示白色
}
canvas.drawText(Character.toString(letters.get(i)), x, y, mPaint);
y += textSpace + textHeight;
}
break;
}
}
这里需要注意 canvas.drawText绘制文本中的第二个参数和第三个参数
public void drawText(@NonNull String text, float x, float y, @NonNull Paint paint)
x就是绘制文本的起始x坐标,y就是绘制文本的底部y坐标,注意是底部,android将在y的上面绘制文本,文本底部坐标就是y。
canvas.drawArc就是绘制半圆,也需要注意方法中的参数。
public void drawArc(@NonNull RectF oval, float startAngle, float sweepAngle, boolean useCenter,
@NonNull Paint paint)
参数1的oval轮廓我们已经在init方法中进行创建了,参数二是开始绘制的角度方位,0度默认为坐标轴中x正向,参数二为从开始绘制角度到结束的角度大小,参数四为true时画扇形,为false时画弧线。
上面的步骤完成就可以看到默认状态的LetterView,但是点击或滑动缺一点效果都没有,因为还要重写onTouchEvent来处理触摸事件。处理触摸事件需要重写View中的onTouchEvent方法:
@Override
public boolean onTouchEvent(MotionEvent event) {//View点击监听事件
super.onTouchEvent(event);
int action = event.getAction();//获取屏幕触摸事件动作
float y;
switch (action) {
case MotionEvent.ACTION_UP:
state = STATE.DEFAULT;//恢复默认状态
invalidate();//刷新view
break;
case MotionEvent.ACTION_DOWN://按下选中状态
case MotionEvent.ACTION_MOVE://滑动选中状态
y = event.getY();
if (y <= textHeight + dWidth/2) {//如果y滑动高度超过view的Top时是负的,所以需要判断
currentPos = 0;
} else if (y >= getMeasuredHeight() - dWidth/2) {//如果y滑动高度超过view的字母栏最高位置时,防止数组越界,所以需要判断
currentPos = letters.size() - 1;
} else {
y = y - textHeight - dWidth/2;//去掉字母A高度和上半圆高度
currentPos = (int) (y / (textHeight + textSpace)) + 1;//因为去掉了字母A 所以这里需要补1
}
state = STATE.TOUCH;//设置为选中状态
invalidate();//刷新view
if (mListener != null) {
mListener.onItemClickListener(currentPos, letters.get(currentPos));//设置监听
}
break;
}
return true;
}
onTouchEvent必须返回true,自己消费事件,不回传给父控件处理,也就是让LetterView自己处理触摸事件。
在onTouchEvent也需要根据两种状态进行处理,但ACTION_UP也就是手指离开屏幕时,需要设置状态为STATE.DEFAULT并调用invalidate重写绘制View。但手指在屏幕上点击ACTION_DOWN或者滑动ACTION_MOVE时,这时就需要判断坐标的位置来获取字母在List中的位置。
调用 event.getY()获取的是view中的相对位置,也就是view的左上角为0。这里需要注意越界的问题,因为当滑动超过view的左上角时,获取的y是负的,当滑动超过view的高度时,获取的y大于view的高度,这两种情况会导致从List获取字母时数组越界。所以当y<=字母A高度+上半圆时,设置currentPos为0;当y>LetterView的高度时,设置为currentPos 为字母List中最后一个位置。中间字母位置判断也很简单,因为字体高度和字体间隔都是固定的,我们可以去掉上半圆的高度和字母A的高度,剩下就是25个字母高度和25个字体间隔,就可以直接除以(字母高度和字体间隔)来获取具体的位置了。
我们可以声明一个接口来供外界调用并传递当前选中的位置和字母给外界:
public void setListener(LetterOnClickListener mListener) {
this.mListener = mListener;
}
public interface LetterOnClickListener {
void onItemClickListener(int position, char letter);
}
在onTouchEvent中:
if (mListener != null) {
mListener.onItemClickListener(currentPos, letters.get(currentPos));//设置监听
}
上面就完成了LetterView的所有编写,使用也非常简单:
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/activity_main"
android:layout_width="match_parent"
android:layout_height="match_parent">
<custom.LetterView
android:id="@+id/MainActivity_LetterView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_centerVertical="true"
android:layout_marginRight="10dp"
app:selectColor="@android:color/holo_green_light"
app:textSize="16sp"
app:textSpace="5dp"
app:dWidth="20dp"
app:defaultTextColor="@android:color/holo_orange_light"
app:touchBgColor="@android:color/holo_orange_light"/>
</RelativeLayout>
在activity中使用:
letterView= (LetterView) findViewById(R.id.MainActivity_LetterView);
letterView.setListener(new LetterView.LetterOnClickListener() {
@Override
public void onItemClickListener(int position, char letter) {
}
});
LetterView完整代码:
package custom;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import com.example.jason.textindicatordemo.R;
import java.util.ArrayList;
import java.util.List;
/**
* Created by Jason on 2017/1/3.
*/
public class LetterView extends View {
private int white = Color.parseColor("#FFFFFF");
private int grey = Color.parseColor("#B3393A3F");
private int green = Color.parseColor("#ff99cc00");
private int dWidth = 60;
private int textSize = 40;
private int textSpace = 10;
private int textHeight;
private int selectColor = green;
private int defaultTextColor=grey;
private int touchTextColor=white;
private int touchBgColor=grey;
private STATE state = STATE.DEFAULT;
private Paint mPaint;
private RectF oval1;
private RectF oval2;
private Rect bound;
private int currentPos;
private List<Character> letters;
private LetterOnClickListener mListener;
public LetterView(Context context) {
this(context, null);
}
public LetterView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public LetterView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initLetters();
TypedArray attributes = context.obtainStyledAttributes(attrs, R.styleable.LetterView);
int attrCount = attributes.getIndexCount();
for (int i = 0; i < attrCount; i++) {
int attr = attributes.getIndex(i);
switch (attr) {
case R.styleable.LetterView_textSize:
textSize = attributes.getDimensionPixelSize(R.styleable.LetterView_textSize, 40);
break;
case R.styleable.LetterView_textSpace:
textSpace = attributes.getDimensionPixelSize(R.styleable.LetterView_textSpace, 10);
break;
case R.styleable.LetterView_dWidth:
dWidth = attributes.getDimensionPixelSize(R.styleable.LetterView_dWidth, 60);
break;
case R.styleable.LetterView_selectColor:
selectColor = attributes.getColor(R.styleable.LetterView_selectColor, green);
break;
case R.styleable.LetterView_touchBgColor:
touchBgColor=attributes.getColor(R.styleable.LetterView_touchBgColor,grey);
break;
case R.styleable.LetterView_touchTextColor:
touchTextColor=attributes.getColor(R.styleable.LetterView_touchTextColor,white);
break;
case R.styleable.LetterView_defaultTextColor:
defaultTextColor=attributes.getColor(R.styleable.LetterView_defaultTextColor,grey);
break;
}
}
attributes.recycle();
init();
}
private void init() {
mPaint = new Paint();
mPaint.setAntiAlias(true);
bound = new Rect();
mPaint.setTextSize(textSize);
mPaint.getTextBounds(String.valueOf(letters.get(0)), 0, 1, bound);//测量字母的高度
textHeight = bound.height();
oval1 = new RectF(0, 0, dWidth, dWidth);// 画圆弧或者扇形外面的方形轮廓,扫描测量 这里需要绘制半圆所以轮廓是正方形
oval2 = new RectF(0, 26 * textHeight + textSpace * 25, dWidth, dWidth + 26 * textHeight + textSpace * 25);
}
@Override
public void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int width;
int height;
if (widthMode == MeasureSpec.EXACTLY) {
width = widthSize;
} else {
width = dWidth;
}
if (heightMode == MeasureSpec.EXACTLY) {
height = heightSize;
} else {
height = 26 * textHeight + dWidth + textSpace * 25;//设置字母高度*26+字母间隔高度*25+上下2个半圆
}
setMeasuredDimension(width, height);//设置高度
}
@Override
public void onDraw(Canvas canvas) {
super.onDraw(canvas);
int x;
int y;
switch (state) {
case DEFAULT://默认状态 透明背景+灰色字母
mPaint.setColor(defaultTextColor);
mPaint.setTextSize(textSize);
y = dWidth/2 + textHeight;//绘制字母的y方向起始位置
for (int i = 0; i < 26; i++) {
mPaint.getTextBounds(Character.toString(letters.get(i)), 0, 1, bound);//测量字母 获取字母的宽度
x = (getMeasuredWidth() - bound.width()) / 2;//绘制字母的x方向起始位置
canvas.drawText(Character.toString(letters.get(i)), x, y, mPaint);
y += textSpace + textHeight;
}
break;
case TOUCH://选中状态 灰色背景+白色字母+选中绿色字母
mPaint.setColor(touchBgColor);
canvas.drawArc(oval1, 180, 180, true, mPaint);// 画弧,参数1是RectF,参数2是角度的开始,参数3是多少度,参数4为true时画扇形,为false时画弧线
canvas.drawRect(0, dWidth/2, dWidth, 26 * textHeight + dWidth/2 + textSpace * 25, mPaint);
canvas.drawArc(oval2, 0, 180, true, mPaint);
mPaint.setColor(touchTextColor);
mPaint.setTextSize(textSize);//设置字母的大小
y = dWidth/2 + textHeight;
for (int i = 0; i < 26; i++) {
mPaint.getTextBounds(Character.toString(letters.get(i)), 0, 1, bound);
x = (getMeasuredWidth() - bound.width()) / 2;
if (i == currentPos) {
mPaint.setColor(selectColor);//如果当前字母被选中 显示绿色
} else {
mPaint.setColor(white);//如果当前字母未被选中 显示白色
}
canvas.drawText(Character.toString(letters.get(i)), x, y, mPaint);
y += textSpace + textHeight;
}
break;
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {//View点击监听事件
super.onTouchEvent(event);
int action = event.getAction();//获取屏幕触摸事件动作
float y;
switch (action) {
case MotionEvent.ACTION_UP:
state = STATE.DEFAULT;//恢复默认状态
invalidate();//刷新view
break;
case MotionEvent.ACTION_DOWN://按下选中状态
case MotionEvent.ACTION_MOVE://滑动选中状态
y = event.getY();
if (y <= textHeight + dWidth/2) {//如果y滑动高度超过view的Top时是负的,所以需要判断
currentPos = 0;
} else if (y >= getMeasuredHeight() - dWidth/2) {//如果y滑动高度超过view的字母栏最高位置时,防止数组越界,所以需要判断
currentPos = letters.size() - 1;
} else {
y = y - textHeight - dWidth/2;//去掉字母A高度和上半圆高度
currentPos = (int) (y / (textHeight + textSpace)) + 1;//因为去掉了字母A 所以这里需要补1
}
state = STATE.TOUCH;//设置为选中状态
invalidate();//刷新view
if (mListener != null) {
mListener.onItemClickListener(currentPos, letters.get(currentPos));//设置监听
}
break;
}
return true;
}
public void initLetters() {
letters = new ArrayList<>();
for (char c = 'A'; c <= 'Z'; c++) {
letters.add(c);
}
}
private enum STATE {//状态枚举类
TOUCH, DEFAULT
}
public void setListener(LetterOnClickListener mListener) {
this.mListener = mListener;
}
public interface LetterOnClickListener {
void onItemClickListener(int position, char letter);
}
}