(UPDATE)点击放大悬浮View的实现之路

UI效果图

如上图效果,公司项目上个版本中需要这个效果,手机上这种效果几乎是没有的,答对了我们在开发机顶盒项目。下面就来说说这个效果的实现路程为了勾引你的欲望,先上一下我实现的效果

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中。效果是达到了,但是有缺点:

  1. 修改起来很麻烦,做不到动态添加
  2. 增加了布局的层级,每次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(这个名字是我和一位美女一同起的我叫她越越(群里都叫她废妹),感谢越越想到这么帅的名字~),主要的类有三个:

  1. ZoomHoverAdapter:为ZoomHoverView提供子view(感谢鸿洋大神的FlowLayout项目给我启发)
  2. ZoomHoverView:继承自RelativeLayout,setAdapter()后通过adapter去给子view添加布局规则,然后addView
  3. 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_contentmatch_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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容