(UPDATE)点击放大悬浮View的实现之路
zoomhover.gif如上图效果,公司项目上个版本中需要这个效果,手机上这种效果几乎是没有的,答对了我们在开发机顶盒项目。下面就来说说这个效果的实现路程为了勾引你的欲望,先上一下我实现的效果
Changed
- 最终思路段落:找到了GridLayout调用bringToFront()不错乱的方法
- 具体实现段落:ZoomHoverAdapter的代码更新
- 新增继承GridLayout的实现,可以设置跨行,跨列
实现思路历程
第一印象:
开始看这个效果的时候,上来的第一印象就是用GridLayout,网格布局迅速搭好了然后测试下,点击放大调用view的setScaleX()和setScaleY()方法,再在点击的时候添加一个阴影背景哇!!!这不搞定了嘛,运行一下看看效果。
first time啊叻叻~发现放大以后,和其他子view重叠的区域被遮挡了...WHY?因为所有子view在同一层,所以就会遮挡!
项目版本中的实现
由于项目的版本比较紧,所以没多长时间去研究,所以当时就采取了FrameLayout+GridLayout+带阴影的layout 这个方案去实现的。FrameLayout在最外层,GridLayout被包含在其中,还有一个带有阴影的layout在GridLayout的上层没点击的时候GONE掉;在点击的时候获取点击的view然后copy一份到带阴影layout中。效果是达到了,但是有缺点:
- 修改起来很麻烦,做不到动态添加
- 增加了布局的层级,每次copy View效率也不高
当时没办法,只能先这样实现后期优化。
空白期思路探索
最近项目终于不忙了,也有时间研究一些新东西,研究优化的事情。准备优化的时候,首先想到了Android TV这种效果是自带的,然后把Leanback的工程扒下来看了又看,最终锁定了Presenter和ImageCardView上,但是小弟不才,完全没看懂内部实现所以放弃了(**Q:**是不是可以用AndroidTV的api?**A:**抱歉,我们用的是厂家定制的api大多和手机api一样,版本为4.4),然后通过google发现,一组神奇的类**ViewOverlay,ViewGroupOverlay(它是view的最上面的一个透明的层,我们可以在这个层之上添加内容而不会影响到整个布局结构。这个层和我们的界面大小相同,可以理解成一个浮动在界面表面的二维空间。)**,嗯....ViewGroupOverlay很符合我的需求但是这个类有个奇怪的设定:向ViewGroupOverlay中添加view以后会把原来的view移除。,,,,,,,这不开玩笑嘛!!!这个也不行~
最终思路
我最终的实现思路是通过View中的bringToFront()方法。来看看官方解释
/**
* Change the view's z order in the tree, so it's on top of other sibling
* views. This ordering change may affect layout, if the parent container
* uses an order-dependent layout scheme (e.g., LinearLayout). Prior
* to {@link android.os.Build.VERSION_CODES#KITKAT} this
* method should be followed by calls to {@link #requestLayout()} and
* {@link View#invalidate()} on the view's parent to force the parent to redraw
* with the new child ordering.
*
* @see ViewGroup#bringChildToFront(View)
*/
改变视图z轴的次序,使它在兄弟姐妹视图的顶部。
其实一开始群里大神和我提过这个方法,但是在GridLayout中使用会导致子view的位置错乱,调用bringToFront()方法的view会移动到最后所以开始放弃了(这里更新:在GridLayout中如果给每个子view确定坐标则调用biringToFront后位置不会错乱,感谢这篇回答)。google真是很强大,将问题复制进去搜到了一个结果,StackOverflow这个回答,里面答案是用RelativeLayout代替LinearLayout,看到这个回答后,我简单试了下FrameLayout下调用发现也不会有错乱。所以在这两个Layout中选择一个!!!我毅然决然的选择了RelativeLayout(因为还要实现网格布局那不是)。
so~我最终通过继承RelativeLayout,子view调用bringToFront()方法来实现!!!以上是我这一周的大脑的工作过程。
更新:由于找到了GridLayout位置不错乱的实现方式而之前有了RelativeLayout的实现,所以选用了两种方式来实现了该效果
具体实现
接下来看具体代码的实现,我给它起名叫做ZoomHoverView(这个名字是我和一位美女一同起的我叫她越越(群里都叫她废妹),感谢越越想到这么帅的名字~),主要的类有三个:
- ZoomHoverAdapter:为ZoomHoverView提供子view(感谢鸿洋大神的FlowLayout项目给我启发)
- ZoomHoverView:继承自RelativeLayout,setAdapter()后通过adapter去给子view添加布局规则,然后addView
- ZoomHoverGridView:继承自GridLayout,新增实现方式,具体实现代码见github
先来ZoomHoverAdapter实现:
public abstract class ZoomHoverAdapter<T> {
private List<T> mDataList;
//数据变化回调
private OnDataChangedListener mOnDataChangedListener;
/**
* 数据变化回调
*/
interface OnDataChangedListener {
void onChanged();
}
/**
* 获取数据的总数
*
* @return
*/
public int getCount() {
return mDataList == null ? 0 : mDataList.size();
}
/**
* 获取对应Item的bean
*
* @param position
* @return
*/
public T getItem(int position) {
if (mDataList == null) {
return null;
} else {
return mDataList.get(position);
}
}
public abstract View getView(ViewGroup parent, int position, T t);
以上为Adapter的所有代码,和我们常用的Adapter并无差别,另外,将原来的设置跨度这部分代码移入了View中。
接下来看ZoomHoverView实现
public class ZoomHoverView extends RelativeLayout implements View.OnClickListener, ZoomHoverAdapter.OnDataChangedListener {
//adapter
private ZoomHoverAdapter mZoomHoverAdapter;
// 需要的列数
private int mColumnNum = 3;
//记录当前列
private int mCurrentColumn = 0;
//记录当前行
private int mCurrentRow = 1;
//记录每行第一列的下标(row First column position)
private SimpleArrayMap<Integer, Integer> mRFColPosMap= new SimpleArrayMap<>();
//子view距离父控件的外边距宽度
private int mMarginParent = 20;
//行列的分割线宽度
private int mDivider = 10;
//当前放大动画
private AnimatorSet mCurrentZoomInAnim = null;
//当前缩小动画
private AnimatorSet mCurrentZoomOutAnim = null;
//缩放动画监听器
private OnZoomAnimatorListener mOnZoomAnimatorListener = null;
//动画持续时间
private int mAnimDuration;
//动画缩放倍数
private float mAnimZoomTo;
//缩放动画插值器
private Interpolator mZoomInInterpolator;
private Interpolator mZoomOutInterpolator;
//上一个ZoomOut的view(为了解决快速切换时,上一个被缩小的view缩放大小不正常的情况)
private View mPreZoomOutView;
//当前被选中的view
private View mCurrentView = null;
//item选中监听器
private OnItemSelectedListener mOnItemSelectedListener;
//存储当前layout中所有子view
private List<View> mViewList;
所有成员变量的定义,都加了注释,这里说一下一个大家有可能不明白的变量mRFColPosMap:RFColPos是Row First Column Position缩写即每一行的第一列的下标,由于我们是网格式布局再加上我们有的item需要拉伸,所以每一行的第一个位置是无法确定的,所以用一个map存储(K-所在行数,V-view的下标)。还有一个mMarginParent属性:因为我们点击放大而子view放大以后无法超越父控件,所以会造成边上view放大被遮挡,需要设置mMarginParent,来控制与父边框的距离。
/**
* 设置适配器
*
* @param adapter
*/
public void setAdapter(ZoomHoverAdapter adapter) {
this.mZoomHoverAdapter = adapter;
mZoomHoverAdapter.setDataChangedListener(this);
changeAdapter();
}
@Override
public void onChanged() {
changeAdapter();
}
这两个方法没什么说的,接下来看看最关键的方法changeAdapter()
,通过这个方法给每个子view设置布局规则,添加view:
/**
* 根据adapter添加view
*/
private void changeAdapter() {
removeAllViews();
//重置参数(因为changeAdapter可能调用多次)
mColumnNum = 3;
mCurrentRow = 1;
mCurrentColumn = 0;
mRFColPosMap.clear();
mViewList = new ArrayList<>(mZoomHoverAdapter.getCount());
//需要拉伸的下标的参数K-下标,V-跨度
SimpleArrayMap<Integer, Integer> needSpanMap = mZoomHoverAdapter.getSpanList();
for (int i = 0; i < mZoomHoverAdapter.getCount(); i++) {
//获取子view
View childView = mZoomHoverAdapter.getView(this, i, mZoomHoverAdapter.getItem(i));
mViewList.add(childView);
childView.setId(i + 1);
//判断当前view是否设置了跨度
int span = 1;
if (needSpanMap.containsKey(i)) {
span = needSpanMap.get(i);
}
//获取AdapterView的的布局参数
RelativeLayout.LayoutParams childViewParams = (LayoutParams) childView.getLayoutParams();
if (childViewParams == null) {
childViewParams = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
}
//如果view的宽高设置了wrap_content或者match_parent则span无效
if (childViewParams.width <= 0) {
span = 1;
}
//如果跨度有变,重新设置view的宽
if (span > 1 && span <= mColumnNum) {
childViewParams.width = childViewParams.width * span + (span - 1) * mDivider;
} else if (span < 1) {
span = 1;
} else if (span > mColumnNum) {
span = mColumnNum;
childViewParams.width = childViewParams.width * span + (span - 1) * mDivider;
}
//设置右下左上的边距
int rightMargin = 0;
int bottomMargin = 0;
int leftMargin = 0;
int topMargin = 0;
//如果跨度+当前的列>设置的列数,换行
if (span + mCurrentColumn > mColumnNum) {
//换行当前行数+1
mCurrentRow++;
//当前列等于当前view的跨度
mCurrentColumn = span;
//换行以后肯定是第一个
mRFColPosMap.put(mCurrentRow, i);
//换行操作
//因为换行,肯定不是第一行
//换行操作后将当前view添加到上一行第一个位置的下面
childViewParams.addRule(RelativeLayout.BELOW, mViewList.get(mRFColPosMap.get(mCurrentRow - 1)).getId());
//不是第一行,所以上边距为分割线的宽度
topMargin = mDivider;
//换行后位置在左边第一个,所以左边距为距离父控件的边距
leftMargin = mMarginParent;
} else {
if (mCurrentColumn <= 0 && mCurrentRow <= 1) {
//第一行第一列的位置保存第一列信息,同时第一列不需要任何相对规则
mRFColPosMap.put(mCurrentRow, i);
//第一行第一列上边距和左边距都是距离父控件的边距
topMargin = mMarginParent;
leftMargin = mMarginParent;
} else {
//不是每一行的第一个,就添加到前一个的view的右面,并且和前一个顶部对齐
childViewParams.addRule(RelativeLayout.RIGHT_OF, mViewList.get(i - 1).getId());
childViewParams.addRule(ALIGN_TOP, mViewList.get(i - 1).getId());
}
//移动到当前列 mCurrentColumn += span;
}
if (mCurrentColumn >= mColumnNum || i >= mZoomHoverAdapter.getCount() - 1) {
//如果当前列为列总数或者当前view的下标等于最后一个view的下标那么就是最右边的view,设置父边距
rightMargin = mMarginParent;
} else {
rightMargin = mDivider;
}
//如果当前view是最后一个那么他肯定是最后一行
if (i >= (mZoomHoverAdapter.getCount() - 1)) {
bottomMargin = mMarginParent;
}
//设置外边距
childViewParams.setMargins(leftMargin, topMargin, rightMargin, bottomMargin);
//添加view
addView(childView, childViewParams);
//添加点击事件
childView.setOnClickListener(this);
}
}
里面的每一行都有注释,就不再这里啰嗦了。
注意:这里如果要用span属性,就必须设置item的宽高,不能设置成wrap_content
和match_parent
,因为这两个获取LayoutParams后width和Height值是负数。
接下来是点击的逻辑处理:
@Override
public void onClick(View view) {
if (mCurrentView == null) {
//如果currentView为null,证明第一次点击
//执行放大动画
zoomInAnim(view);
mCurrentView = view;
if (mOnItemSelectedListener != null) {
mOnItemSelectedListener.onItemSelected(mCurrentView, mCurrentView.getId() - 1);
}
} else {
if (view.getId() != mCurrentView.getId()) {
//点击的view不是currentView
//currentView执行缩小动画
zoomOutAnim(mCurrentView);
//当前点击的view赋值给currentView
mCurrentView = view;
//执行放大动画
zoomInAnim(mCurrentView);
if (mOnItemSelectedListener != null) {
mOnItemSelectedListener.onItemSelected(mCurrentView, mCurrentView.getId() - 1);
}
}
}
}
currentView为当前选中的view,逻辑很简单,不再做解释了。
下面是放大动画的代码:
/**
* 放大动画
*
* @param view
*/
private void zoomInAnim(final View view) {
//将view放在其他view之上
view.bringToFront();
//按照bringToFront文档来的,暂没测试
if (Build.VERSION.SDK_INT < KITKAT) {
requestLayout();
}
if (mCurrentZoomInAnim != null) {
//如果当前有放大动画执行,cancel掉
mCurrentZoomInAnim.cancel();
}
ObjectAnimator objectAnimatorX = ObjectAnimator.ofFloat(view, "scaleX", 1.0f, mAnimZoomTo);
ObjectAnimator objectAnimatorY = ObjectAnimator.ofFloat(view, "scaleY", 1.0f, mAnimZoomTo);
objectAnimatorX.setDuration(mAnimDuration);
objectAnimatorX.setInterpolator(mZoomInInterpolator);
objectAnimatorY.setDuration(mAnimDuration);
objectAnimatorY.setInterpolator(mZoomInInterpolator);
AnimatorSet set = new AnimatorSet();
set.playTogether(objectAnimatorX, objectAnimatorY);
set.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animator) {
//放大动画开始
if (mOnZoomAnimatorListener != null) {
mOnZoomAnimatorListener.onZoomInStart(view);
}
}
@Override
public void onAnimationEnd(Animator animator) {
//放大动画结束
if (mOnZoomAnimatorListener != null) {
mOnZoomAnimatorListener.onZoomInEnd(view);
}
mCurrentZoomInAnim = null;
}
@Override
public void onAnimationCancel(Animator animator) {
//放大动画退出
mCurrentZoomInAnim = null;
}
@Override
public void onAnimationRepeat(Animator animator) {
}
});
set.start();
mCurrentZoomInAnim = set;
}
放大动画内有个关键方法就是view.bringToFront()
,它使view在其他view之上。缩小的动画代码就不贴了,写法和放大类似但是没有bringToFront()方法,另外还有很多自定义效果的方法等也都不贴了。
用法
注:为了更灵活本view并没有加入阴影特效,而是提供了动画的监听来动态操作,这样可操作性强,更加灵活。
完整代码和用法请看ZoomHoverView