一.效果
1.层叠显示,通过xml属性可控制Y轴偏移量,X轴偏移量,缩放比例。
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="StackLayout">
//Y轴方向的偏移量,负数向上偏移,正数向下偏移
<attr name="offsetY" format="dimension"/>
//X轴方向的偏移量,负数向左偏移,正数向右偏移
<attr name="offseetScale" format="integer"/>
//缩放比的偏移量,范围为0-100,
<attr name="offsetX" format="dimension"/>
</declare-styleable>
</resources>
2.可拖动,自动复位。拖动时有动画效果。
3.支持通过调用函数的方式飞出,且方向可以自定义。
//函数声明
/**
* 自动飞出
* @param left true表示从左边飞出,否则从右边
* @param up true表示从上边放飞出,否则从下边飞出
*/
public void takeOff(boolean left,boolean up){
if(getChildCount()!=0){
mSelectIndex=getChildCount()-1;
autoDismissOrRestore(left?-2000:2000,up?-2000:2000);
}
}
//使用
@Override
public void onClick(View v) {
switch (v.getId()){
case R.id.btn1:
gallery.takeOff(true,true);
break;
case R.id.btn2:
gallery.takeOff(true,false);
break;
case R.id.btn3:
gallery.takeOff(false,true);
break;
case R.id.btn4:
gallery.takeOff(false,false);
break;
}
}
效果
4.支持以adapter的方式使用,也支持直接布局
apdater方式
布局文件:
<com.zhuguohui.learn.StackLayout
android:id="@+id/gallery"
app:offsetY="-20dp"
app:offsetX="-20dp"
app:offseetScale="5"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerInParent="true">
</com.zhuguohui.learn.StackLayout>
代码:
自定义的Adpater继承自StackLayout.BaseAdapter。
/**
* Created by zhuguohui on 2016/4/26.
*/
public class ImageAdapter extends StackLayout.BaseAdapter{
private int[] images; // 数据源
private Context context;
public ImageAdapter(Context context,int[] images) {
super();
this.context = context;
this.images = images;
}
//设置显示的数量
@Override
public int getVisibleCount() {
return 3;
}
@Override
public int getCount() {
return images.length;
}
@Override
public View getView(View view, int position, StackLayout parent) {
ImageView imageView;
if(view==null) {
imageView = new ImageView(context);
imageView.setScaleType(ImageView.ScaleType.FIT_XY);
imageView.setLayoutParams(new Gallery.LayoutParams(500, 400));
}else {
imageView= (ImageView) view;
}
Glide.with(context).load(images[position]).into(imageView);
return imageView;
}
}
设置给StackLayout
gallery= (StackLayout) findViewById(R.id.gallery);
adapter=new ImageAdapter(this,rid);
gallery.setAdapter(adapter);
效果
直接布局方式
<com.zhuguohui.learn.StackLayout
android:id="@+id/gallery"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerInParent="true"
app:offseetScale="5"
app:offsetX="-20dp"
app:offsetY="-20dp">
<ImageView
android:layout_width="300dp"
android:layout_height="200dp"
android:src="@drawable/image1" />
<ImageView
android:layout_width="300dp"
android:layout_height="200dp"
android:src="@drawable/image2" />
<ImageView
android:layout_width="300dp"
android:layout_height="200dp"
android:src="@drawable/image3" />
<ImageView
android:layout_width="300dp"
android:layout_height="200dp"
android:src="@drawable/image4" />
</com.zhuguohui.learn.StackLayout>
效果就是最开始演示的效果,我就不放图了。
二.功能实现
关于功能实现方面的内容比较多,我就讲一些主要的东西,比如布局,view复用,观察者模式的使用。
1.布局
这个控件直接继承自ViewGroup,也就是说必须重写onLayout方法。关于View的叠放,我的思路是从后向前遍历view,通过设置TranslationY,TranslantionX,ScaleX,ScaleY来实现叠放的效果。同时记录View的起始中心点,这个主要是在后来拖动动画时需要。
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
//从中心布局
int mWidth = getWidth();
int mHight = getHeight();
int left = l + mWidth / 2;
int top = t + mHight / 2;
int childcount = getChildCount();
mViewPositionList.clear();
for (int i = 0; i < childcount; i++) {
mViewPositionList.add(new Point(0, 0));
}
//注意这里的循环遍历有两个,一个用来获取view,一个用来计算偏移量
for (int i = childcount - 1, j = 0; i >= 0; i--, j++) {
View childView = getChildAt(i);
int childTop = top - childView.getMeasuredHeight() / 2;
int childLeft = left - childView.getMeasuredWidth() / 2;
int childbuttom = childTop + childView.getMeasuredHeight();
int childright = childLeft + childView.getMeasuredWidth();
childView.layout(childLeft, childTop, childright, childbuttom);
childView.setTranslationY(j * mOffsetY);
childView.setTranslationX(j*mOffsetX);
childView.setScaleX((1 - j * mOffsetScale));
childView.setScaleY((1 - j * mOffsetScale));
//记录view的起始中心点
Point point = mViewPositionList.get(i);
point.set(childLeft + childView.getMeasuredWidth() / 2, childTop + childView.getMeasuredHeight() / 2);
}
}
2.伴随动画
在view被拖动的时候,计算新的位置与初始位置的偏移量,并除以View宽度的二分之一得到比率。此处使用offsetTopAndBottom,和offsetLeftAndRight方法修改view的位置,不会引起view的重绘。当然这个方法会在手指移动的时候调用。
private void updateView(int dx, int dy, int xMove, int yMove) {
if (mSelectIndex != -1) {
View view = getChildAt(mSelectIndex);
if (view == null) {
return;
}
Point point = mViewPositionList.get(mSelectIndex);
//偏移view实现拖动效果
view.offsetTopAndBottom(dy);
view.offsetLeftAndRight(dx);
//计算新的中心的
int centerx = view.getLeft() + view.getWidth() / 2;
int centery = view.getTop() + view.getHeight() / 2;
//计算偏移量
int x = centerx - point.x;
int y = centery - point.y;
int distance = (int) Math.sqrt(x * x + y * y);
// 计算比率
float rate = (float) (distance * 2.0 / view.getWidth());
//更新其他view
updateViews(rate);
}
}
更新其他view,发现这个函数名没取好
private void updateViews(float rate) {
if (rate > 1) {
rate = 1;
}
int count = getChildCount();
int j = 1;
//注意此处是从count-2开始循环,因为count-1为我们正在拖动的那个view
for (int i = count - 2; i >= 0; i--, j++) {
View view = getChildAt(i);
float scaleX = (float) (1 - mOffsetScale * j);
//计算新的缩放值,算法与onlayout中的一样,只是多了一点。
float newScale = (float) (scaleX + mOffsetScale * rate);
view.setScaleY(newScale);
view.setScaleX(newScale);
float translateY = (j - rate) * mOffsetY;
float translateX = (j - rate) * mOffsetX;
view.setTranslationY(translateY);
view.setTranslationX(translateX);
}
}
3.自动复位或飞出
此处的思路是计算X轴与Y轴的速度,如果速度大于一定值就飞出否则复位,在飞出的时候,根据速度的方向计算终点的X,Y坐标,并根据位移计算出时间,取最小的时间作为动画的时间,然后根据这些信息创建属性动画,在动画结束时添加复用view的处理。
private void autoDismissOrRestore(float velocityX, float velocityY) {
if (mSelectIndex != -1) {
boolean out = true;
final View outView = getChildAt(mSelectIndex);
int finalx = -1;
int finaly = -1;
int useTime = 0;
int initLeft = 0;
int initTop = 0;
if (velocityX != 0 || velocityY != 0) {
if (velocityX > 0) {
finalx = getWidth();
} else {
finalx = -getWidth();
}
if (velocityY < 0) {
finaly = -getHeight();
} else {
finaly = getHeight();
}
//计算移动距离
int distanceX = Math.abs(outView.getLeft() - finalx);
int distanceY = Math.abs(outView.getRight() - finaly);
int xTime = Integer.MAX_VALUE;
int yTime = Integer.MAX_VALUE;
if (velocityX != 0) {
xTime = (int) (distanceX * 1000 / Math.abs(velocityX));
} else {
yTime = (int) (distanceY * 1000 / Math.abs(velocityY));
}
//计算时间
useTime = (int) Math.min(xTime, yTime);
} else {
//返回
Point point = mViewPositionList.get(mSelectIndex);
initLeft = point.x - outView.getWidth() / 2;
initTop = point.y - outView.getHeight() / 2;
finalx = initLeft - outView.getLeft();
finaly = initTop - outView.getTop();
useTime = 200;
out = false;
}
if (finalx != -1 || finaly != -1) {
final boolean finalOut = out;
ValueAnimator animatorX = ObjectAnimator.ofInt(finalx).setDuration(Math.abs(useTime));
animatorX.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
int lastOffset = 0;
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//此处使用的是offsetLeftAndRight,因为之前使用过setTranslationX发现
//view的显示范围与通过view.getlerf()的范围不一致。造成了,手指触摸是获取
//被触摸到的view不正确,因此才改用offsetLeftAndRight
//还需要注意使用offsetLeftAndRight的时候设置的是偏移量,具有叠加的效果
//所以此处不能直接使用animation.getAnimatedValue()
int offset = (int) animation.getAnimatedValue() - lastOffset;
outView.offsetLeftAndRight(offset);
lastOffset = (int) animation.getAnimatedValue();
}
});
ValueAnimator animatorY = ObjectAnimator.ofInt(finaly).setDuration(Math.abs(useTime));
animatorY.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
int lastOffset = 0;
@Override
public void onAnimationUpdate(ValueAnimator animation) {
int offset = (int) animation.getAnimatedValue() - lastOffset;
outView.offsetTopAndBottom(offset);
lastOffset = (int) animation.getAnimatedValue();
}
});
animatorY.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
if (finalOut) {
//复用view
reuseView(outView);
} else {
//复位的时候,其他的view位置也要更新,手动设置比率为0就行了
updateViews(0);
}
mSelectIndex = -1;
}
});
AnimatorSet set = new AnimatorSet();
set.playTogether(animatorX, animatorY);
set.start();
}
}
}
4.复用View
复用View,通过的是在view移除界面的时候不是调用removeView,而是调用removeViewInLayout,然后根据需要填充的数据,将移除的view更新数据后调用addViewInLayout,添加到view中,注意添加的位置是最后一个
private void reuseView(View outView) {
//将移除的view插入到第一个,因为我们的layout是从最后开始显示的,所以第一个显示在最底层
//此处不需要使用removeView和addView因为这两个方法会 调用 requestLayout()和invalidate(true);
removeViewInLayout(outView);
//此处需要判断mAdapter是否为空,以防在不使用Adapter的情况下,也能正常显示
if (mAdapter!=null&&mNextPosition <= mAdapter.getCount() - 1) {
View view=mAdapter.getView(outView,mNextPosition, StackLayout.this);
addViewInLayout(view, 0, view.getLayoutParams(), true);
mNextPosition++;
}
requestLayout();
}
5.观察者模式
观察者模式是对象的行为模式,又叫发布-订阅(Publish/Subscribe)模式、模型-视图(Model/View)模式、源-监听器(Source/Listener)模式或从属者(Dependents)模式。观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象。这个主题对象在状态上发生变化时,会通知所有观察者对象,使它们能够自动更新自己。
UML图如下:
以上的内容是我从其他的地方copy过来的,简单一点,此处我们的观察者是StackLayout,被观察者是Adapter,当adapter有数据更新的时候,就吼一声,老子变了!,然后注册在Adapter中的StackLayout就开始刷新自己,你变我也变。由于这种设计模式很常用,系统以及给我们实现好了,要实现观察者只需要实现Observer接口,要实现被观察者可以有两种方式,一种是直接继承自Observable,或者内部有一个Observable的成员变量。
注册的时候调用addObserver
addObserver(observer);
移除的时候使用
deleteObserver(observer);
需要更新的时候使用setChanged和notifyObservers,
setChanged();
notifyObservers();
切记一定要先调用setChanged()因为在notifyObservers的时候会坚持是否发生改变
但是坑爹的是setChanged是一个受保护的方法
所以不能直接调用,除非你继承它,或者持有它的一个子类,系统的BaseAdapter 就是如此。
而我的Adapter没有那么多业务需求所以直接继承
public static abstract class BaseAdapter extends Observable {
/**
* 获取可见项的数量
*
* @return
*/
public abstract int getVisibleCount();
/**
* 获取数据大小
*
* @return
*/
public abstract int getCount();
/**
* 获取用于显示的view
*
* @param convertView 需要复用的view,如果第一次创建则为null
* @param position 显示的位置
* @param parent 父view
* @return
*/
public abstract View getView(View convertView, int position, StackLayout parent);
/**
* 发送更新
*/
public void notifyDataSetChange() {
setChanged();
notifyObservers();
}
public void registerObserver(Observer observer) {
addObserver(observer);
}
public void unRegisterObserver(Observer observer) {
deleteObserver(observer);
}
}
设置Adapter
public void setAdapter(BaseAdapter adapter) {
if (mAdapter != null) {
mAdapter.unRegisterObserver(this);
}
if (adapter == null) {
throw new IllegalArgumentException("adapter is null");
}
mAdapter = adapter;
//设置可见数量,可见数量不能比数据多
mVisibleSize = mAdapter.getVisibleCount() > mAdapter.getCount() ? mAdapter.getCount() : mAdapter.getVisibleCount();
adapter.registerObserver(this);
adapter.notifyDataSetChange();
}
当adapter调用notifyDataSetChange的时候,被触发每一个注册在adapter中的Obserse的update的方法。
而我们的update的方法就是重置view
@Override
public void update(Observable observable, Object data) {
resetView();
}
/**
* 重置状态
*/
private void resetView() {
//清除以后的view
removeAllViews();
//根据需要显示的数目创建view
for (int i = 0; i < mVisibleSize; i++) {
View view=mAdapter.getView(null,i,this);
if(view!=null){
//注意此处的添加顺序
addView(view,0);
mNextPosition++;
}
}
}
6.自动飞出
很简单,模拟一个速度就行了
/**
* 自动飞出
* @param left true表示从左边飞出,否则从右边
* @param up 如果为true表示从上边放飞出,否则从下边飞出
*/
public void takeOff(boolean left,boolean up){
if(getChildCount()!=0){
mSelectIndex=getChildCount()-1;
autoDismissOrRestore(left?-2000:2000,up?-2000:2000);
}
}
三.源码下载
https://github.com/zhuguohui/StackLayout
四.总结
通过这个自定义控件的编写,算是理清了很多东西,学会了使用观察者模式,复用view等技术。对我的成长还是蛮大的。以后会多试着写一下好用的控件。这是这个月最后一篇博客,希望大家不要觉得太水,如果你点赞我就更高兴了O(∩_∩)O~~