概要
对于列表控制,在 RecyclerView
出现之前使用的是 ListView
, 在为 ListView
实现索引的时候,大致有两种方式。
- 写一个类,继承自
ListView
,重写draw()
方法来绘制索引,然后利用onInterceptTouchEvent()
来截断事件,用onTouchEvent()
来处理事件。 - 单独写一个自定义
View
实现索引,然后开放一个接口实现与ListView
的联动。
当 RecyclerView
出现的时候,大家也纷纷用以上两种方式来实现索引,当我在 Github
上搜索关键字 RecyclerView Indexer
的时候,看到的情况也是如此,然而我对这却不满意,有如下几点原因。
- 重写
RecyclerView
耦合性过高,如果一旦写得不好,修改起来比较麻烦。 - 用自定义
View
实现索引虽然解决了耦合性过高的问题,但是这种方式有个小小的缺点就是需要在布局中增加这个控件。 RecyclerView
的ItemDecoration
可以用来绘制索引,也解决了以上两个问题。
RecyclerView
的 ItemDecoration
起初让我感受到它的强大是在实现联系人的悬浮头部,onDraw()
方法可以让你为每个 Item
绘制一个装饰
,而 onDrawOver()
方法可以让你在 RecyclerView
之上绘制你想要的任何东西,包括索引,这也就是本文的主要思想。
话不多说,先看下效果。
效果图展示了如下几点
- 索引有一个位移动画,当慢慢滑动
RecyclerView
的时候,索引会从右到左快速移动出来。 - 当用手指在索引上移动的时候,跟随手指会出现一个小气泡,我称之为索引指示器,并且索引不会消失。
- 当手指从索引上离开的时候,索引会慢慢位移出屏幕。
实现
整个过程看起来是有点小复杂,但是可以把实现分为几步来实现。
- 简单的绘制索引。
- 实现位移动画。
- 绘制索引指示器。
我已经把这个写成一个库,所以我会抽取部分代码来演示实现的步骤,文末我会给出 Github
地址。
在细分绘制步骤之前,我们需要有一个绘制的原理图。
绘制索引
绘制索引我们解决几个问题
- 需要提前知道索引的字符串,例如字母表。
- 需要提前知道索引字符的字体大小,这样就能确定每个字符所占的正方形的边长。
- 当知道了每个字符的对应的矩形的边长,这样就能计算出索引的轮廓,在实际的效果图中,就是黑色的圆角矩形。
通过 Builder
模式来设置需要的属性,例如 Context
,字体大小,索引字符串等等。
public SimpleIndexer(Builder builder) {
// 索引字符串
mIndexerString = builder.mIndexerString;
if (TextUtils.isEmpty(mIndexerString)) {
Log.w(TAG, "You have not set indexer string.");
return;
}
DisplayMetrics displayMetrics = builder.mContext.getResources().getDisplayMetrics();
ViewConfiguration viewConfiguration = ViewConfiguration.get(builder.mContext);
mScaledTouchSlop = viewConfiguration.getScaledTouchSlop();
// 字体大小
mIndexerTextSize = builder.mIndexerTextSize <= DEFAULT_INDEXER_TEXT_SIZE_SP ?
DEFAULT_INDEXER_TEXT_SIZE_SP : builder.mIndexerTextSize;
mIndexerTextSize = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, mIndexerTextSize,
displayMetrics);
// 间距
int padding = builder.mPadding <= 0 ?
DEFAULT_PADDING_DP : builder.mPadding;
mPadding = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, padding,
displayMetrics);
mIndexerTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mIndexerTextPaint.setTextSize(mIndexerTextSize);
Paint.FontMetrics fontMetrics = mIndexerTextPaint.getFontMetrics();
float fontMetricsHeight = fontMetrics.bottom - fontMetrics.top;
// 字符所占矩形边长
mCellWidth = mCellHeight = (int) Math.ceil(fontMetricsHeight);
// 索引轮廓 Rect
mOutlineRect = new RectF();
mOutlineRect.right = mCellWidth;
mOutlineRect.bottom = mCellHeight / 2.f + mCellHeight * mIndexerString.length() + mCellHeight / 2.f;
// 索引轮廓 Path
mOutlinePath = new Path();
mOutlinePath.addArc(mOutlineRect.left, mOutlineRect.top, mOutlineRect.width(), mCellHeight,
180, 180);
mOutlinePath.rLineTo(0, mOutlineRect.height() - mCellHeight);
mOutlinePath.addArc(mOutlineRect.left, mOutlineRect.height() - mCellHeight,
mOutlineRect.width(), mOutlineRect.height(), 0, 180);
mOutlinePath.lineTo(0, mCellHeight / 2.f);
mOutlinePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mOutlinePaint.setStyle(Paint.Style.STROKE);
mOutlinePaint.setColor(Color.BLACK);
int outlineStrokeWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, DEFAULT_OUTLINE_STROKE_WIDTH_DP, displayMetrics);
mOutlinePaint.setStrokeWidth(outlineStrokeWidth);
// 整个索引所在 Rect
mOuter = new RectF();
offsetOuter();
// 索引指示器背景颜色
mIndicatorBgColor = builder.mIndicatorColor <= 0 ?
DEFAULT_INDICATOR_BG_COLOR : builder.mIndicatorColor;
}
SimpleIndexer
是继承自 RecyclerView.ItemDecoration
的,当所有的条件准备就绪后,可以在 onDrawOver()
中可以来绘制这个索引
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
// If width or height changed , check whether RecyclerView has enough space to draw indexer.
if (mRecyclerViewWidth != parent.getWidth() || mRecyclerViewHeight != parent.getHeight()) {
mRecyclerViewWidth = parent.getWidth();
mRecyclerViewHeight = parent.getHeight();
if ((mRecyclerViewHeight - mOutlineRect.height()) / 2.f < mPadding) {
Log.w(TAG, "Couldn't show indexer. RecyclerView must have enough height!!!");
mHasEnoughSpace = false;
} else {
mHasEnoughSpace = true;
}
}
if (!mHasEnoughSpace) {
return;
}
// If translate, adjust outline and outer's rect.
mOutlineRect.offsetTo(parent.getWidth() - mTranslationX,
parent.getHeight() / 2.f - mOutlineRect.height() / 2.f);
offsetOuter();
drawOutlineAndIndexer(c);
}
private void drawOutlineAndIndexer(Canvas c) {
c.save();
// 1. Draw outline.
c.translate(mOutlineRect.left, mOutlineRect.top);
c.drawPath(mOutlinePath, mOutlinePaint);
// 2. Draw indexer.
for (int i = 0; i < mIndexerString.length(); i++) {
String character = String.valueOf(mIndexerString.charAt(i));
mIndexerTextPaint.getTextBounds(character, 0, character.length(), mTmpTextBound);
float left = mCellWidth / 2.f - mTmpTextBound.width() / 2.f;
float top = mCellHeight * (i + 1) + mTmpTextBound.height() / 2.f;
c.drawText(character, left, top, mIndexerTextPaint);
}
c.restore();
}
为索引添加动画
在添加动画之前,首先要明确动画有几种状态
// 隐藏
private static final int ANIMATION_STATE_OUT = 0;
// 正在位移进屏幕
private static final int ANIMATION_STATE_TRANSLATING_IN = 1;
// 显示
private static final int ANIMATION_STATE_IN = 2;
// 正在从位移出屏幕
private static final int ANIMATION_STATE_TRANSLATING_OUT = 3;
// 动画状态
@AnimationState
private int mAnimationState = ANIMATION_STATE_OUT;
当动画处理隐藏状态,索引是不需要绘制的,因此在 onDrawOver()
中要添加判断条件
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
// Check indexer string and animation state.
if (TextUtils.isEmpty(mIndexerString) || mAnimationState == ANIMATION_STATE_OUT) {
return;
}
}
当滑动 RecyclerView
的时候,需要执行显示动画。 我们需要为 RecyclerView
监听滑动事件
public void attachToRecyclerView(RecyclerView recyclerView, onScrollListener listener) {
mListener = listener;
if (mRecyclerView == recyclerView) {
return;
}
if (mRecyclerView != null) {
mRecyclerView.removeItemDecoration(this);
mRecyclerView.removeOnItemTouchListener(mItemTouchListener);
mRecyclerView.removeOnScrollListener(mOnScrollListener);
}
mRecyclerView = recyclerView;
if (mRecyclerView != null) {
mRecyclerView.addItemDecoration(this);
mRecyclerView.addOnItemTouchListener(mItemTouchListener);
mRecyclerView.addOnScrollListener(mOnScrollListener);
}
}
// 滑动事件监听器
private RecyclerView.OnScrollListener mOnScrollListener = new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
}
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
if (Math.abs(dy) >= mScaledTouchSlop) {
translateIn();
}
}
};
当达到滑动的要求后,执行了显示动画
private void translateIn() {
switch (mAnimationState) {
// 如果正在执行隐藏动画,就取消当前动画,执行隐藏动画
case ANIMATION_STATE_TRANSLATING_OUT:
// If animation is translating out, cancel it and execute translate in animation.
mTranslateAnimator.cancel(); // fall through
// 如果动画处于隐藏状态,执行显示动画
case ANIMATION_STATE_OUT:
mAnimationState = ANIMATION_STATE_TRANSLATING_IN;
mTranslateAnimator.setFloatValues((float) mTranslateAnimator.getAnimatedValue(), 1);
mTranslateAnimator.setInterpolator(mInInterpolator);
mTranslateAnimator.start();
break;
}
}
动画启动后,我们需要计算出绘制索引的偏移量,这就需要监听动画的更新事件,然后进行重绘。
private ValueAnimator.AnimatorUpdateListener mUpdateListener = new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float animatedValue = (float) animation.getAnimatedValue();
mTranslationX = animatedValue * mMaxTranslationX;
redraw();
}
};
当索引完全处理显示状态后,我们需要让索引在一定的时间内自动隐藏,我们需要监听动画的状态事件。
private Animator.AnimatorListener mAnimatorListener = new AnimatorListenerAdapter() {
private boolean mCanceled;
@Override
public void onAnimationCancel(Animator animation) {
mCanceled = true;
}
@Override
public void onAnimationEnd(Animator animation) {
// If canceled, do nothing.
if (mCanceled) {
mCanceled = false;
return;
}
float animatedValue = (float) mTranslateAnimator.getAnimatedValue();
if (animatedValue == 0) { // translate out complete.
mAnimationState = ANIMATION_STATE_OUT;
} else { // translate in complete.
mAnimationState = ANIMATION_STATE_IN;
// If is not dragging, post a hide runnable within RecyclerView.
if (!mIsDragging) {
postHideRunnableDelayed(TRANSLATE_OUT_DELAY_AFTER_VISIBLE_MS);
}
}
}
};
private void postHideRunnableDelayed(int delay) {
mRecyclerView.postDelayed(mHideRunnable, delay);
}
private Runnable mHideRunnable = new Runnable() {
@Override
public void run() {
translateOut();
}
};
private void translateOut() {
switch (mAnimationState) {
// 如果正在执行显示动画,取消当前动画,执行隐藏动画
case ANIMATION_STATE_TRANSLATING_IN:
// If animation is translating in, cancel it and execute translate out animation.
mTranslateAnimator.cancel();// fall through
// 如果已经显示,执行隐藏动画
case ANIMATION_STATE_IN:
mAnimationState = ANIMATION_STATE_TRANSLATING_OUT;
mTranslateAnimator.setFloatValues((float) mTranslateAnimator.getAnimatedValue(), 0);
mTranslateAnimator.setInterpolator(mOutInterpolator);
mTranslateAnimator.start();
break;
}
}
绘制索引指示器
索引指示器呢,每个人可能都有自己的想法,主流的实现就是前面展示效果,它与 Android 原生的设计相似。 还有一种实现,就是在屏幕中间绘制一个矩形,显示索引字符。 为了达到实现各种不同的效果,代码遵循OCP原则,我把 SimpleIndexer
写成抽象类,而唯一的抽象方法就是要用户去实现索引指示器。
public abstract class SimpleIndexer extends RecyclerView.ItemDecoration {
public abstract void drawIndicator(Canvas c, RectF outer, float indicatorBaseY, String indicatorChar);
}
当然我自己实现了这两种主流的指示器,例如前面的效果的实现类如下
public class BalloonIndexer extends SimpleIndexer {
private TextPaint mBalloonTextPaint;
private RectF mBalloonIndicatorRect;
private Path mBalloonPath;
private float mOutlineMinMarginTop;
private Paint mBalloonPaint;
public BalloonIndexer(Builder builder) {
super(builder);
mBalloonPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBalloonPaint.setStyle(Paint.Style.FILL);
mBalloonPaint.setColor(mIndicatorBgColor);
mBalloonTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mBalloonTextPaint.setColor(Color.WHITE);
mBalloonTextPaint.setTextSize(mIndexerTextSize * 2);
Paint.FontMetrics fontMetrics = mBalloonTextPaint.getFontMetrics();
float balloonBoundSize = fontMetrics.bottom - fontMetrics.top;
float diameter = (float) Math.hypot(balloonBoundSize, balloonBoundSize);
mBalloonIndicatorRect = new RectF(0, 0, diameter, diameter);
mBalloonPath = new Path();
mBalloonPath.addArc(mBalloonIndicatorRect, 90, 270);
mBalloonPath.rLineTo(0, mBalloonIndicatorRect.height() / 2);
mBalloonPath.rLineTo(-mBalloonIndicatorRect.width() / 2, 0);
mOutlineMinMarginTop = diameter - mCellHeight * 3.f / 2 - mPadding;
if (mOutlineMinMarginTop < 0) {
mOutlineMinMarginTop = 0;
}
}
@Override
public void drawIndicator(Canvas c, RectF outer, float indicatorBaseY, String indicatorChar) {
Log.d("david", "drawIndicator");
if ((mRecyclerViewHeight - outer.height()) / 2.f <= mOutlineMinMarginTop) {
return;
}
c.save();
float dy = indicatorBaseY - mBalloonIndicatorRect.height();
c.translate(outer.left - mBalloonIndicatorRect.width(),
dy);
c.drawPath(mBalloonPath, mBalloonPaint);
mBalloonTextPaint.getTextBounds(indicatorChar, 0, indicatorChar.length(), mTmpTextBound);
c.drawText(indicatorChar, mBalloonIndicatorRect.width() / 2.f - mTmpTextBound.width() / 2.f,
mBalloonIndicatorRect.width() / 2.f + mTmpTextBound.height() / 2.f, mBalloonTextPaint);
c.restore();
}
}
drawIndicator(Canvas c, RectF outer, float indicatorBaseY, String indicatorChar)
方法的参数解释下
- outer,包含索引轮廓的矩形,它有 padding。
- indicatorBaseY,触摸索引条时,索引字符的 Y 轴值,也就是原理图中的绿色的线。
- indicatorChar, 索引字符。
还有另外一种实现,在屏幕中显示一种矩形区域
public class SquareIndexer extends SimpleIndexer {
private RectF mSquareRect;
private TextPaint mSquareTextPaint;
private Paint mSquarePaint;
public SquareIndexer(Builder builder) {
super(builder);
mSquarePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mSquarePaint.setStyle(Paint.Style.FILL);
mSquarePaint.setColor(mIndicatorBgColor);
mSquareTextPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mSquareTextPaint.setColor(Color.WHITE);
mSquareTextPaint.setTextSize(mIndexerTextSize * 2);
Paint.FontMetrics fontMetrics = mSquareTextPaint.getFontMetrics();
float balloonBoundSize = fontMetrics.bottom - fontMetrics.top;
float diameter = (float) Math.hypot(balloonBoundSize, balloonBoundSize);
mSquareRect = new RectF(0, 0, diameter, diameter);
}
@Override
public void drawIndicator(Canvas c, RectF outer, float indicatorBaseY, String indicatorChar) {
c.translate((c.getWidth() - mSquareRect.width()) / 2.f,
(c.getHeight() - mSquareRect.height()) / 2.f);
float radius = mSquareRect.width() / 8.f;
c.drawRoundRect(mSquareRect, radius, radius, mSquarePaint);
mSquareTextPaint.getTextBounds(indicatorChar, 0, indicatorChar.length(), mTmpTextBound);
c.drawText(indicatorChar, mSquareRect.width() / 2.f - mTmpTextBound.width() / 2.f,
mSquareRect.width() / 2.f + mTmpTextBound.height() / 2.f, mSquareTextPaint);
}
}
效果图如下
使用
通过前面的讲解,我们知道,SimpleIndexer
是抽象类,具体实现类有两个,分别为 BalloonIndexer
和 SquareIndexer
,当然你也可以自己实现其它效果的子类。 同时 SimpleIndexer
是通过接口与 RecyclerView
进行联动的,具体使用如下
SimpleIndexer.Builder builder = new SimpleIndexer.Builder(this, ContactsIndexer.DEFAULT_INDEXER_CHARACTERS)
.indexerTextSize(12)
.padding(SimpleIndexer.DEFAULT_PADDING_DP)
.indicatorColor(SimpleIndexer.DEFAULT_INDICATOR_BG_COLOR);
SimpleIndexer balloonIndexer = new BalloonIndexer(builder);
balloonIndexer.attachToRecyclerView(mContactsList, (rv, sectionIndex) -> {
RecyclerView.Adapter adapter = rv.getAdapter();
if (adapter instanceof SectionIndexer) {
SectionIndexer indexer = (SectionIndexer) adapter;
int pos = indexer.getPositionForSection(sectionIndex);
RecyclerView.LayoutManager layoutManager = rv.getLayoutManager();
if (layoutManager instanceof LinearLayoutManager) {
LinearLayoutManager linearLayoutManager = (LinearLayoutManager) layoutManager;
linearLayoutManager.scrollToPositionWithOffset(pos, 0);
}
}
});
}
本文代码已经打包成库,具体如何使用参见 Github
的 READ.ME。
反馈
如何在使用中有任何问题,或者任何意见,随意反馈,感谢。