RecyclerView自定义LayoutManager,打造不规则布局
来源:互联网 发布:anello淘宝上有正品吗 编辑:程序博客网 时间:2024/06/05 23:06
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 {}
- 1
- 1
定义完成后, 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); }}
- 1
这样, 其实一个最简单的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, center); } public CardLayoutManager(int groupSize, boolean center) { mGroupSize = groupSize; isGravityCenter = center; mItemFrames = new Pool<>(new Pool.New<Rect>() { @Override public Rect get() { return new Rect();} }); } // ...}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 10
- 111131151617
ok, 在完成准备工作后, 我们就开始着手准备进行item的布局操作了, 在RecyclerView.LayoutManager
中布局的入口是一个叫onLayoutChildren
的方法. 我们来重写这个方法.
@Overridepublic 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);}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
为什么只测量第一个view呢? 这里是因为在我们的这个效果中所有的item大小都是一样的, 所以我们只要获取第一个的大小, 就知道所有的item的大小了. 另外还有个方法getDecoratedMeasuredWidth
, 这个方法是什么意思? 其实类似的还有很多, 例如getDecoratedMeasuredHeight
,getDecoratedLeft
… 这个getDecoratedXXX
的作用就是获取该view以及他的decoration
的值, 大家都知道RecyclerView
是可以设置decoration
的.
继续代码
int firstLineSize = mGroupSize / 2 + 1;int secondLineSize = firstLineSize + mGroupSize / 2;
- 1
- 2
这两句主要是来获取每一组中第一行和第二行中item的个数.
if (isGravityCenter && firstLineSize * itemWidth < getHorizontalSpace()) { mGravityOffset = (getHorizontalSpace() - firstLineSize * itemWidth) / 2;} else { mGravityOffset = 0;}
- 1
- 2
- 3
- 4
- 5
这几行代码的作用是当设置了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); } }}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
在这里面, 我们首先定义了一个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);
- 1
- 2
- 3
然后大家就可以看到上面的效果了, 高兴ing… 不过手指在屏幕上滑动的一瞬间, 高兴就会变成纳闷了. 纳尼? 怎么不能滑动呢? 好吧, 是因为我们的LayoutManager
没有处理滑动操作, 是的, 滑动操作需要我们自己来处理…
让RecyclerView动起来
要想让RecyclerView能滑动, 我们需要重写几个方法.
public boolean canScrollVertically() {}public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {}
- 1
- 2
同样的, 因为我们的LayoutManager
还支持横向滑动, 所以还有
public boolean canScrollHorizontally() {}public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {}
- 1
- 2
我们先来看看竖直方向上的滑动处理.
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;}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
第一个方法返回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;}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 1
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(); }}
- 65
代码并不长, 首先我们通过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
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
- 40
- 41
- 42
- 43
- 44
- 45
- 46
- 47
- 48
- 49
- 50
- 51
- 52
- 53
- 54
- 55
- 56
- 57
- 58
- 59
- 60
- 61
- 62
- 63
- 64
- 2
- 3
- 4
- 5
- 6
- 7
- RecyclerView自定义LayoutManager,打造不规则布局
- RecyclerView自定义LayoutManager,打造不规则布局
- android RecyclerView自定义 LayoutManager
- Recyclerview-自定义LayoutManager
- 针对RecyclerView打造属于你的LayoutManager
- 打造属于你的LayoutManager-RecyclerView
- RecyclerView——实现自定义LayoutManager
- RecyclerView自定义LayoutManager实现横向瀑布流
- RecyclerView系列(7)—自定义LayoutManager
- RecyclerView ---LayoutManager
- 利用自定义xml属性指定来RecyclerView的LayoutManager
- RecyclerView 源码学习(二):一步一步自定义LayoutManager
- 自定义 LayoutManager 实现弧形以及滑动放大效果 RecyclerView
- 自定义 LayoutManager 实现弧形以及滑动放大效果 RecyclerView
- 自定义LayoutManager
- LayoutManager自定义
- RecyclerView has no LayoutManager
- 【Android】掌握自定义LayoutManager(二) 实现流式布局
- 《深入理解java虚拟机》学习-第八章-虚拟机字节码执行引擎-运行时栈帧结构
- iOS开发-获取设备IP
- 解决spring请求中文乱码问题
- 高性能web服务器nginx
- VS2010 无可用源
- RecyclerView自定义LayoutManager,打造不规则布局
- Hibernate—(HQL查询)
- 整型与无符号整型相加
- Hadoop基本理论
- ionic3 解决Error: No provider for Http!
- Billboard——线段树
- unity\_baseLayout
- tomcat服务器详解
- jsp内置对象