持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第9天,点击查看活动详情
前言
为什么会想到做这一个选题?之前写到一篇九宫格动态列表评论的软键盘弹出效果,自动定位到指定坐标的文章【传送门】。
由于我们的九宫格控件已经上线4年左右了,最近开始要大改动态模块,所以需要捡起来重新修改一下,所以顺便整理了一下当时实现的经历与坑点。
当时实现九宫格控件的时候参考了市面上的很多Demo与开源的方案,确实有很多方案,但是大多数方案也只是实现了效果,当数据多了、类型多了之后很容易发生卡顿,比较严重还有高度测量问题,图片加载混乱等等问题。
索性我就把从开始选方案到最终成型的过程记录一下,希望多大家有所启发。
由于时间久远,其中还发现有性能问题换了一种方案,当时记录的笔记也比较粗糙,没有记录完全,我尽量把我当时的思路讲清楚。
一、明确需求,实现思路
由于我们的需求比较特殊,不止是展示好友动态,还可以发布话题,指定发布圈子,这些都跟九宫格控件本身不相关,但是在列表的展示类型上也不止九宫格展示,还需要圆角图片,九宫格投票的类型,和视频的类型等等。
所以我们九宫格控件还需要能实现视频的展示,九宫格图片的展示与预览,还需要对投票的类型支持。
如果要实现九宫格的效果,其实大致就两种方案,要么使用GridView,要么使用自定义ViewGroup。
如果是详情页面还可以使用GridView的方式,但是在列表中由于需要复用的问题,导致Grid的效率相对比较差,并且还有一些特殊的展示需求,如4个图片并行排列,所以我们就直接考虑了自定义ViewGroup的方式。
如何实现自定义ViewGroup呢?其实我们实现了两个版本的迭代。一起来看看吧。
二、第一版本,直接实现
第一个版本的思路,其实就是一个类,ViewGroup在里面测量,布局,并使用一个Map容器记录每一个九宫格的宽高属性,避免复用的时候快速测量。
测量代码如下:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
if (!canMeasureLayout){ //主要是在onPause的时候不要再测量了
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
return;
}
int width = MeasureSpec.getSize(widthMeasureSpec);
int height = 0;
int totalWidth = width - getPaddingLeft() - getPaddingRight();
if (mImageInfo != null && mImageInfo.size() > 0) {
Log.e("onMeasure","进入条件,准备测量了");
//查看缓存如果有缓存的宽高,那么直接使用,不需要再次测量,避免回收使用的时候gridWidth是回收的宽度造成测量不准确
if (mCurPosition != -1 && WHCacheHelper.mAllWidth.get(mCurPosition) != null && WHCacheHelper.mAllWidth.get(mCurPosition) != 0) {
gridWidth = WHCacheHelper.mAllWidth.get(mCurPosition);
gridHeight = WHCacheHelper.mAllHeight.get(mCurPosition);
} else {
Log.e("onMeasure","进入条件,开始测量了");
//没有缓存需要测量,测量完成之后需要缓存起来
if (mImageInfo.size() == 1) {
//只有一张图片的时候
columnCount = 1;
rowCount = 1;
//说明是初始化,先尝试拿到url中的宽高
ImageInfo imageInfo = mImageInfo.get(0);
String thumbnailUrl = imageInfo.getThumbnailUrl();
int startIndex = thumbnailUrl.lastIndexOf("-");
int endIndex = thumbnailUrl.lastIndexOf(".");
if (startIndex != -1 && startIndex != 0 && endIndex != -1 && endIndex != 0 && endIndex - startIndex <= 10) {
String substring = thumbnailUrl.substring(startIndex + 1, endIndex);
if (!TextUtils.isEmpty(substring) && substring.contains("x")) {
String[] split = substring.split("x");
try {
handleWidthHeight(Integer.parseInt(split[0]), Integer.parseInt(split[1]), totalWidth);
} catch (NumberFormatException e) {
e.printStackTrace();
handleWidthHeight(0, 0, totalWidth);
}
} else {
handleWidthHeight(0, 0, totalWidth);
}
} else {
handleWidthHeight(0, 0, totalWidth);
}
} else {
//如果是多张图片,计算宽度,宽都按总宽度的 1/3 计算一个grid的宽高
gridWidth = gridHeight = (totalWidth - gridSpacing * 2) / 3;
}
//缓存每一个NineGridView的宽高
if (mCurPosition != -1) {
WHCacheHelper.mAllWidth.put(mCurPosition, gridWidth);
WHCacheHelper.mAllHeight.put(mCurPosition, gridHeight);
}
}
}
width = gridWidth * columnCount + gridSpacing * (columnCount - 1) + getPaddingLeft() + getPaddingRight();
height = gridHeight * rowCount + gridSpacing * (rowCount - 1) + getPaddingTop() + getPaddingBottom();
setMeasuredDimension(width, height);
}
由于需要我们处理单个图片的情况与多个图片的情况,由于一行只展示三个Item,所以我们可以计算公共多少行,再加上间距即可计算需要测量的宽高。
主要是单个图片的测量相对麻烦,需要拿到图片链接中的宽高属性,判断是否大于控件宽度,如果大于控件宽度则需要等于控件宽度,再对高度进行比例的缩放。
/**
* 处理测量真正的宽高(单张图片的)
*/
private void handleWidthHeight(int width, int height, int totalWidth) {
if (width == 0) {
//如果没有携带分辨率,那么就按1:1的比例
gridWidth = singleImageSize;
gridHeight = singleImageSize;
} else {
//如果携带了分辨率,那么直接赋值
float width2 = width;
float height2 = height;
//根据不同的比例获取不同的宽高
singleImagefinalWidthHeight(width, height, width2 / height2);
}
}
/**
* 根据宽高比,重新计算最终的gridWidth,gridHeight
*/
private void singleImagefinalWidthHeight(float imgWidth, float imgHeight, float ratio) {
singleImageRatio = ratio;
int realImgWith = 0;
int realImgHeight = 0;
//设置单独照片的时候比例放大,不要写死了-指定宽度高度。设置一个最小的长度,低于这个长度就按照这个长度
if (ratio >= 1.0f) {
//横长竖短 - 如果大于最大值,不做处理,图片展示为最大长度算
if (imgWidth >= singleImageSize) {
//如果在最大和最小值之间,那么按图片的本身的宽高
realImgWith = singleImageSize;
} else if (imgWidth > singleImageMinSize) {
realImgWith = (int) imgWidth;
} else {
//如果小于最小值,那么按最小的长度算
realImgWith = singleImageMinSize;
}
gridWidth = realImgWith;
gridHeight = (int) (realImgWith / ratio);
} else {
//竖长横短 - 和上面一样的逻辑
if (imgHeight >= singleImageSize) {
realImgHeight = singleImageSize;
} else if (imgHeight > singleImageMinSize) {
realImgHeight = (int) imgHeight;
} else {
realImgHeight = singleImageMinSize;
}
gridWidth = (int) (realImgHeight * ratio);
gridHeight = realImgHeight;
}
}
主要需要处理的就是单个图片的宽高与比例,除了测量之外,ViewGroup最重要的就是给子View布局了:
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
if (!canMeasureLayout) return; //主要是在onPause的时候不要再布局了
Boolean aBoolean = WHCacheHelper.mAllPositionLayouted.get(mCurPosition);
if (aBoolean != null && aBoolean) { //如果已经测量过了,有缓存标识,那么不要再次测量了
return;
}
if (mImageInfo == null) {
return;
}
Log.e("onLayout","进入条件,开始布局了");
int childrenCount = mImageInfo.size();
for (int i = 0; i < childrenCount; i++) {
final ImageView childrenView = (ImageView) getChildAt(i);
//先清除回收img上面的原有bitmap缓存
childrenView.setImageDrawable(getContext().getResources().getDrawable(R.drawable.ic_default_color));
int rowNum = i / columnCount;
int columnNum = i % columnCount;
int left = (gridWidth + gridSpacing) * columnNum + getPaddingLeft();
int top = (gridHeight + gridSpacing) * rowNum + getPaddingTop();
int right = left + gridWidth;
int bottom = top + gridHeight;
childrenView.layout(left, top, right, bottom);
//处理图片的加载,并动态的处理只有一张图片的时候的宽高比例
if (mImageLoader != null) {
mImageLoader.onDisplayImage(getContext(), childrenView, mImageInfo.get(i).thumbnailUrl);
}
WHCacheHelper.mAllPositionLayouted.clear();
WHCacheHelper.mAllPositionLayouted.put(mCurPosition, true);
}
}
这个是根据行与列计算每一个Item的位置,并且使用图片加载引擎去加载图片。
其实比较难控制的就是对缓存类的控制
public class WHCacheHelper {
public static Map<Integer, Integer> mAllWidth = new HashMap<>();
public static Map<Integer, Integer> mAllHeight = new HashMap<>();
}
由于使用缓存很容易导致出错,导致一些情况下缓存的宽高不对,从而导致偶现的效果错乱的问题。如果去掉自定义的缓存就可以实现效果,但是滑动还是会卡顿,大致原因就是在测量中进行了复杂的逻辑校验与判断,导致耗时相对比较多,造成列表的卡顿。
所以才有了之前说的第一个版本上线之后反正滚动卡顿,才有了后面的一个版本重做。
三、第二版本优化实现,解耦逻辑
第一个版本上线的效果并不是很好,并且由于后面的版本上线了评论,投票等其他九宫格的方式,所以迭代更新了第二个版本。
我们需要把九宫格的逻辑提取出来作为一个基类的抽象类,而不同的布局类型去实现不同的布局,不止是图片,对于一些自定义的布局也能做成九宫格的形式去展示,扩展性也更好一点。
/**
* 抽象九宫格-具体的布局和测量在这里完成
*/
public abstract class AbstractNineGridLayout<T> extends ViewGroup {
private static final int MAX_CHILDREN_COUNT = 9;
private int itemWidth;
private int itemHeight;
private int horizontalSpacing;
private int verticalSpacing;
private boolean singleMode;
private boolean fourGridMode;
private int singleWidth;
private int singleHeight;
private boolean singleModeOverflowScale;
public AbstractNineGridLayout(Context context) {
this(context, null);
}
public AbstractNineGridLayout(Context context, AttributeSet attrs) {
super(context, attrs);
if (attrs != null) {
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.NineGridLayout);
int spacing = a.getDimensionPixelSize(R.styleable.NineGridLayout_spacing, 0);
horizontalSpacing = a.getDimensionPixelSize(R.styleable.NineGridLayout_horizontal_spacing, spacing);
verticalSpacing = a.getDimensionPixelSize(R.styleable.NineGridLayout_vertical_spacing, spacing);
singleMode = a.getBoolean(R.styleable.NineGridLayout_single_mode, true);
fourGridMode = a.getBoolean(R.styleable.NineGridLayout_four_gird_mode, true);
singleWidth = a.getDimensionPixelSize(R.styleable.NineGridLayout_single_mode_width, 0);
singleHeight = a.getDimensionPixelSize(R.styleable.NineGridLayout_single_mode_height, 0);
singleModeOverflowScale = a.getBoolean(R.styleable.NineGridLayout_single_mode_overflow_scale, true);
a.recycle();
}
//优先填充布局,再执行测量和绘制
fillChildView();
}
/**
* 设置显示的数量
*/
public void setDisplayCount(int count) {
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
getChildAt(i).setVisibility(i < count ? VISIBLE : GONE);
}
}
/**
* 设置单独布局的宽和高
*/
public void setSingleModeSize(int w, int h) {
if (w != 0 && h != 0) {
this.singleWidth = w;
this.singleHeight = h;
}
}
/**
* 一般用这个方法填充布局,每一个小布局的布局文件
*/
protected void inflateChildLayout(int layoutId) {
removeAllViews();
for (int i = 0; i < MAX_CHILDREN_COUNT; i++) {
LayoutInflater.from(getContext()).inflate(layoutId, this);
}
}
/**
* 返回每一个小布局的内部控件ID,用数组包装返回
*/
@SuppressWarnings("unchecked")
protected <V extends View> V[] findInChildren(int viewId, Class<V> clazz) {
V[] result = (V[]) Array.newInstance(clazz, getChildCount());
for (int i = 0; i < result.length; i++) {
result[i] = (V) getChildAt(i).findViewById(viewId);
}
return result;
}
//子类去实现-填充布局文件
protected abstract void fillChildView();
//子类去实现-对布局文件赋值数据(一般专门去给adapter去调用的)
public abstract void renderData(T data);
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec) - getPaddingLeft() - getPaddingRight();
int notGoneChildCount = getNotGoneChildCount();
if (notGoneChildCount == 1 && singleMode) {
itemWidth = singleWidth > 0 ? singleWidth : widthSize;
itemHeight = singleHeight > 0 ? singleHeight : widthSize;
if (itemWidth > widthSize && singleModeOverflowScale) {
itemWidth = widthSize; //单张图片先定宽度。
itemHeight = (int) (widthSize * 1f / singleWidth * singleHeight); //根据宽度计算高度
}
} else {
itemWidth = (widthSize - horizontalSpacing * 2) / 3;
itemHeight = itemWidth;
}
//测量子布局
measureChildren(MeasureSpec.makeMeasureSpec(itemWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(itemHeight, MeasureSpec.EXACTLY));
if (heightMode == MeasureSpec.EXACTLY) {
setMeasuredDimension(widthMeasureSpec, heightMeasureSpec);
} else {
notGoneChildCount = Math.min(notGoneChildCount, MAX_CHILDREN_COUNT);
int height = ((notGoneChildCount - 1) / 3 + 1) * (itemHeight + verticalSpacing) - verticalSpacing + getPaddingTop() + getPaddingBottom();
setMeasuredDimension(widthMeasureSpec, MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY));
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
int notGoneChildCount = getNotGoneChildCount();
int position = 0;
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
if (child.getVisibility() == View.GONE) {
continue;
}
int row = position / 3;
int col = position % 3;
if (notGoneChildCount == 4 && fourGridMode) {
row = position / 2;
col = position % 2;
}
int x = col * itemWidth + getPaddingLeft() + horizontalSpacing * col;
int y = row * itemHeight + getPaddingTop() + verticalSpacing * row;
child.layout(x, y, x + itemWidth, y + itemHeight);
//最多只摆放9个
position++;
if (position == MAX_CHILDREN_COUNT) {
break;
}
}
}
//获取真正显示的子布局
private int getNotGoneChildCount() {
int childCount = getChildCount();
int notGoneCount = 0;
for (int i = 0; i < childCount; i++) {
if (getChildAt(i).getVisibility() != View.GONE) {
notGoneCount++;
}
}
return notGoneCount;
}
}
为了方便大家观看,先把全部的代码贴出。
第二个方案和第一个方案的区别:
- 九宫格子View的填充方式暴露出去,让子类自由实现,可以是控件也可以是布局
- 把九宫格子View的数据填充方式暴露,子类自由的设置数据类型
- 对子View中的控件放入数组中管理,有多少个子View就有多少个数量
- 子类自由设置数据类型,方便对每一个子View的控制
- 对单独图片的宽高提取预取到对象中,没有在测量中进行耗时逻辑
- 对单独图片的宽高最大值进行限制,而并非一股脑的控件宽度
此方法的测量和布局方式其实是和第一种方案差不多的,只是把数据预处理放在请求数据之后了,把缓存的逻辑去掉了。因为测量效率比较高不需要缓存也能满足了。(当然如果想提高效率也能自己拓展实现宽高的缓存)
如何使用呢?
比如我们默认的图片九宫格,我们就可以直接继承这个基类。
/**
* 默认的图片九宫格
*/
public class ImageViewNineGridLayout extends AbstractNineGridLayout<List<ImageInfo>> {
private ImageView[] imageViews;
private final ImageLoader mImageLoader;
public ImageViewNineGridLayout(Context context, AttributeSet attrs) {
super(context, attrs);
mImageLoader = new NineGlideLoader();
}
@Override
protected void fillChildView() {
inflateChildLayout(R.layout.item_image_grid);
imageViews = findInChildren(R.id.iv_image, ImageView.class);
}
@Override
public void renderData(List<ImageInfo> imageInfos) {
setSingleModeSize(imageInfos.get(0).getImageViewWidth(), imageInfos.get(0).getImageViewHeight());
setDisplayCount(imageInfos.size());
for (int i = 0; i < imageInfos.size(); i++) {
String url = imageInfos.get(i).getThumbnailUrl();
ImageView imageView = imageViews[i];
//使用自定义的Loader加载
mImageLoader.onDisplayImage(getContext(), imageView, url);
//点击事件
setClickListener(imageView, i, imageInfos);
}
}
//设置内部每一个图片的点击事件,跳转到预览页面
private void setClickListener(ImageView imageView, int position, List<ImageInfo> imageInfos) {
imageView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
List<Object> list = new ArrayList<>();
for (ImageInfo imageInfo : imageInfos) {
if (imageInfo != null) {
list.add(imageInfo.getThumbnailUrl());
}
}
if (mListener != null) {
mListener.onPreview(imageViews, position, list);
}
}
});
}
private OnPreViewListener mListener;
public void setOnPreViewListener(OnPreViewListener listener) {
mListener = listener;
}
public interface OnPreViewListener {
void onPreview(ImageView[] imageViews, int position, List<Object> imageInfos);
}
}
由于布局中就是一个ImageView,所以很简单,只需要提供数据,然后在数据填充的方法中使用图片加载引擎去加载图片即可。
效果如图:(本图片为本地测试数据,无任何特殊含义)
而如果是投票的类型,我们就需要添加一个布局,需要确定投票的选项与勾勾的选择逻辑。
/**
* 新版本投票的九宫格
*/
public class BallotNineGridLayout extends AbstractNineGridLayout<List<ImageInfo>> {
private MyOptionImageView[] imageViews;
private final ImageLoader mImageLoader;
//默认在xml中使用
public BallotNineGridLayout(Context context, AttributeSet attrs) {
super(context, attrs);
mImageLoader = new NineGlideLoader();
}
@Override
protected void fillChildView() {
inflateChildLayout(R.layout.item_ballot_image_grid);
imageViews = findInChildren(R.id.iv_image, MyOptionImageView.class);
}
@Override
public void renderData(List<ImageInfo> imageInfos) {
setSingleModeSize(imageInfos.get(0).getImageViewWidth(), imageInfos.get(0).getImageViewHeight());
setDisplayCount(imageInfos.size());
for (int i = 0; i < imageInfos.size(); i++) {
String url = imageInfos.get(i).getThumbnailUrl();
MyOptionImageView optionImageView = imageViews[i];
//使用自定义的Loader加载
mImageLoader.onDisplayImage(getContext(), optionImageView, url);
//设置点击事件
setClickListener(optionImageView, i);
}
}
//设置内部每一个图片的点击事件
private void setClickListener(MyOptionImageView optionImageView, int position) {
optionImageView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
if (mListener != null) mListener.onInnerClick(position);
}
});
}
private OnInnerClickListener mListener;
public void setOnInnerClickListener(OnInnerClickListener listener) {
mListener = listener;
}
public interface OnInnerClickListener {
void onInnerClick(int innerPosition);
}
//获取到指定索引的投票图片控件
public MyOptionImageView getOptionImageView(int index) {
if (index >= imageViews.length) return null;
return imageViews[index];
}
}
这里我是把投票的View做了一个封装,所以跟图片九宫格有点类似,当然如果直接使用自定义的投票布局也能一样的实现的。
实现的效果大致如下:(本图片为本地测试数据,无任何特殊含义)
如果想扩展的话,可以自己继承抽象类,填充自己的layout,这样的话以后不管是什么样的布局,只要是以九宫格的方式展示,都可以使用自定义的布局方式来实现了。
四、其他注意事项
当前真正实现一个九宫格列表还有其他一些细枝末节的点,比如圆角,点击覆盖效果,预览效果。
比如我们的图片都是圆角的,并且加了点击的阴影效果。
public class NineGridViewWrapper extends CustomRoundImageView {
private int moreNum = 0;
private int maskColor = 0x88000000;
public NineGridViewWrapper(Context context) {
this(context, null);
}
public NineGridViewWrapper(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public NineGridViewWrapper(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setFocusable(false);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
Drawable drawable = getDrawable();
if (drawable != null) {
drawable.setColorFilter(Color.GRAY, PorterDuff.Mode.MULTIPLY);
ViewCompat.postInvalidateOnAnimation(this);
}
break;
case MotionEvent.ACTION_MOVE:
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
Drawable drawableUp = getDrawable();
if (drawableUp != null) {
drawableUp.clearColorFilter();
ViewCompat.postInvalidateOnAnimation(this);
}
break;
}
return super.onTouchEvent(event);
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
setImageDrawable(null);
}
}
比如如何在九宫格的最后一个子View上显示剩余图片数量的文本,也可以直接修改上面的类,在ImageView上面绘制。
比如选中与未选中的图片的drawable的绘制,直接在ImageView上面绘制,和上面的方案都是一样的思路去实现。
而需要注意的是,自定义Item的宽高缓存需要慎重,如果想继续优化速度,其实大家也可以加入Item宽高缓存,在测量的时候直接赋值,会稍微加快处理的速度,但是记住在onLayout的时候记得一定要重新加载数据。
还有就是在列表上下滚动的时候有复用的问题,记住图片最好在加载前清除之前的图片并展示站位图,否则可能会出现九宫格其中的一张图片显示复用的图片,如果使用得当时可以加速测量速度,如果使用不当反倒会负优化。
大致运行环境如下:(本图片为本地测试数据,无任何特殊含义)
录制GIF软件帧数比较低,大家明白即可。
总结
总的来说九宫格控件的自定义并不难,主要就是ViewGroup的自定义,如何测量,如何布局子View,可以说是比较标准的测量与布局示例了,然后就是对一些子View的抽取,子View的数据赋值的抽取,就是一个比较完善的自定义布局了。
本文的核心代码都已经在文中贴出了,想要可以自取即可。
当然了,这种方案可能也只是闭门造车,还需要大家提提意见,如果你有更好的方案,或者优化的空间都也可以一起交流一下。如有错漏的地方还请指出,如果有疑问也可以在评论区大家一起讨论哦。
如果感觉本文对你有一点点的启发,还望你能点赞
支持一下,你的支持是我最大的动力。
Ok,这一期就此完结。