您的位置:首页 > 移动开发 > Android开发

自定义布局,堆叠布局来袭!

2015-12-28 22:15 525 查看
最近爱上写博客了,一来可以巩固自己的学习成果,二来可以帮助有需要的人。好了闲话就到这里,开始进入今天的正题。

这篇博客是上篇博客点击打开链接的后续,上篇博客跟大家一起时间了仿大街app的拖动删除或者收藏的效果,如果还没看的话,可以去看看。当然没看的话也不影响看这篇博客。为了给不看上篇博客的人提供方便,我在这里先贴下今天我们要完成的效果。


还是那句话,感兴趣的看官可以看下,顺便帮我顶下,谢谢啦。不感兴趣的大腿请绕道呀。好了,开工!

主要功能分析

1.自定义布局
2.对子view的处理(旋转,透明度变换等)
3.为自定义布局提供setAdapter注入数据的支持
4.为自定义布局提供view重用的机制

界面分析

这个上篇博客已经写过了,不懂的可以自己去看下。大概就是不断动态的为FrameLayout添加和删除view,item布局没啥好说,主要就是自定义形状的imageview(圆形图片、弧边的矩形)。

功能实现

自定义布局

类似这种堆叠效果的话,我们就不用按照传统的自定义布局的步骤,先继承ViewGroup,然后重写onMeasure和onLayout什么的了,android已经有提供一个有类似这种效果的原生布局了,嗯没错,就是FrameLayout。嗯分析到这里我觉得后面就挺简单了,下面看代码。

自定义布局 StackLayout

private StackLayoutAdapter mAdapter;

private int mContentWidth = 350;//内容区域的宽度 dp
private int mContentHeight = 470;//内容区域的高度 dp

private float mRotateFactor;//控制item旋转范围
private double mItemAlphaFactor;//控制item透明度变化范围

private int mLimitTranslateX = 100;//限制移动距离,当超过这个距离的时候,删除该item

public StackLayout(Context context)
{
this(context, null);
}

public StackLayout(Context context, AttributeSet attrs)
{
this(context, attrs, 0);
}

public StackLayout(Context context, AttributeSet attrs, int defStyleAttr)
{
super(context, attrs, defStyleAttr);
TypedArray t = context.obtainStyledAttributes(attrs, R.styleable.StackLayout);
mContentWidth = t.getDimensionPixelSize(R.styleable.StackLayout_contentWidth, ScreenUtils
.dp2px(mContentWidth, getContext()));
mContentHeight = t.getDimensionPixelSize(R.styleable.StackLayout_contentHeight,
ScreenUtils.dp2px(mContentHeight, getContext()));
int screenWidth = ScreenUtils.getScreenWidth(getContext());
mRotateFactor = 60 * 1.0f / screenWidth;
//左滑,透明度最少到0.1f
mItemAlphaFactor = 0.9 * 1.0f / screenWidth / 2;
}


先定义一些变量,作用的话注释写得挺清楚了,不清楚的话看后面的使用就懂了。这里简单说一下mContentWith和mContentHeight这两个是自定义的属性,用于设置内容区域的宽度和高度的。以factor结尾的变量则是控制item的透明度或者旋转相关的梯度值。

嗯,构造方法是好了,然后什么时候给StackLayout添加数据呢,当然是在setAdapter的时候了,这里我们自定义一下我们为StackLayout提供的StackLayoutAdapter

StackLayout的适配器StackLayoutAdapter

public abstract class StackLayoutAdapter<T>
{
private Context mContext;
private List<T> mDatas;
private int mCurrentIndex;//目前数据集读到的下标

public StackLayoutAdapter(Context context, List<T> datas)
{
this.mContext = context;
this.mDatas = datas;
}

public int getCount()
{
return mDatas.size();
}

public T getItem(int pos)
{
return mDatas.get(pos);
}

public int getCurrentIndex()
{
return mCurrentIndex;
}

public void setCurrentIndex(int index)
{
this.mCurrentIndex = index;
}

public abstract View getView(int pos, View convertView, ViewGroup parent);

嗯,简直不要太简单,用过listview或者gridview的都知道,这基本是仿造的android提供的adpter来写的。继续回到自定义布局的setAdapter方法

setAdapter方法

public void setAdapter(StackLayoutAdapter adapter)
{
this.mAdapter = adapter;
//最多加载两条数据
int itemCount = adapter.getCount();
int loadCount = itemCount > 2 ? 2 : itemCount;
for (int i = 0; i < loadCount; i++)
{
addViewToFirst();
}
}
/**
* 将item添加到最后的位置
*/
public void addViewToFirst()
{
makeAndAddView(0);
}


这个方法我们要判断一下数据的长度,如果数据的长度大于2,那么我们默认只加载两条数据,至于为什么是2呢,人不2,那跟咸鱼还有什么区别?
可以看到,我们在for循环中调用addViewToFirst,这个方法最终会调用makeAndAddView,其实也就是获得一个view,然后将view添加到StackLayout的第一个位置。
来,接着看makeAndAddView方法。

makeAndAddView方法

private void makeAndAddView(int pos)
{
if (mAdapter.getCurrentIndex() == mAdapter.getCount() - 1)
{
return;//没有更多数据
}
View item = obtainView(mAdapter.getCurrentIndex());
addView(item, pos);
//增加数据集的下标
mAdapter.setCurrentIndex(mAdapter.getCurrentIndex() + 1);
}
首先先判断数据当前下标是否已经到数据的末尾了,如果到了直接返回,否则,调用obtainView方法获得一个view,获得view后将view添加到StackLayout中指定的位置去,这里根据上下文,传入的是0这个位置,也就是布局的第一个位置。添加后更新下数据集的下标。
到这里可能有人会问,妈的你每个方法里面的逻辑就那么一点,写那么多方法干嘛?这里我想说的是,虽然没几行代码,但是毕竟实现的功能不一样,以不同的功能来划分方法,我觉得代码结构还是挺清晰的,也有利于以后的扩展。个人见解。然后再来看看是如何获得一个view的。

obtainView方法

private View obtainView(int pos)
{
//加载布局
View item = LayoutInflater.from(getContext()).inflate(R.layout.stack_item, null);
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(mContentWidth, mContentHeight,
Gravity
.CENTER_HORIZONTAL);
item.setLayoutParams(lp);
item = mAdapter.getView(pos, item, this);
//初始化事件
initEvent(item);
return item;
}
跟上篇博客差不都,还是调用的inflate方法来加载一个布局,然后设置一下它的参数。嗯,这里看下mAdapter.getView方法,这是一个抽象方法,当你在为StackLayout设置Adapter的时候是需要重写这个方法的。
得到view之后就为view设置一些事件啦。

initEvent方法

private void initEvent(final View item)
{
//设置item的重心,主要是旋转的中心
item.setPivotX(item.getLayoutParams().width / 2);
item.setPivotY(item.getLayoutParams().height * 2);
item.setOnTouchListener(new View.OnTouchListener()
{
float touchX, distanceX;//手指按下时的坐标以及手指在屏幕移动的距离

@Override
public boolean onTouch(View v, MotionEvent event)
{
switch (event.getAction())
{
case MotionEvent.ACTION_DOWN:
touchX = event.getRawX();
break;
case MotionEvent.ACTION_MOVE:
distanceX = event.getRawX() - touchX;

item.setRotation(distanceX * mRotateFactor);
//alpha scale 1~0.1
//item的透明度为从1到0.1
item.setAlpha(1 - (float) Math.abs(mItemAlphaFactor * distanceX));
break;
case MotionEvent.ACTION_UP:

if (Math.abs(distanceX) > mLimitTranslateX)
{
//移除view
removeViewWithAnim(getChildCount() - 1, distanceX < 0);
addViewToFirst();
} else
{
//复位
item.setRotation(0);
item.setAlpha(1);
}
break;
}
return true;
}
});
}

代码逻辑真是炒鸡简单,就是根据用户手指滑动的距离改变view的透明度啊,旋转的角度啊什么的,然后再手指抬起的时候判断移动的距离是是否超过了限定的距离(mLimitTranslateX),如果超过了,则删除这个view。这里再看一下移除view的逻辑。

removeViewWithAnim方法

public View removeViewWithAnim(int pos, boolean isLeft)
{
final View view = getChildAt(pos);
view.animate()
.alpha(0)
.rotation(isLeft ? -90 : 90)
.setDuration(400).setListener(new AnimatorListenerAdapter()
{
@Override
public void onAnimationEnd(Animator animation)
{
removeView(view);
if (getChildCount() == 0)//如果只剩一条item的时候
{
Toast.makeText(getContext(), "已是最后一页...", Toast.LENGTH_SHORT).show();
}
}
});
return view;
}
为了不让用户显得突兀,我们这里要有一个淡出的效果,当然要根据左滑还是右滑设置淡出的方向,然后在动画结束的时候在removeView,并且如果没有更多数据的时候,给用户打印个toast。
完啦,对啊,我们的自定义布局这样就完啦,然后我们来看看MainActivity的逻辑

MainActivity

private StackLayout mContainer;

private double mItemIvAlphaFactor;//控制item上面的图片的透明度变化范围

@Override
protected void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mContainer = (StackLayout) findViewById(R.id.flContainer);
mContainer.setAdapter(new StackLayoutAdapter<User>(this, GenerateData.getDatas())
{
@Override
public View getView(int pos, View convertView, ViewGroup parent)
{
if (convertView != null)
{
User user = getItem(pos);
//					convertView = LayoutInflater.from(MainActivity.this).inflate(R.layout
//							.stack_item, null);
ImageView roundAvatar = (ImageView) convertView.findViewById(R.id.roundAvatar);
ImageView blurAvatar = (ImageView) convertView.findViewById(R.id.blurAvatar);
CatImageLoader.getInstance().loadImage(user.getAvater(), blurAvatar);
CatImageLoader.getInstance().loadImage(user.getAvater(), roundAvatar);
TextView tvUsername = (TextView) convertView.findViewById(R.id.tvUsername);
TextView tvSchool = (TextView) convertView.findViewById(R.id.tvSchool);
TextView tvMajor = (TextView) convertView.findViewById(R.id.tvMajor);
TextView tvEntranceTime = (TextView) convertView.findViewById(R.id
.tvEntranceTime);
TextView tvSkill = (TextView) convertView.findViewById(R.id.tvSkill);
final ImageView ivIgnore = (ImageView) convertView.findViewById(R.id.ivIgnore);
final ImageView ivInterested = (ImageView) convertView.findViewById(R.id
.ivInterested);
tvUsername.setText(user.getName());
tvSchool.setText(user.getSchool());
tvMajor.setText(user.getMajor() + " | " + user.getSchoolLevel());
tvEntranceTime.setText(user.getEntranceTime());
tvSkill.setText("装逼 吹牛逼");
}
return convertView;
}
});
}
这种类型的代码是不是炒鸡眼熟?跟listview很像吧?不过这个是我们自己实现的,是不是有点小激动,反正作为一个小鸟,我觉得是有点小激动。ok,来看看我们目前为止的效果。



嗯,效果还是挺满意的,不过好像跟我们原来的那些东西相比少了些什么?我们左滑忽略图片或者右滑的时候那个感兴趣的图片哪去了?嗯,那我们来为它添加下。
(-。-;)呃..,怎么添加呢?写到StackLayout的initEvent里面去?肯定不行啦,这样这个自定义布局就只适用于我们这个例子。直接拿到vi
d426
ew然后设置onTouchListener?这样肯定也不行了,那我们原来设置的旋转和透明度变化的效果就被覆盖了。我们的目的肯定是要适用于任何情况,那么设置特定效果的这种事肯定是得我们在不改变StackLayout控件的情况下来进行改造了。
嗯,我的解决方法是设置一个回调,在view触发onTouch的时候回调方法,并且把view传回来,用户可以在回调方法里设置自己的逻辑,嗯,那我们开始着手实现吧。

OnTouchEffectListener接口

public interface onTouchEffectListener
{
void onTouchEffect(View item, MotionEvent event, float distanceX);
}
public void setOnTouchEffectListener(onTouchEffectListener listener)
{
this.mOnTouchEffectListener = listener;
}


在StackLayout里面定义一个接口,里面就一个回调方法,当触发的时候,把这些参数传进去,这里我把移动的距离distanceX也传进去了,虽然这个值可以利用event计算得到,但是为了防止重复计算,我还是把这个值传进去好。然后为其提供一个设置listener的方法。接着我们就得在initEvent里面进行相应的修改了。

修改后的initEvent方法

private void initEvent(final View item)
{
//设置item的重心,主要是旋转的中心
item.setPivotX(item.getLayoutParams().width / 2);
item.setPivotY(item.getLayoutParams().height * 2);
item.setOnTouchListener(new View.OnTouchListener()
{
float touchX, distanceX;//手指按下时的坐标以及手指在屏幕移动的距离

@Override
public boolean onTouch(View v, MotionEvent event)
{
switch (event.getAction())
{
case MotionEvent.ACTION_DOWN:
touchX = event.getRawX();
break;
case MotionEvent.ACTION_MOVE:
distanceX = event.getRawX() - touchX;
if (mOnTouchEffectListener != null)
mOnTouchEffectListener.onTouchEffect(item, event, distanceX);
item.setRotation(distanceX * mRotateFactor);
//alpha scale 1~0.1
//item的透明度为从1到0.1
item.setAlpha(1 - (float) Math.abs(mItemAlphaFactor * distanceX));
break;
case MotionEvent.ACTION_UP:
if (mOnTouchEffectListener != null)
mOnTouchEffectListener.onTouchEffect(item, event, distanceX);
if (Math.abs(distanceX) > mLimitTranslateX)
{
//移除view
removeViewWithAnim(getChildCount() - 1, distanceX < 0);
addViewToFirst();
} else
{
//复位
item.setRotation(0);
item.setAlpha(1);
}
break;
}
return true;
}
});
}


基本也没怎么修改,也就是在move和up的前面判断一下listener是否为空,如果不为空,则回调方法。然后看一下我们如何设置这个回调。回到我们的MainActivity。

MainActivity

mContainer.setOnTouchEffectListener(new StackLayout.onTouchEffectListener()
{
@Override
public void onTouchEffect(View item, MotionEvent event, float distanceX)
{
ImageView ivIgnore = (ImageView) item.findViewById(R.id.ivIgnore);
ImageView ivInterested = (ImageView) item.findViewById(R.id
.ivInterested);
switch (event.getAction())
{
case MotionEvent.ACTION_MOVE:
if (distanceX < 0)//如果为左滑
{
//显示忽略图标,隐藏感兴趣图标
ivIgnore.setVisibility(View.VISIBLE);
ivInterested.setVisibility(View.GONE);
ivIgnore.setAlpha((float) (Math.abs(distanceX) * mItemIvAlphaFactor));
} else//如果为右滑
{
//显示感兴趣图标,隐藏忽略图标
ivIgnore.setVisibility(View.GONE);
ivInterested.setVisibility(View.VISIBLE);
ivInterested.setAlpha((float) (distanceX * mItemIvAlphaFactor));
}
break;
case MotionEvent.ACTION_UP:
if (Math.abs(distanceX) < mContainer.getLimitTranslateX())
{
//复位
ivIgnore.setAlpha(1.0f);
ivInterested.setAlpha(1.0f);
ivIgnore.setVisibility(View.GONE);
ivInterested.setVisibility(View.GONE);
}
break;
}
}
});

前面的代码就不贴了,就贴下增加的代码,相信不用过多的解释了吧。注释也有。然后看一下效果。



到这里我们的效果就跟我们开头的效果一样了。各位看官了,满足了吗?顶下我的博客呗。
什么?你还不满意?卧槽?好吧,其实我也不满意,接下来我们为其添加类似listview的复用功能。
我想大家都知道ListView有个复用view的机制,当view移出屏幕的时候会将这个移出屏幕的view进行复用,这里我们不过多讨论ListView的这个特性。我们接着实现我们的代码。
private LinkedList<View> mScrapViews = new LinkedList<>();

首先不用想,我们肯定得有个容器来存放我们废弃掉的view,这里我使用LinkedList。接下来哪里需要有复用view的逻辑呢?答案显而易见,肯定是在remoeView的时候将view添加到废弃list中,addView的时候从废弃list中获取view了。那么看看removeViewWithAnim的新版本

removeViewWithAnim新版本

public View removeViewWithAnim(final View view, boolean isLeft)
{
//		final View view = getChildAt(pos);
view.animate()
.alpha(0)
.rotation(isLeft ? -90 : 90)
.setDuration(400).setListener(new AnimatorListenerAdapter()
{
@Override
public void onAnimationEnd(Animator animation)
{
removeView(view);
//移除view后将view添加到我们的废弃view的list中
resetItem(view);//记得重置状态,否则复用的时候会看不到view
mScrapViews.add(view);
if (getChildCount() == 0)//如果只剩一条item的时候
{
Toast.makeText(getContext(), "已是最后一页...", Toast.LENGTH_SHORT).show();
}
}
});
return view;
}
没什么改变,就是在removeView后,重置一下状态。这一步很重要,因为view此时的透明度为0,是看不见的,如果不重置,将会导致你复用view的时候看不到。接着就把view添加到废弃list中了。我们看一眼resetItem

resetItem

private void resetItem(View item)
{
item.setRotation(0);
item.setAlpha(1);
}
不多说了。接着看我们添加view时的逻辑

obtainView

private View obtainView(int pos)
{
//先尝试从废弃缓存中取出view
View scrapView = mScrapViews.size() > 0 ? mScrapViews.removeLast() : null;
View item = mAdapter.getView(pos, scrapView, this);
if (item != scrapView)
{
//代表view布局变化了,inflate了新的布局
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(mContentWidth,
mContentHeight, Gravity.CENTER_HORIZONTAL);
item.setLayoutParams(lp);
//初始化事件
initEvent(item);
}
return item;
}

第四行,判断当前list是否有缓存view,有的话,返回最后一个,没有的话,返回null。

嗯,大概就更改这么点,然后我们要如何使用它呢?用过ListView的肯定知道ViewHolder吧,省得每次都去findViewById了。
ViewHolder我就写成StackLayoutAdater的静态内部类了,大概是这么个样子。

StackLayoutAdapter.ViewHolder

public static class ViewHolder
{
public ImageView roundAvatar;
public ImageView blurAvatar;
public TextView tvUsername;
public TextView tvSchool;
public TextView tvMajor;
public TextView tvEntranceTime;
public TextView tvSkill;
public ImageView ivIgnore;
public ImageView ivInterested;

public ViewHolder(ImageView roundAvatar, ImageView blurAvatar, TextView tvUsername,
TextView tvSchool, TextView tvMajor, TextView tvEntranceTime, TextView
tvSkill, ImageView ivIgnore, ImageView ivInterested)
{
this.roundAvatar = roundAvatar;
this.blurAvatar = blurAvatar;
this.tvUsername = tvUsername;
this.tvSchool = tvSchool;
this.tvMajor = tvMajor;
this.tvEntranceTime = tvEntranceTime;
this.tvSkill = tvSkill;
this.ivIgnore = ivIgnore;
this.ivInterested = ivInterested;

}
}


然后我们就要在MainActivity中使用改进后的布局了。代码如下

MainActivity最终效果

mContainer.setAdapter(new StackLayoutAdapter<User>(this, GenerateData.getDatas())
{
@Override
public View getView(int pos, View convertView, ViewGroup parent)
{
ViewHolder viewHolder;
User user = getItem(pos);
if (convertView == null)
{
convertView = LayoutInflater.from(MainActivity.this).inflate(R.layout
.stack_item, null);
ImageView roundAvatar = (ImageView) convertView.findViewById(R.id.roundAvatar);
ImageView blurAvatar = (ImageView) convertView.findViewById(R.id.blurAvatar);
TextView tvUsername = (TextView) convertView.findViewById(R.id.tvUsername);
TextView tvSchool = (TextView) convertView.findViewById(R.id.tvSchool);
TextView tvMajor = (TextView) convertView.findViewById(R.id.tvMajor);
TextView tvEntranceTime = (TextView) convertView.findViewById(R.id
.tvEntranceTime);
TextView tvSkill = (TextView) convertView.findViewById(R.id.tvSkill);
ImageView ivIgnore = (ImageView) convertView.findViewById(R.id.ivIgnore);
ImageView ivInterested = (ImageView) convertView.findViewById(R.id
.ivInterested);
viewHolder = new ViewHolder(roundAvatar, blurAvatar, tvUsername, tvSchool,
tvMajor, tvEntranceTime, tvSkill, ivIgnore, ivInterested);
convertView.setTag(viewHolder);
} else
{
viewHolder = (ViewHolder) convertView.getTag();
}
viewHolder.ivIgnore.setVisibility(View.GONE);
viewHolder.ivInterested.setVisibility(View.GONE);
CatImageLoader.getInstance().loadImage(user.getAvater(), viewHolder.blurAvatar);
CatImageLoader.getInstance().loadImage(user.getAvater(), viewHolder.roundAvatar);
viewHolder.tvUsername.setText(user.getName());
viewHolder.tvSchool.setText(user.getSchool());
viewHolder.tvMajor.setText(user.getMajor() + " | " + user.getSchoolLevel());
viewHolder.tvEntranceTime.setText(user.getEntranceTime());
viewHolder.tvSkill.setText("装逼 吹牛逼");
return convertView;
}
});


我就贴有改动过的代码就好。也就是setAdapter部分的逻辑。首先,判断是否有复用的view,没有的话就新inflate一个,然后创建一个ViewHolder并与view绑定。如果有复用的view,即convertview不为null,则直接取出viewholder。然后后面爱干嘛干嘛。最终把view给返回去。
这种是ListView最简单的复用view的写法了,对这部分还比较模糊的还是先学学ListView吧。好了,试试效果先。



嗯,效果上看没什么变化,不过看一下打印的log。


有人会问,咦,不是默认只加载两个布局吗,怎么inflate了3次。因为我们removeView的时候是带动画的,所以会有延迟,导致第三个view加载的时候,第一个view还没有被回收,所以会再inflate一次,之后就都是复用view了。怎样?杠杠的吧。

总结

到这里就接近尾声了,我们也比较规范的实现了一个属于我们的布局了虽然比较简单,但是我们也是跟随者谷歌的脚步着实地装了一次逼,毕竟为了与国际接轨,我们采用setAdapter的形式注入数据,也采用了仿listview的复用机制。当然,跟listview的复用机制相比,这个简直,呵呵。。
我就不继续对这个布局发表其他博客了,其实还有很多改造的地方,比如可以支持几种viewType什么等等。。
嗯,这篇博客到此结束,谢谢大家的收看,顶个回复呗。。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息