字母索引侧边栏在日常的开发中,特别是IM联系人或者电话簿应用等等用处还是挺广泛。
效果图:
需要熟悉的内容
1、根据需求,考虑需要暴露哪些自定义属性
2、熟悉自定义View中文字的测量,绘制
3、熟悉自定义View的测量
思路:
1、测量每个文字(字母)占用的高度,计算出View总的高度,
测量文字的宽度,使用setMeasuredDimension将测量的宽高赋值
2、根据需求绘制
3、处理手指触摸事件
实现代码:
代码的注释写的应该还算清楚,可以根据自己的需求进行修改,我这里没有处理padding的逻辑,有可能大家有需求,但是需要自己处理padding的逻辑,会影响测量,绘制的结果
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.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import java.util.ArrayList;
import java.util.List;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
public class SideSearchView extends View {
private static final String TAG = SideSearchView.class.getSimpleName();
// 背景默认形状,圆,正方形
public static final int CIRCLE = 1;
public static final int SQUARE = 2;
// 文字显示位置,居中或者从左开始
public static final int GRAVITY_START = 1;
public static final int GRAVITY_CENTER = 2;
// 字体未选中和选中默认大小
private static final int TXT_NORMAL_SIZE = 12;
private static final int TXT_SELECT_SIZE = 12;
// 默认在字体周围添加的多余的宽高,主要是为了选中背景的绘制
private static final int DEFAULT_OFFSET_WIDTH = 2;
private static final int DEFAULT_OFFSET_HEIGHT = 2;
private static final int TWO_TIMES = 2;
private static final String TXT_NORMAL_COLOR = "#000000";
private static final String TXT_SELECT_COLOR = "#000000";
private static final String SHAPE_COLOR = "#01806B";
private Context mContext;
private List<String> mIndexList;
// 未选中文字大小
private int mTextNormalSize;
// 选中文字大小
private int mTextSelectSize;
// 未选中文字颜色
private int mTextNormalColor;
// 选中文字颜色
private int mTextSelectColor;
// 文字显示位置
private int mTextGravity;
// 选中之后的背景图形
private int mSelectShape;
// 选中之后背景图形颜色
private int mSelectShapeColor;
// 选中背景图形半径
private float mSelectShapeRadius;
// 控件的默认宽高
private int defaultWidth;
private int defaultHeight;
// 文字的画笔
private Paint mTxtPaint;
// 选中背景的画笔
private Paint mShapePaint;
// 控件的宽高
private int mWidth;
private int mHeight;
// 触摸选中的位置, 默认未触摸选中
private int mPosition = -1;
// 记录上一次触摸的位置,避免重复调用
private int mPrePosition = -1;
// 计算单个字符所占用的高度
private float mSingleTxtHeight;
// 判断当前手指是否触摸在View上
private boolean mIsTouch = false;
private OnSearchChangedListener mOnSearchChangedListener;
@IntDef({CIRCLE, SQUARE})
public @interface SelectShape {
}
@IntDef({GRAVITY_START, GRAVITY_CENTER})
public @interface GRAVITY {
}
public SideSearchView(Context context) {
this(context, null);
}
public SideSearchView(Context context,
@Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public SideSearchView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
this.mContext = context;
init();
initAttrs(attrs);
}
private void init() {
mIndexList = new ArrayList<>();
for (int i = 'A' - 1; i <= 'Z'; i++) {
char cha = i >= 'A' ? (char) i : '#';
mIndexList.add(String.valueOf(cha));
}
mTextNormalSize = dp2px(mContext, TXT_NORMAL_SIZE);
mTextSelectSize = dp2px(mContext, TXT_SELECT_SIZE);
mTextNormalColor = Color.parseColor(TXT_NORMAL_COLOR);
mTextSelectColor = Color.parseColor(TXT_SELECT_COLOR);
mTextGravity = GRAVITY_CENTER;
mSelectShape = CIRCLE;
mSelectShapeColor = Color.parseColor(SHAPE_COLOR);
mTxtPaint = new Paint();
mTxtPaint.setAntiAlias(true);
mShapePaint = new Paint();
mShapePaint.setAntiAlias(true);
}
private void initAttrs(AttributeSet attrs) {
TypedArray typedArray = mContext.obtainStyledAttributes(attrs, R.styleable.SideSearchView);
mTextNormalSize = typedArray.getDimensionPixelSize(
R.styleable.SideSearchView_side_text_normal_size, mTextNormalSize);
mTextSelectSize = typedArray.getDimensionPixelSize(
R.styleable.SideSearchView_side_text_select_size, mTextSelectSize);
mTextNormalColor = typedArray.getColor(R.styleable.SideSearchView_side_text_normal_color,
mTextNormalColor);
mTextSelectColor = typedArray.getColor(R.styleable.SideSearchView_side_text_select_color,
mTextSelectColor);
mTextGravity = typedArray.getInt(R.styleable.SideSearchView_side_text_gravity,
mTextGravity);
mSelectShape = typedArray.getInt(R.styleable.SideSearchView_side_select_shape,
mSelectShape);
mSelectShapeColor = typedArray.getColor(R.styleable.SideSearchView_side_select_shape_color,
mSelectShapeColor);
typedArray.recycle();
mTxtPaint.setColor(mTextNormalColor);
mTxtPaint.setTextSize(mTextNormalSize);
mShapePaint.setColor(mSelectShapeColor);
defaultWidth = mTextSelectSize + dp2px(mContext, DEFAULT_OFFSET_WIDTH) * TWO_TIMES;
calculateDefaultHeight();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int measuredWidth = getCustomDefaultSize(widthMeasureSpec, defaultWidth);
int measuredHeight = getCustomDefaultSize(heightMeasureSpec, defaultHeight);
if (measuredWidth < defaultWidth) {
measuredWidth = defaultWidth;
}
if (measuredHeight < defaultHeight) {
measuredHeight = defaultHeight;
}
setMeasuredDimension(measuredWidth, measuredHeight);
}
private int getCustomDefaultSize(int measureSpec, int defaultSize) {
int mode = MeasureSpec.getMode(measureSpec);
int size = MeasureSpec.getSize(measureSpec);
if (mode == MeasureSpec.EXACTLY) {
//确切大小,所以将得到的尺寸给view
return size;
} else if (mode == MeasureSpec.AT_MOST) {
return Math.min(defaultSize, size);
} else {
return defaultSize;
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
this.mWidth = w;
this.mHeight = h;
mSingleTxtHeight = mHeight * 1f / mIndexList.size();
// 背景圆半径
mSelectShapeRadius = mSingleTxtHeight / TWO_TIMES;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
for (int i = 0; i < mIndexList.size(); i++) {
String indexData = mIndexList.get(i);
if (i == mPosition) {
canvasSelect(canvas, indexData, i);
} else {
canvasNormal(canvas, indexData, i);
}
}
}
/**
* 绘制未选中的状态.
*
* @param canvas 画布
* @param indexData 绘制Text
* @param index Text在列表中的位置
*/
private void canvasNormal(Canvas canvas, String indexData, int index) {
mTxtPaint.setColor(mTextNormalColor);
mTxtPaint.setTextSize(mTextNormalSize);
float letterWidth = mTxtPaint.measureText(indexData);
float letterHeight = getTxtHeight(indexData, mTxtPaint);
// 计算(x,y),默认是该字母的居中绘制坐标
float xPos = 0f;
if (mTextGravity == GRAVITY_CENTER) {
// 绘制在左侧
xPos = (mWidth - letterWidth) / TWO_TIMES;
} else {
// 绘制在中间
xPos = getPaddingLeft() + (mTextNormalSize - letterWidth) / TWO_TIMES;
}
float txtOffset = 0;
if (mSingleTxtHeight > letterHeight) {
txtOffset = (mSingleTxtHeight - letterHeight) / TWO_TIMES;
}
float yPos = getPaddingTop() + mSingleTxtHeight * (index + 1) - txtOffset;
canvas.drawText(indexData, xPos, yPos, mTxtPaint);
}
/**
* 绘制选中的状态.
*
* @param canvas 画布
* @param indexData 绘制Text
* @param index Text在列表中的位置
*/
private void canvasSelect(Canvas canvas, String indexData, int index) {
mTxtPaint.setColor(mTextSelectColor);
mTxtPaint.setTextSize(mTextSelectSize);
float letterWidth = mTxtPaint.measureText(indexData);
float letterHeight = getTxtHeight(indexData, mTxtPaint);
// 计算(x,y),默认是该字母的居中绘制坐标
float xPos = 0f;
if (mTextGravity == GRAVITY_CENTER) {
// 绘制在左侧
xPos = (mWidth - letterWidth) / TWO_TIMES;
} else {
// 绘制在中间
xPos = getPaddingLeft() + (mTextSelectSize - letterWidth) / TWO_TIMES;
}
float txtOffset = 0;
if (mSingleTxtHeight > letterHeight) {
txtOffset = (mSingleTxtHeight - letterHeight) / TWO_TIMES;
}
float positionHeight = mSingleTxtHeight * (index + 1);
float yPos = getPaddingTop() + positionHeight - txtOffset;
if (mSelectShape == CIRCLE) {
float cy = positionHeight - mSelectShapeRadius;
// 圆形Y轴加这个偏移量是为了纠正背景的偏移
canvas.drawCircle(mWidth * 1.0f / TWO_TIMES, cy,
mSelectShapeRadius, mShapePaint);
} else {
float left = mWidth * 1.0f / TWO_TIMES - mSelectShapeRadius;
float top = positionHeight - mSingleTxtHeight;
float right = mWidth * 1.0f / TWO_TIMES + mSelectShapeRadius;
canvas.drawRect(left, top, right, positionHeight, mShapePaint);
}
canvas.drawText(indexData, xPos, yPos, mTxtPaint);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
mIsTouch = false;
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
mIsTouch = true;
// 获取触摸位置的Y坐标
float y = event.getY();
mPosition = calculatePosition(y);
//Log.e(TAG, "mPrePosition:: " + mPrePosition
// + " mPosition:: " + mPosition);
if (mPosition != mPrePosition && mPosition >= 0
&& mPosition <= mIndexList.size() - 1) {
mPrePosition = mPosition;
//Log.e(TAG, "onChanged mPosition:: " + mPosition);
if (mOnSearchChangedListener != null) {
mOnSearchChangedListener.onChanged(mIndexList.get(mPosition), mPosition);
}
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mPosition = -1;
mIsTouch = false;
break;
default:
mIsTouch = false;
break;
}
invalidate();
//Log.e(TAG, "isTouch:: " + isTouch);
if (mOnSearchChangedListener != null) {
mOnSearchChangedListener.onTouch(mIsTouch);
}
return mIsTouch;
}
/**
* 计算触摸点所在的字母位置.
*/
private int calculatePosition(float y) {
if (y < getPaddingTop() || y > mHeight + getPaddingTop()) {
return -1;
} else {
return (int) ((y - getPaddingTop()) / mHeight * mIndexList.size());
}
}
/**
* 初始化时,计算控件默认高度.
*/
private void calculateDefaultHeight() {
if (mIndexList.isEmpty()) {
return;
}
mTxtPaint.setTextSize(mTextSelectSize);
StringBuilder builder = new StringBuilder();
for (String s : mIndexList) {
builder.append(s);
}
defaultHeight = (int) ((getTxtHeight(builder.toString(), mTxtPaint)
+ dp2px(mContext, DEFAULT_OFFSET_HEIGHT) * TWO_TIMES) * mIndexList.size());
}
/**
* 设置选中背景图形.
*
* @param shape SelectShape
*/
public void setSelectShape(@SelectShape int shape) {
this.mSelectShape = shape;
}
/**
* 控制文字的显示位置.
*
* @param gravity 位置
*/
public void setTextGravity(@GRAVITY int gravity) {
this.mTextGravity = gravity;
invalidate();
}
/**
* 设置索引参数.
*
* @param list 索引集合
*/
public void setIndexList(List<String> list) {
if (list.isEmpty()) {
return;
}
this.mIndexList = list;
calculateDefaultHeight();
requestLayout();
invalidate();
}
/**
* 判断此时是否是触摸状态.
*/
public boolean isTouch() {
return mIsTouch;
}
public void setOnSearchChangedListener(OnSearchChangedListener onSearchChangedListener) {
this.mOnSearchChangedListener = onSearchChangedListener;
}
/**
* 触摸数据的回调.
*/
public interface OnSearchChangedListener {
void onChanged(String indexData, int position);
void onTouch(boolean isTouch);
}
private int dp2px(Context context, int dp) {
float density = context.getResources().getDisplayMetrics().density;
return (int) (dp * density + 0.5f);
}
/**
* 获取文字的高度.
*/
private float getTxtHeight(String txt, Paint paint) {
Rect rect = new Rect();
paint.getTextBounds(txt, 0, txt.length(), rect);
return rect.bottom - rect.top;
}
}
自定义的属性
<declare-styleable name="SideSearchView">
<attr name="side_text_normal_size" format="dimension"/>
<attr name="side_text_select_size" format="dimension"/>
<attr name="side_text_normal_color" format="color"/>
<attr name="side_text_select_color" format="color"/>
<attr name="side_text_gravity" format="enum">
<enum name="start" value="1"/>
<enum name="center" value="2"/>
</attr>
<attr name="side_select_shape" format="enum">
<enum name="circle" value="1"/>
<enum name="square" value="2"/>
</attr>
<attr name="side_select_shape_color" format="color"/>
</declare-styleable>
View已经写完了,我们尝试使用完成的控件
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.dh.testdemo.SideSearchView
android:id="@+id/sideView"
android:layout_width="50dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:side_text_normal_size="14sp"
app:side_text_normal_color="#000"
app:side_text_select_size="14sp"
app:side_text_select_color="#FFC400"
app:side_select_shape="circle"
android:background="#C9E4DD"/>
<TextView
android:id="@+id/tvMark"
android:layout_width="70dp"
android:layout_height="70dp"
android:background="#9DE6D3"
android:gravity="center"
android:textColor="@color/black"
android:textSize="20sp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
mIndexList = new ArrayList<>();
mSideView = findViewById(R.id.sideView);
mTvMark = findViewById(R.id.tvMark);
for (int i = 'A' - 1; i <= 'Z'; i++) {
char cha = i >= 'A' ? (char) i : '#';
mIndexList.add(String.valueOf(cha));
}
mSideView.setIndexList(mIndexList);
mSideView.setOnSearchChangedListener(new SideSearchView.OnSearchChangedListener() {
@Override
public void onChanged(String indexData, int position) {
Log.e("MainActivity", "indexData:: " + indexData + " position:: " + position);
mTvMark.setText(indexData);
}
@Override
public void onTouch(boolean isTouch) {
if (isTouch) {
mTvMark.setVisibility(View.VISIBLE);
} else {
mTvMark.setVisibility(View.GONE);
}
}
});