由于项目要求定制桌面,并且根据应用功能的主次来决定应用图标的大小,有的垮行、有的跨列。当时还不知道有这个开源项目可以用,所以就用LinearLayout+weight的方式实现的,但也只实现了跨列布局,并且每行的图标weight都必须相等。总之当图标比重变化的时候就要做一番调整,使用性很差。
虽然网上很多关于磁贴的实现方式都是利用上述的方式实现,但后来知道AsymmetricGridView可以解决的时候立马放弃了上述的方式。用起来也挺方便的,但由于我的使用情景是桌面图标显示,所以在使用的过程发现其存在两个缺点:
- 默认单元列个数为3,每行图标所占单元列数总和大于3时,布局比例就失调了。
- 行高与单元列宽相等,这就导致在一些屏幕尺寸上图标超出屏幕显示区域。
一、AsymmetricGridView的实现原理
从GitHub上下在源码后,查看其工程目录可以发现library目录。其中就是实现非对称布局的最重要部分,该实现方式有两种:1.基于GridView实现;2.基于RecyclerView实现;本文针对GridView方式进行进行分析。
说到GridView,不外乎两点:1.数据源;2.适配器Adapter。AsymmetricGridView的数据源是DemoItem,也就是子项显示的数据;适配器Adapter是AsymmetricGridViewAdapter。所以我们先从AsymmetricGridViewAdapter入手。
AsymmetricGridViewAdapter的作用是基于GridView的适配器,封装成AsymmetricGridView使用的适配器。其中主要用到的类有:“AppsAdapter”和“AdapterImpl”,前者是GridView使用的适配器,后者提供计算行高、列宽的方法,是实现非对称布局的重点研究对象。在此先总体描述下该布局的实现方式:
1. 先将AppAdapter中的数据DemoItem按行所占的比例计算分类成行数据。例如四个子控件,行总单元数为4.每个控件所占比例为:1、2、1、2;那么前三个控件:1+2+1=4,放在第一行、第四个控件放第二行。以此类推。
2. 根据屏幕尺寸大小、行高总单元数和控件所占单元数来计算每个字控件的宽高尺寸。
3. 根据子控件尺寸参数和所在位置填充。
1. AppsAdapter
该类实现方式与普通的GridView适配器实现方式一致,其中强调两个方法:
/**
* 获取Item 类型个数
*/
@Override
public int getViewTypeCount() {
return 3;
}
/**
* 获取Item类型 :
* @param : position
* 0: 长 = 宽
* 1: 长 > 宽
* 2: 长 < 宽
*/
@Override
public int getItemViewType(int position) {
float clum = mItems.get(position).getColumnSpan();
float row = mItems.get(position).getRowSpan();
if(row == clum){
return 0;
}else if(row > clum){
return 1;
}else {
return 2;
}
}
这两个方法的返回值会在“AdapterImpl”中用到。
2. AdapterImpl
为了更好的理解,我们根据AsymmetricGridViewAdapter的实现方式来研究AdapterImpl。先贴上代码:
package com.felipecsl.asymmetricgridview;
import android.content.Context;
import android.database.DataSetObserver;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.ListAdapter;
import android.widget.WrapperListAdapter;
/**
* 对 AppsAdapter 进行封装,并增加一些接口
* @author chenyuye
*/
public final class AsymmetricGridViewAdapter extends BaseAdapter
implements AGVBaseAdapterDao, WrapperListAdapter {
//AppsAdapter存放数据源
private final ListAdapter appsAdapter;
//适配器接口
private final AdapterImpl adapterImpl;
/**
* 对 AppsAdapter 进行封装,并增加一些接口
* @param context
* @param listView AsymmetricGridView
* @param adapter AppsAdapter
*/
public AsymmetricGridViewAdapter(Context context, AsymmetricGridView listView,
ListAdapter adapter) {
this.adapterImpl = new AdapterImpl(context, this, listView);
this.appsAdapter = adapter;
//注册数据源变化观察者
appsAdapter.registerDataSetObserver(new GridDataSetObserver());
}
class GridDataSetObserver extends DataSetObserver {
@Override
public void onChanged() {
recalculateItemsPerRow();
}
@Override
public void onInvalidated() {
recalculateItemsPerRow();
}
}
/**
* 重新计算Item获取每行数据
*/
void recalculateItemsPerRow() {
adapterImpl.recalculateItemsPerRow();
}
/**
* 获取AsymmetricItem
*/
@Override
public AsymmetricItemDao getItem(int position) {
return (AsymmetricItemDao) appsAdapter.getItem(position);
}
@Override
public AsymmetricViewHolder onCreateAsymmetricViewHolder(
int position, ViewGroup parent, int viewType) {
return new AsymmetricViewHolder<>(appsAdapter.getView(position, null, parent));
}
@Override
public void onBindAsymmetricViewHolder(
AsymmetricViewHolder holder, ViewGroup parent, int position) {
appsAdapter.getView(position, holder.itemView, parent);
}
@Override
public long getItemId(int position) {
return appsAdapter.getItemId(position);
}
/**
* 重点关注,子控件布局由这里实现
*/
@Override
public View getView(int position, View convertView, ViewGroup parent) {
//创建ListView的子Item LinearLayout
AdapterImpl.ViewHolder viewHolder = adapterImpl.onCreateViewHolder();
adapterImpl.onBindViewHolder(viewHolder, position, parent);
return viewHolder.itemView;
}
/**
* Returns the row count for ListView display purposes
*/
@Override
public int getCount() {
return adapterImpl.getRowCount();
}
/**
* 获取Item类型 偶数排列:1 基数排列:0
*/
@Override
public int getItemViewType(int position) {
return appsAdapter.getItemViewType(position);
}
@Override
public int getActualItemCount() {
return appsAdapter.getCount();
}
@Override
public ListAdapter getWrappedAdapter() {
return appsAdapter;
}
}
该适配器继承自“BaseAdapter”,所以会重写几个方法:getView(),getCount()等,由于该布局实现方式是逐行排列,故getCount()是获取每行的子控件个数,这个参数是从AdapterImpl中获取。另外实现了两个接口:
AGVBaseAdapterDao和WrapperListAdapter。其中getActualItemCount()是获取全部数据源的个数,从AppAdapter中获取。其他方法在接下来的分析中再作说明。
getView()是该适配器的入口,也就是每行子控件的布局具体实现方式。每行控件用RecyclerView.ViewHolder来存储,接下来我们来分析getView()所调用的两个方法:onCreateViewHolder()和onBindViewHolder()。
onCreateViewHolder():目的是创建一个没有子控件的LinearLayout提供onBindViewHolder()使用。
/**
* 创建ListView的Item的LinearLayout
* @return
*/
ViewHolder onCreateViewHolder() {
if (debugEnabled) {
Log.d(TAG, "onCreateViewHolder()");
}
LinearLayout layout = new LinearLayout(mContext, null);
if (debugEnabled) {
layout.setBackgroundColor(Color.parseColor("#83F27B"));
}
layout.setShowDividers(LinearLayout.SHOW_DIVIDER_MIDDLE);
layout.setDividerDrawable(
ContextCompat.getDrawable(mContext, R.drawable.felipescsl_item_divider_horizontal));
AbsListView.LayoutParams layoutParams = new AbsListView.LayoutParams(
AbsListView.LayoutParams.MATCH_PARENT, AbsListView.LayoutParams.WRAP_CONTENT);
layout.setLayoutParams(layoutParams);
return new ViewHolder(layout);
}
onBindViewHolder():实现非对称布局的重点方法
/**
* 重点函数,子空间对排列实现
* @param holder ListView Item
* @param position 当前应用对位置
* @param parent ListView 父控件
*/
void onBindViewHolder(ViewHolder holder, int position, ViewGroup parent) {
if (debugEnabled) {
Log.d(TAG, "onBindViewHolder(" + String.valueOf(position) + ")");
}
//已经排列好的一行数据
RowInfo rowInfo = itemsPerRow.get(position);
FyLog.i(TAG, "RowInfo h="+rowInfo.getRowHeight() + " getSpaceLeft="+ rowInfo.getSpaceLeft()+ " size="+ rowInfo.getItems().size());
if (rowInfo == null) {
return;
}
List<RowItem> rowItems = new ArrayList<>(rowInfo.getItems());
LinearLayout layout = initializeLayout(holder.itemView());
// Index to control the current position of the current column in this row
int columnIndex = 0;
// Index to control the current position in the array of all the items available for this row
int currentIndex = 0;
float spaceLeftInArea = rowInfo.getSpaceLeft();
int spaceLeftInColumn = listView.getNumColumns(); //总列数
float spaceLeftInRow = rowInfo.getRowHeight(); //一行的单元高数
boolean isClumFinish = false;
List<String> rowSign = new ArrayList<>();
while (!rowItems.isEmpty() && columnIndex < listView.getNumColumns()) {
RowItem currentItem = rowItems.get(currentIndex);
if (spaceLeftInColumn == 0) {
// No more space in this column. Move to next one
currentIndex = 0;
spaceLeftInRow = rowInfo.getRowHeight();
spaceLeftInColumn = listView.getNumColumns();
continue;
}
// Is there enough space in this column to accommodate currentItem?
float currentClum = currentItem.getItem().getColumnSpan();
float currentRow = currentItem.getItem().getRowSpan();
float currentArea = currentRow * currentClum;
if (currentArea <= spaceLeftInArea) {
rowItems.remove(currentItem);
//都是对ObjectPool进行操作
int actualIndex = currentItem.getIndex();
int viewType = agvAdapter.getItemViewType(actualIndex);
FyLog.d(TAG, "viewType="+ viewType+ "viewHoldersMap size is: "+ viewHoldersMap.size());
ObjectPool<AsymmetricViewHolder<?>> pool = viewHoldersMap.get(viewType);
if (pool == null) {
pool = new ObjectPool<>();
viewHoldersMap.put(viewType, pool);
}
AsymmetricViewHolder viewHolder = pool.get();
if (viewHolder == null) {
//持有AppsAdapter ListView Item
viewHolder = agvAdapter.onCreateAsymmetricViewHolder(actualIndex, parent, viewType);
}
//将AppsAdapter中对Item 绑定到ViewHolder对LinearLayout中
agvAdapter.onBindAsymmetricViewHolder(viewHolder, parent, actualIndex);
View view = viewHolder.itemView;//持有AppsAdapter中对Item
view.setTag(new ViewState(viewType, currentItem, viewHolder));
view.setOnClickListener(this);
view.setOnLongClickListener(this);
if(!isClumFinish){
//行没被全部填充的要进行备份
spaceLeftInRow -= currentRow;
FyLog.i(TAG, "spaceLeftInRow="+spaceLeftInRow);
if(spaceLeftInRow != 0)
rowSign.add(String.valueOf(columnIndex));
spaceLeftInRow = rowInfo.getRowHeight();
}
currentIndex = 0;
columnIndex++;
spaceLeftInArea -= currentArea;
boolean b = rowSign.contains(String.valueOf(columnIndex));
if(isClumFinish && !b)
columnIndex++;
FyLog.e(TAG, "rowSign"+ b+ " rowInfo.getRowHeight()="+rowInfo.getRowHeight()+
" spaceLeftInColumn="+ spaceLeftInColumn+ " currentClum="+ currentClum);
view.setLayoutParams(new LinearLayout.LayoutParams(getRowWidth(currentItem.getItem()),
getRowHeight(currentItem.getItem())));
LinearLayout childLayout = findOrInitializeChildLayout(layout, columnIndex);//垂直布局
childLayout.addView(view);
spaceLeftInColumn -= currentClum;
if(spaceLeftInColumn == 0){
spaceLeftInColumn = listView.getNumColumns();
isClumFinish = true;
columnIndex = -1;
}
} else if (currentIndex < rowItems.size() - 1) {
// Try again with next item
FyLog.i(TAG, "only add currentIndex="+currentIndex);
currentIndex++;
} else {
rowSign = null;
break;
}
}
if (debugEnabled && position % 20 == 0) {
Log.d(TAG, linearLayoutPool.getStats("LinearLayout"));
for (Map.Entry<Integer, ObjectPool<AsymmetricViewHolder<?>>> e : viewHoldersMap.entrySet()) {
Log.d(TAG, e.getValue().getStats("ConvertViewMap, viewType=" + e.getKey()));
}
}
}
该方法最开始是从itemsPerRow获取数据,那么这些数据是从哪里获取的呢?通过查找很容易发现该数据是通过异步任务类“ProcessRowsTask”获取的。那接下来我们先分析ProcessRowsTask。
@Override
protected final List<RowInfo> doInBackground(Void... params) {
// We need a map in order to associate the item position in the wrapped adapter.
List<RowItem> itemsToAdd = new ArrayList<>();
for (int i = 0; i < agvAdapter.getActualItemCount(); i++) {
try {
//从AsymmetricGridViewAdapter获取数据源RowItem
itemsToAdd.add(new RowItem(i, agvAdapter.getItem(i)));
} catch (CursorIndexOutOfBoundsException e) {
Log.w(TAG, e);
}
}
FyLog.e(TAG, "itemsToAdd list="+ itemsToAdd.size());
//再封装成RowInfo返回
return calculateItemsPerRow(itemsToAdd);
}
doInbackground()方法是将AppAdapter适配器中的数据封装成RowItem列表,然后再利用calculateItemsPerRow()方法处理成按行归类的RowInfo列表。那关键就在于calculateItemsPerRow()方法,该方法主要由两个子方法组成:calculateItemsPerRow()和calculateItemsForRow(),calculateItemsPerRow()目的是利用calculateItemsForRow()获取RowInfo列表,比较简单。先说明下calculateItemsForRow()方法。
private RowInfo calculateItemsForRow(List<RowItem> items, float initialSpaceLeft) {
final List<RowItem> itemsThatFit = new ArrayList<>();
int currentItem = 0;
float rowHeight = 1;
float areaLeft = 1 * initialSpaceLeft; //剩余单元面积数
float totalArea = 0;
//还有列没填充 还有剩余数据没有使用
while (areaLeft > 0 && currentItem < items.size()) {
final RowItem item = items.get(currentItem++);
//计算所占单元面积数
float itemArea = item.getItem().getRowSpan() * item.getItem().getColumnSpan();
//小于当前格行所占行数
FyLog.i(TAG, "rowHeight="+ rowHeight+ " item.getItem().getRowSpan()="+ item.getItem().getRowSpan());
if (rowHeight < item.getItem().getRowSpan()) {
// restart with double height 重新计算
FyLog.i(TAG, "clear");
itemsThatFit.clear();
rowHeight = item.getItem().getRowSpan(); //使用最高的行单元数进行计算
currentItem = 0;
areaLeft = initialSpaceLeft * item.getItem().getRowSpan(); //计算总单元面积数
totalArea = 0;
} else if (areaLeft >= itemArea) {
//还有地方没被占用
areaLeft -= itemArea;//剩余单元面积数
totalArea += itemArea;
FyLog.e(TAG, "itemArea="+ itemArea + ", areaLeft="+ areaLeft);
itemsThatFit.add(item);
} else if (!listView.isAllowReordering()) {
FyLog.i(TAG, "isAllowReordering");
break;
}
}
FyLog.e(TAG, "list="+ itemsThatFit.size()+ "totalarea="+ totalArea);
return new RowInfo(rowHeight, itemsThatFit, totalArea);
}
主要实现方式是计算面积,根据总单元列数和行所占单元数来计算总面积和子控件面积,然后根据面积来决定是放在第一行还是第二行。例如:假设总单元列数为3,总共有5个控件,其宽高所占比例为1*1、1*1、2*1、1*1、1*1。那么总面积应用总列数3*行最高单元数2=6,并且子控件面积为:1、1、2、1、1。那前三个控件面积和1+1+2=4 < 6,故剩下两个控件依然可以作为这一行的数据。但如果再来一个控件面积1,由于1+1+2+1+1=6,总面积已全部被占,故第六个控件只能放在第二行。
二、AsymmetricGridView使用方法
该控件的使用方式和普通的GridView一致,需要在xml中添加:
<com.felipecsl.asymmetricgridview.AsymmetricGridView
android:id="@+id/mGridView"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:divider="@android:color/transparent"
android:dividerHeight="@dimen/launcher_pageview_devider"
android:fadingEdge="none"
android:focusable="false"
android:gravity="center"
android:listSelector="@android:color/transparent"
android:scrollbars="none"
android:cacheColorHint="@android:color/transparent"
/>
在Activity中也需要为AsymmetricGridView设置适配器AppsAdapter,该适配器需要传入DemoItem的List列表。由于该方法属于不规则布局,每个方块的布局参数需要手动设定,例如:
private void initDatas() {
// TODO Auto-generated method stub
for (int i = 0 ; i < 11 ; i++) {
float rowSpan = 1;
float colSpan = 0;
if (0 == i) {
rowSpan = (float) 3.5;
colSpan = 2;
} else if (1 == i) {
rowSpan = (float) 3.5;
colSpan = 1;
} else if (2 == i) {
rowSpan = (float) 3.3;
colSpan = 1;
} else if (3 == i) {
rowSpan = (float) 3.3;
colSpan = 1;
} else if (4 == i) {
rowSpan = (float) 6.6;
colSpan = 1;
}else if (5 == i) {
rowSpan = (float) 3.3;
colSpan = 1;
}else if (6 == i) {
rowSpan = (float) 3.3;
colSpan = 1;
}else if (7 == i) {
rowSpan = (float) 5;
colSpan = 1;
}else if (8 == i) {
rowSpan = (float) 2.5;
colSpan = 2;
}else if (9 == i) {
rowSpan = (float) 2.5;
colSpan = 2;
}else if(10 == i){
rowSpan = 3;
colSpan = 3;
}
//根据列,行和次序索引创建DemoItem
DemoItem item = new DemoItem(colSpan, rowSpan, i);
item.drawable = Utils.getStateListDrawable(colors_pressed[i],
colors_normal[i], mContext);//方块北京颜色
item.title = "Label"+i;//文字说明
item.icon = getIcons("ui_Speech Recorder_icon.png");//图片
item.titleSize = 10;//文字说明字体大小
mItems.add(item);
}
mGridView.setAdapter(new AsymmetricGridViewAdapter(mContext, mGridView, mAdapter));
mAdapter.notifyDataSetChanged();
}
效果图如上
该控件目前应用在Launcher,首页采用这种方式布局,用户可以定义要显示的应用,该项目会在另外一篇博客上分享。稍后会上传本文的实例Demo源码。