本文已授权微信公众号:鸿洋(hongyangAndroid)在微信公众号平台原创首发。
RecyclerView的时代
自从google推出了RecyclerView
这个控件, 铺天盖地的一顿叫好, 开发者们也都逐渐从ListView
,GridView
等控件上转移到了RecyclerView
上, 那为什么RecyclerView
这么受开发者的青睐呢? 一个主要的原因它的高灵活性, 我们可以自定义点击事件, 随意切换显示方式, 自定义item动画, 甚至连它的布局方式我们都可以自定义.
吐吐嘈
夸完了RecyclerView
, 我们再来吐槽一下大家在工作中各种奇葩需求, 大家在日常工作中肯定会遇到各种各种的奇葩需求, 这里没就包括奇形怪状的需求的UI. 站在我们开发者的角度, 看到这些奇葩的UI, 心中无数只草泥马呼啸崩腾而过, 在愤愤不平的同时还不得不老老实实的去找解决方案… 好吧, 吐槽这么多, 其实大家都没有错, 站在开发者的角度, 这样的需求无疑增加了我们很多工作量, 不加班怎么能完成? 但是站在老板的角度, 他也是希望将产品做好, 所以才会不断的思考改需求.
效果展示
开始进入正题, 今天我们的主要目的还是来自定义一个LayoutManager
, 实现一个奇葩的UI, 这样的一个布局我也是从我的一个同学的需求那看到的, 我们先来看看效果.
当然了, 效果不是很优雅, 主要是配色问题, 配色都是随机的, 所以肯定没有UI上好看. 原始需求是一个死的布局, 当然用自定义View的形式可以完成, 但是我认为那样不利于扩展, 例如效果图上的从每组3个变成每组9个, 还有一点很重要, 就是用RecyclerView
我们还得轻松的利用View
的复用机制. 好了, UI我们就先介绍到这, 下面我们开始一步步的实现这个效果.
自定义LayoutManager
前面说了, 我们这个效果是利用自定义RecyclerView
的LayoutManager
实现的, 所以, 首先我们要准备一个类让它继承RecyclerView.LayoutManager
.
public class CardLayoutManager extends RecyclerView.LayoutManager {}
定义完成后, android studio会提醒我们去实现一下RecyclerView.LayoutManager
里的一个抽象方法,
public class CardLayoutManager extends RecyclerView.LayoutManager {
@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
}
}
这样, 其实一个最简单的LayoutManager
我们就完成了, 不过现在在界面上是什么也没有的, 因为我们还没有对item view进行布局. 在开始布局之前, 还有几个参数需要我们从构造传递, 一个是每组需要显示几个, 一个当每组的总宽度小于RecyclerView
总宽度的时候是否要居中显示, 来重写一下构造方法.
public class CardLayoutManager extends RecyclerView.LayoutManager {
public static final int DEFAULT_GROUP_SIZE = 5;
// ...
public CardLayoutManager(boolean center) {
this(DEFAULT_GROUP_SIZE, true);
}
public CardLayoutManager(int groupSize, boolean center) {
mGroupSize = groupSize;
isGravityCenter = center;
mItemFrames = new Pool<>(new Pool.New<Rect>() {
@Override
public Rect get() { return new Rect();}
});
}
// ...
}
ok, 在完成准备工作后, 我们就开始着手准备进行item的布局操作了, 在RecyclerView.LayoutManager
中布局的入口是一个叫onLayoutChildren
的方法. 我们来重写这个方法.
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getItemCount() <= 0 || state.isPreLayout()) { return;}
detachAndScrapAttachedViews(recycler);
View first = recycler.getViewForPosition(0);
measureChildWithMargins(first, 0, 0);
int itemWidth = getDecoratedMeasuredWidth(first);
int itemHeight = getDecoratedMeasuredHeight(first);
int firstLineSize = mGroupSize / 2 + 1;
int secondLineSize = firstLineSize + mGroupSize / 2;
if (isGravityCenter && firstLineSize * itemWidth < getHorizontalSpace()) {
mGravityOffset = (getHorizontalSpace() - firstLineSize * itemWidth) / 2;
} else {
mGravityOffset = 0;
}
for (int i = 0; i < getItemCount(); i++) {
Rect item = mItemFrames.get(i);
float coefficient = isFirstGroup(i) ? 1.5f : 1.f;
int offsetHeight = (int) ((i / mGroupSize) * itemHeight * coefficient);
// 每一组的第一行
if (isItemInFirstLine(i)) {
int offsetInLine = i < firstLineSize ? i : i % mGroupSize;
item.set(mGravityOffset + offsetInLine * itemWidth, offsetHeight, mGravityOffset + offsetInLine * itemWidth + itemWidth,
itemHeight + offsetHeight);
}else {
int lineOffset = itemHeight / 2;
int offsetInLine = (i < secondLineSize ? i : i % mGroupSize) - firstLineSize;
item.set(mGravityOffset + offsetInLine * itemWidth + itemWidth / 2,
offsetHeight + lineOffset, mGravityOffset + offsetInLine * itemWidth + itemWidth + itemWidth / 2,
itemHeight + offsetHeight + lineOffset);
}
}
mTotalWidth = Math.max(firstLineSize * itemWidth, getHorizontalSpace());
int totalHeight = getGroupSize() * itemHeight;
if (!isItemInFirstLine(getItemCount() - 1)) { totalHeight += itemHeight / 2;}
mTotalHeight = Math.max(totalHeight, getVerticalSpace());
fill(recycler, state);
}
这里的代码很长, 我们一点点的来分析, 首先一个detachAndScrapAttachedViews
方法, 这个方法是RecyclerView.LayoutManager
的, 它的作用是将界面上的所有item都detach掉, 并缓存在scrap中,以便下次直接拿出来显示.
接下来我们通过一下代码来获取第一个item view并测量它.
View first = recycler.getViewForPosition(0);
measureChildWithMargins(first, 0, 0);
int itemWidth = getDecoratedMeasuredWidth(first);
int itemHeight = getDecoratedMeasuredHeight(first);
为什么只测量第一个view呢? 这里是因为在我们的这个效果中所有的item大小都是一样的, 所以我们只要获取第一个的大小, 就知道所有的item的大小了. 另外还有个方法getDecoratedMeasuredWidth
, 这个方法是什么意思? 其实类似的还有很多, 例如getDecoratedMeasuredHeight
, getDecoratedLeft
… 这个getDecoratedXXX
的作用就是获取该view以及他的decoration
的值, 大家都知道RecyclerView
是可以设置decoration
的.
继续代码
int firstLineSize = mGroupSize / 2 + 1;
int secondLineSize = firstLineSize + mGroupSize / 2;
这两句主要是来获取每一组中第一行和第二行中item的个数.
if (isGravityCenter && firstLineSize * itemWidth < getHorizontalSpace()) {
mGravityOffset = (getHorizontalSpace() - firstLineSize * itemWidth) / 2;
} else {
mGravityOffset = 0;
}
这几行代码的作用是当设置了isGravityCenter为true, 并且每组的宽度小于recyclerView的宽度时居中显示
.
接下来的一个if...else...
在if中的是判断当前item是否在它所在组的第一行. 为什么要加这个判断? 大家看效果就知道了, 因为第二行的view的起始会有一个二分之一的item宽度的偏移, 而且相对于第一行, 第二行的高度是偏移了二分之一的item高度. 至于这里面具体的逻辑大家可以对照着效果图去看代码, 这里就不一一解释了.
再往下, 我们记录了item的总宽度和总高度, 并且调用了fill
方法, 其实在这个onLayoutChildren
方法中我们仅仅记录了所有的item view所在的位置, 并没有真正的去layout它, 那真正的layout肯定是在这个fill
方法中了,
private void fill(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getItemCount() <= 0 || state.isPreLayout()) { return;}
Rect displayRect = new Rect(mHorizontalOffset, mVerticalOffset,
getHorizontalSpace() + mHorizontalOffset,
getVerticalSpace() + mVerticalOffset);
Rect rect = new Rect();
for (int i = 0; i < getChildCount(); i++) {
View item = getChildAt(i);
rect.left = getDecoratedLeft(item);
rect.top = getDecoratedTop(item);
rect.right = getDecoratedRight(item);
rect.bottom = getDecoratedBottom(item);
if (!Rect.intersects(displayRect, rect)) {
removeAndRecycleView(item, recycler);
}
}
for (int i = 0; i < getItemCount(); i++) {
Rect frame = mItemFrames.get(i);
if (Rect.intersects(displayRect, frame)) {
View scrap = recycler.getViewForPosition(i);
addView(scrap);
measureChildWithMargins(scrap, 0, 0);
layoutDecorated(scrap, frame.left - mHorizontalOffset, frame.top - mVerticalOffset,
frame.right - mHorizontalOffset, frame.bottom - mVerticalOffset);
}
}
}
在这里面, 我们首先定义了一个displayRect
, 他的作用就是标记当前显示的区域, 因为RecyclerView
是可滑动的, 所以这个区域不能简单的是0~高度/宽度这么一个值, 我们还要加上当前滑动的偏移量.
接下来, 我们通过getChildCount
获取RecyclerView
中的所有子view, 并且依次判断这些view是否在当前显示范围内, 如果不再, 我们就通过removeAndRecycleView
将它移除并回收掉, recycle
的作用是回收一个view, 并等待下次使用, 这里可能会改变它的属性(例如显示的值). 而scrap
的作用是缓存一个view, 并等待下次显示, 这里的view会被重新绑定新的数据.
ok, 继续代码, 又一个for循环, 这里是循环的getItemCount
, 也就是所有的item个数, 这里我们依然判断它是不是在显示区域, 如果在, 则我们通过recycler.getViewForPosition(i)
拿到这个view, 并且通过addView
添加到RecyclerView
中, 添加进去了还没完, 我们还需要调用measureChildWithMargins
方法对这个view进行测量. 最后的最后我们调用layoutDecorated
对item view进行layout操作.
好了, 我们来回顾一下这个fill
方法都是干了什么工作, 首先是回收操作, 这保证了RecyclerView
的子view仅仅保留可显示范围内的那几个, 然后就是将这几个view进行布局.
现在我们来到MainActivity
中,
mRecyclerView = (RecyclerView) findViewById(R.id.list);
mRecyclerView.setLayoutManager(new CardLayoutManager(mGroupSize, true));
mRecyclerView.setAdapter(mAdapter);
然后大家就可以看到上面的效果了, 高兴ing… 不过手指在屏幕上滑动的一瞬间, 高兴就会变成纳闷了. 纳尼? 怎么不能滑动呢? 好吧, 是因为我们的LayoutManager
没有处理滑动操作, 是的, 滑动操作需要我们自己来处理…
让RecyclerView动起来
要想让RecyclerView能滑动, 我们需要重写几个方法.
public boolean canScrollVertically() {}
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {}
同样的, 因为我们的LayoutManager
还支持横向滑动, 所以还有
public boolean canScrollHorizontally() {}
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {}
我们先来看看竖直方向上的滑动处理.
public boolean canScrollVertically() {
return true;
}
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
detachAndScrapAttachedViews(recycler);
if (mVerticalOffset + dy < 0) {
dy = -mVerticalOffset;
} else if (mVerticalOffset + dy > mTotalHeight - getVerticalSpace()) {
dy = mTotalHeight - getVerticalSpace() - mVerticalOffset;
}
offsetChildrenVertical(-dy);
fill(recycler, state);
mVerticalOffset += dy;
return dy;
}
第一个方法返回true代表着可以在这个方法进行滑动, 我们主要是来看第二个方法.
首先我们还是先调用detachAndScrapAttachedViews
将所有的子view缓存起来, 然后一个if...else...
判断是做边界检测, 接着我们调用offsetChildrenVertical
来做偏移, 主要代码中这里的参数, 是对scrollVerticallyBy
取反, 因为在scrollVerticallyBy
参数中这个dy
在我们手指往左滑动的时候是正值, 可能是google感觉这个做更加直观吧. 接着我们还是调用fill
方法来做新的子view的布局, 最后我们记录偏移量并返回.
这里面的逻辑还算简单, 横向滑动的处理逻辑也相同, 下面给出代码, 就不再赘述了.
public boolean canScrollHorizontally() {
return true;
}
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
detachAndScrapAttachedViews(recycler);
if (mHorizontalOffset + dx < 0) {
dx = -mHorizontalOffset;
} else if (mHorizontalOffset + dx > mTotalWidth - getHorizontalSpace()) {
dx = mTotalWidth - getHorizontalSpace() - mHorizontalOffset;
}
offsetChildrenHorizontal(-dx);
fill(recycler, state);
mHorizontalOffset += dx;
return dx;
}
ok, 现在我们再次运行程序, 发现RecyclerView
真的可以滑动了. 到现在位置我们的自定义LayoutManager
已经实现了. 不过那个菱形咋办呢? 算了, 直接搞一张图片上去就行了. 其实刚开始我也是这么想的, 不过仔细想想, 一个普通的图片是有问题的. 我们还是要通过自定义view的方式去实现.
来搞一搞那个菱形
上面提到了, 那个菱形用图片是有问题的, 问题出在哪呢? 先来说答案吧: 点击事件. 说到这可能有些同学已经明白了, 也有一部分还在纳闷中… 我们来具体分析一下. 首先来张图.
大家看黄色框部分, 其实第三个view的布局是在黄色框里面的, 那如果我们点击第一个view的黄色框里面的区域是不是就点击到第三个view上了? 而我们的感觉确是点击在了第一个上, 所以一个普通的view在这里是不适用的. 根据这个问题, 我们再来想想自定义这个view的思路, 是不是只要我们在dispatchTouchEvent方法中来判断点击的位置是不是在那个菱形中, 如果不在就返回false, 让事件可以继续在RecyclerView往下分发
就可以了?
下面我们根据这个思路来实现这么个view.
public class CardItemView extends View {
private int mSize;
private Paint mPaint;
private Path mDrawPath;
private Region mRegion;
public CardItemView(Context context) {
this(context, null, 0);
}
public CardItemView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CardItemView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Paint.Style.FILL);
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.Card, defStyleAttr, 0);
mSize = ta.getDimensionPixelSize(R.styleable.Card_size, 10);
mPaint.setColor(ta.getColor(R.styleable.Card_bgColor, 0));
ta.recycle();
mRegion = new Region();
mDrawPath = new Path();
mDrawPath.moveTo(0, mSize / 2);
mDrawPath.lineTo(mSize / 2, 0);
mDrawPath.lineTo(mSize, mSize / 2);
mDrawPath.lineTo(mSize / 2, mSize);
mDrawPath.close();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(mSize, mSize);
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
if (!isEventInPath(event)) { return false;}
}
return super.dispatchTouchEvent(event);
}
private boolean isEventInPath(MotionEvent event) {
RectF bounds = new RectF();
mDrawPath.computeBounds(bounds, true);
mRegion.setPath(mDrawPath, new Region((int)bounds.left,
(int)bounds.top, (int)bounds.right, (int)bounds.bottom));
return mRegion.contains((int) event.getX(), (int) event.getY());
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(Color.TRANSPARENT);
canvas.drawPath(mDrawPath, mPaint);
}
public void setCardColor(int color) {
mPaint.setColor(color);
invalidate();
}
}
代码并不长, 首先我们通过Path
来规划好我们要绘制的菱形的路径, 然后在onDraw
方法中将这个Path
绘制出来, 这样, 那个菱形就出来了.
我们还是重点来关注一下dispatchTouchEvent
方法, 这个方法中我们通过一个isEventInPath
来判断是不是DOWN
事件发生在了菱形内, 如果不是则直接返回false, 不处理事件.
通过上面的分析, 我们发现其实重点是在isEventInPath
中, 这个方法咋写的呢?
private boolean isEventInPath(MotionEvent event) {
RectF bounds = new RectF();
mDrawPath.computeBounds(bounds, true);
mRegion.setPath(mDrawPath, new Region((int)bounds.left,
(int)bounds.top, (int)bounds.right, (int)bounds.bottom));
return mRegion.contains((int) event.getX(), (int) event.getY());
}
判断点是不是在某一个区域内, 我们是通过Region
来实现的, 首先我们通过Path.computeBounds
方法来获取到这个path
的边界, 然后通过Region.contains
来判断这个点是不是在该区域内.
到现在为止, 整体的效果我们已经实现完成了, 而且点击事件我们处理的也非常棒, 如果大家有这种需求, 可以直接copy该代码使用, 如果没有就当让大家来熟悉一下如何自定义LayoutManager
了.
参考链接: https://github.com/hehonghui/android-tech-frontier/
最后给出github地址: https://github.com/qibin0506/CardLayoutManager