实现RecyclerView索引

概要

对于列表控制,在 RecyclerView 出现之前使用的是 ListView, 在为 ListView 实现索引的时候,大致有两种方式。

  1. 写一个类,继承自 ListView,重写 draw() 方法来绘制索引,然后利用 onInterceptTouchEvent() 来截断事件,用 onTouchEvent() 来处理事件。
  2. 单独写一个自定义 View 实现索引,然后开放一个接口实现与 ListView 的联动。

RecyclerView 出现的时候,大家也纷纷用以上两种方式来实现索引,当我在 Github 上搜索关键字 RecyclerView Indexer 的时候,看到的情况也是如此,然而我对这却不满意,有如下几点原因。

  1. 重写 RecyclerView 耦合性过高,如果一旦写得不好,修改起来比较麻烦。
  2. 用自定义 View 实现索引虽然解决了耦合性过高的问题,但是这种方式有个小小的缺点就是需要在布局中增加这个控件。
  3. RecyclerViewItemDecoration 可以用来绘制索引,也解决了以上两个问题。

RecyclerViewItemDecoration 起初让我感受到它的强大是在实现联系人的悬浮头部,onDraw() 方法可以让你为每个 Item 绘制一个装饰,而 onDrawOver() 方法可以让你在 RecyclerView 之上绘制你想要的任何东西,包括索引,这也就是本文的主要思想。

话不多说,先看下效果。

索引效果

效果图展示了如下几点

  1. 索引有一个位移动画,当慢慢滑动 RecyclerView 的时候,索引会从右到左快速移动出来。
  2. 当用手指在索引上移动的时候,跟随手指会出现一个小气泡,我称之为索引指示器,并且索引不会消失。
  3. 当手指从索引上离开的时候,索引会慢慢位移出屏幕。

实现

整个过程看起来是有点小复杂,但是可以把实现分为几步来实现。

  1. 简单的绘制索引。
  2. 实现位移动画。
  3. 绘制索引指示器。

我已经把这个写成一个库,所以我会抽取部分代码来演示实现的步骤,文末我会给出 Github 地址。

在细分绘制步骤之前,我们需要有一个绘制的原理图。

原理图

绘制索引

绘制索引我们解决几个问题

  1. 需要提前知道索引的字符串,例如字母表。
  2. 需要提前知道索引字符的字体大小,这样就能确定每个字符所占的正方形的边长。
  3. 当知道了每个字符的对应的矩形的边长,这样就能计算出索引的轮廓,在实际的效果图中,就是黑色的圆角矩形。

通过 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) 方法的参数解释下

  1. outer,包含索引轮廓的矩形,它有 padding。
  2. indicatorBaseY,触摸索引条时,索引字符的 Y 轴值,也就是原理图中的绿色的线。
  3. 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 是抽象类,具体实现类有两个,分别为 BalloonIndexerSquareIndexer,当然你也可以自己实现其它效果的子类。 同时 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);
                }
            }
        });
    }

本文代码已经打包成库,具体如何使用参见 GithubREAD.ME

反馈

如何在使用中有任何问题,或者任何意见,随意反馈,感谢。

发布了44 篇原创文章 · 获赞 30 · 访问量 400万+

猜你喜欢

转载自blog.csdn.net/zwlove5280/article/details/80979207