BannerRecyclerView

Introduction: Android Repository to implement Banner View
More: Author   ReportBugs   
Tags:

Android Repository to implement Banner View

预览

很多 app 的首页都有一个可以滑动的 banner。大概长这样:

实现方式有多种,介绍一种用 RecyclerView 的实现方式

实现思路

第一种平铺的 banner 其实很好实现,就是一个 RecycerView + PagerSnapHelper。但是为了兼容多种显示效果,例如第二种的显示效果,我们需要去自定义 LayoutMmanager 和 SnapHelper。

LayoutManager 部分

自定义 LayoutManager 一般是分两步,布局滑动,再想想 LayoutManager 需要些什么属性。

属性

需要一个 boolean 值标识是否循环布局。 需要两个 float 值标识滑动时的宽高缩放。

public class BannerLayoutManager{
    private float heightScale = 0.9f;
    private float widthScale = 0.9f;
    private boolean infinite = true;  //默认无限循环

    ...

}

布局

1、计算第一个 View 的开始位置 : int offsetX = (父布局宽度 - 子 View 宽度) / 2

 int offsetX = (mOrientationHelper.getTotalSpace() - mOrientationHelper.getDecoratedMeasurement(scrap)) / 2;

2、计算是否要添加一个 view 为第 1 个子 view,以显示出循环布局的效果。

 View lastChild = getChildAt(getChildCount() - 1);
   // 如果是循环布局,并且最后一个 view 已超出父布局,则添加最左边的 view
  if ( infinite && lastChild != null && getDecoratedRight(lastChild) > mOrientationHelper.getTotalSpace()) {
    layoutLeftItem(recycler);
  }

3、缩放所有的 view
缩放规则:以父布局的中线(中心线)为基准,如果子 view 的中线与中心线重合,则缩放比为 1.0f;如果不重合,则计算出子 view 的中线与中心线的距离,距离越大,缩放比越小。

 private void scaleItem() {
        if (heightScale >= 1 || widthScale >= 1) {
            return;
        }

        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            float itemMiddle = (getDecoratedRight(child) + getDecoratedLeft(child)) / 2.0f;
            float screenMiddle = mOrientationHelper.getTotalSpace() / 2.0f;
            float interval = Math.abs(screenMiddle - itemMiddle) * 1.0f;

            float ratio = 1 - (1 - heightScale) * (interval / itemWidth);
            float ratioWidth = 1 - (1 - widthScale) * (interval / itemWidth);
            child.setScaleX(ratioWidth);
            child.setScaleY(ratio);
        }
    }

4、总体的布局方法

private void layoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        if (getItemCount() == 0 || state.isPreLayout()) {
            removeAndRecycleAllViews(recycler);
            return;
        }
        detachAndScrapAttachedViews(recycler);

        View scrap = recycler.getViewForPosition(0);
        measureChildWithMargins(scrap, 0, 0);
        itemWidth = getDecoratedMeasuredWidth(scrap);
        int offsetX = (mOrientationHelper.getTotalSpace() - mOrientationHelper.getDecoratedMeasurement(scrap)) / 2;
        for (int i = 0; i < getItemCount(); i++) {
            if (offsetX > mOrientationHelper.getTotalSpace()) {
                break;
            }
            View viewForPosition = recycler.getViewForPosition(i);
            addView(viewForPosition);
            measureChildWithMargins(viewForPosition, 0, 0);
            offsetX += layoutItem(viewForPosition, offsetX);
        }

        View lastChild = getChildAt(getChildCount() - 1);
        // 如果是循环布局,并且最后一个 view 已超出父布局,则添加最左边的 view
        if ( infinite && lastChild != null && getDecoratedRight(lastChild) > mOrientationHelper.getTotalSpace()) {
            layoutLeftItem(recycler);
        }
        scaleItem();
    }

滑动

对滑动的处理就是为了对 view 的回收,以减少消耗,提高效率。 处理方式就是根据滑动距离去添加和删除 view。

我也是第一次自定义 LayoutManager,感觉写得有点繁琐了。分了左滑右滑两种情况去写。

    @Override
    public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
        return offsetDx(dx, recycler);
    }

    private int offsetDx(int dx, RecyclerView.Recycler recycler) {
        int realScroll = dx;
        // 向左
        if (dx > 0) {
            realScroll = scrollToLeft(dx, recycler, realScroll);
        }
        // 向右
        if (dx < 0) {
            realScroll = scrollToRight(dx, recycler, realScroll);
        }
        scaleItem();

        return realScroll;
    }

scrollToLeft 或者 scrollToRight 都是只做了三件事,添加 view,计算实际滑动距离并滑动,回收 view

    private int scrollToLeft(int dx, RecyclerView.Recycler recycler, int realScroll) {
        while (true) {
            // 将需要添加的 view 添加到 RecyclerView 中
            View rightView = getChildAt(getChildCount() - 1);
            int decoratedRight = getDecoratedRight(rightView);
            if (decoratedRight - dx < mOrientationHelper.getTotalSpace()) {
                int position = getPosition(rightView);
                if (!infinite && position == getItemCount() - 1) {
                    break;
                }

                int addPosition = infinite ? (position + 1) % getItemCount() : position + 1;
                View lastViewAdd = recycler.getViewForPosition(addPosition);
                addView(lastViewAdd);
                measureChildWithMargins(lastViewAdd, 0, 0);
                int left = decoratedRight;
                layoutDecoratedWithMargins(lastViewAdd, left, getItemTop(lastViewAdd), left + getDecoratedMeasuredWidth(lastViewAdd), getItemTop(lastViewAdd) + getDecoratedMeasuredHeight(lastViewAdd));
            } else {
                break;
            }
        }

        // 处理滑动
        View lastChild = getChildAt(getChildCount() - 1);
        int left = getDecoratedLeft(lastChild);
        if (getPosition(lastChild) == getItemCount() - 1) {
            // 最后一个 view 已经到底了,计算实际可以滑动的距离
            if (left - dx < 0) {
                realScroll = left;
            }
        }
        offsetChildrenHorizontal(-realScroll);

        // 回收滑出父布局的 view
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            int decoratedRight = getDecoratedRight(child);
            if (decoratedRight < 0) {
                removeAndRecycleView(child, recycler);
            }
        }
        return realScroll;
    }

这样,自定义的 LayoutManager 基本就完成了。

SnapHelper 部分

自定义完成了 LayoutManager 的确可以高效的实现 gif 中的效果,但是滑动的时候就有问题了,RecyclerView 默认是支持 fling 操作的,就是惯性滑动。而无法做到一次只滑动一页,并且居中显示的效果(类似 ViewPager 的滑动效果)。
为了实现这种效果,google 提供了一个 SnapHelper 抽象类,我们可以继承这个去实现自己的滑动逻辑。SDK 提供了 PagerSnapHelper 和 LinearSnapHelper 两种实现。
PagerSnapHelper 可以做到 ViewPager 那种一次滑动一页的效果,但是当滑动到最后一个 view 的时候会明显的出现卡顿。因为 PagerSnapHelper 默认不支持循环布局这种情况的。所以我继承 PagerSnaperHelper,修改了一点点逻辑,实现了循环滑动的效果。

public class BannerPageSnapHelper extends PagerSnapHelper {

    private boolean infinite = false;
    private OrientationHelper horizontalHelper;

    @Override
    public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX,
                                      int velocityY) {
        final int itemCount = layoutManager.getItemCount();
        if (itemCount == 0) {
            return RecyclerView.NO_POSITION;
        }

        View mStartMostChildView = findStartView(layoutManager, getHorizontalHelper(layoutManager));

        if (mStartMostChildView == null) {
            return RecyclerView.NO_POSITION;
        }
        final int centerPosition = layoutManager.getPosition(mStartMostChildView);
        if (centerPosition == RecyclerView.NO_POSITION) {
            return RecyclerView.NO_POSITION;
        }

        final boolean forwardDirection;
        if (layoutManager.canScrollHorizontally()) {
            forwardDirection = velocityX > 0;
        } else {
            forwardDirection = velocityY > 0;
        }

        if (forwardDirection) {
            if (centerPosition == layoutManager.getItemCount() - 1) {
                return infinite ? 0 : layoutManager.getItemCount() - 1;
            } else {
                return centerPosition + 1;
            }
        } else {
            return centerPosition;
        }
    }

    private View findStartView(RecyclerView.LayoutManager layoutManager,
                               OrientationHelper helper) {
        int childCount = layoutManager.getChildCount();
        if (childCount == 0) {
            return null;
        }

        View closestChild = null;
        int start = Integer.MAX_VALUE;

        for (int i = 0; i < childCount; i++) {
            final View child = layoutManager.getChildAt(i);
            int childStart = helper.getDecoratedStart(child);

            /** if child is more to start than previous closest, set it as closest  **/
            if (childStart < start) {
                start = childStart;
                closestChild = child;
            }
        }
        return closestChild;
    }

    @NonNull
    private OrientationHelper getHorizontalHelper(
            @NonNull RecyclerView.LayoutManager layoutManager) {
        if (horizontalHelper == null) {
            horizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager);
        }
        return horizontalHelper;
    }

    public boolean isInfinite() {
        return infinite;
    }

    public void setInfinite(boolean infinite) {
        this.infinite = infinite;
    }
}

扩展

关于 banner 部分,一般项目会有以下几个参数。

1、style 展示样式:例如圆角 或是平铺。 可以在每个子 view 外面套一个 CardView 去设置圆角,然后根据需求在 adapter 中设置 view 的宽高。

2、是否循环显示:BannerLayoutManager 和 PagerHelper 都有一个属性,infinite,为 true 时,循环显示。

3、自动播放:这个在 Activity 或者 Fragment 中用 Rxjava 或者 Handler 加一个定时器,调用 recyclerView.smoothScrollToPosition(position)就行了 。

4、滑动动画的显示时间:BannerLayoutManager 中有个 smoothScrollTime 属性,调用 set 方法设置一下就行了。

应该能满足大多数需求吧。。

源码

想了解详情去看代码吧 https://github.com/ZhangHao555/BannerRecyclerView

Apps
About Me
Google+: Trinea trinea
GitHub: Trinea