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

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

这篇博客是上篇博客点击打开链接的后续,上篇博客跟大家一起时间了仿大街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里面去?肯定不行啦,这样这个自定义布局就只适用于我们这个例子。直接拿到view然后设置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什么等等。。
嗯,这篇博客到此结束,谢谢大家的收看,顶个回复呗。。


  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值