自定义ScaleLayout (模仿小米相册查看图片效果)

之前写的两篇关于自定义View:
http://www.jianshu.com/p/32d7d1ab985c 模仿饿了么加载效果(五八同城,UC也都有这个效果)

http://www.jianshu.com/p/e180aa9f293b 模仿小米的进度控件

先来看看效果图,这个gif弄得蛋疼,加快了播放速度,降低了清晰度:


scale_gif1.gif

GIF_20161019_230600.gif

github地址:https://github.com/niniloveyou/ScaleLayout

下面会从以下几个方面分析如何实现这个效果:

1.初始化完成后做了什么

2.onMeasure onLayout

3.触摸事件的处理

4.对外提供方法和接口

.
.
.
.
首先讲讲大概的思路:
就是我们要有三个View 分别为TopView CenterView bottomView 这很好理解,故名思义就是把这三个子View分别放在ViewGroup的上中下。
OnMeasure()中把CenterView的大小设置为等同于自身的大小
onLayout() 获取topview bottomView的高度,根据高度设置当centerView缩小时topView/BottomView位移距离
onInterceptTouchEvent() 只处理滑动冲突部分。
onTouchEvent()中才是真正滑动缩小或放大实现的部分。

1.初始化完成后做了什么

我们先贴代码,后面紧跟着解释:

<pre>
@Override
protected void onFinishInflate() {
super.onFinishInflate();

    int childCount = getChildCount();
    if(childCount < 1){
        throw new IllegalStateException("ScaleLayout should have one direct child at least !");
    }

    mTopView = findViewById(R.id.scaleLayout_top);
    mBottomView = findViewById(R.id.scaleLayout_bottom);
    mCenterView = findViewById(R.id.scaleLayout_center);

    // if centerView does not exist
    // it make no sense
    if(mCenterView == null){
        throw new IllegalStateException("ScaleLayout should have one direct child at least !");
    }

    LayoutParams lp = (FrameLayout.LayoutParams)mCenterView.getLayoutParams();
    lp.gravity &= Gravity.CENTER;
    mCenterView.setLayoutParams(lp);

    //hide topView and bottomView
    //set the topView on the top of ScaleLayout
    if(mTopView != null){
        lp = (FrameLayout.LayoutParams)mTopView.getLayoutParams();
        lp.gravity &= Gravity.TOP;
        mTopView.setLayoutParams(lp);
        mTopView.setAlpha(0);
    }

    //set the bottomView on the bottom of ScaleLayout
    if(mBottomView != null){
        lp = (FrameLayout.LayoutParams)mBottomView.getLayoutParams();
        lp.gravity &= Gravity.BOTTOM;
        mBottomView.setLayoutParams(lp);
        mBottomView.setAlpha(0);
    }

    setState(mState, false);
}

</pre>

大家都知道onFinishInflate方法是View在XML中解析完成的回调,因此可以在里面做一些检查以及初始化的工作。 从代码不难看出,我首先就是检查了ScaleLayout的子View数量, 少于一个就直接抛出异常了,因为如果没有一个子View, 咱们自定义的这个ScaleLayout就没什么意义了, 其次 是我指定了 上中下三个子View的id, 这么做是因为ScaleLayout是个ViewGroup,可能不止三个,但是多了我们又没法判断,哪一个是topView, 哪个是centerView, 有可能会乱掉。

后面又对CenterView做了判空, 以及对三个View的位置做了些设置。

2.onMeasure onLayout

<pre>
/**
* 使得centerView 大小等同ScaleLayout的大小
* 如果不想这样处理,也可以在触摸事件中使用TouchDelegate
* @param widthMeasureSpec
* @param heightMeasureSpec
*/
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);

    final int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    final int heightSize = MeasureSpec.getSize(heightMeasureSpec);

    int layoutHeight = heightSize - getPaddingTop() - getPaddingBottom();
    int layoutWidth = widthSize - getPaddingLeft() - getPaddingRight();

    int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(layoutWidth, MeasureSpec.EXACTLY);
    int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(layoutHeight, MeasureSpec.EXACTLY);

    mCenterView.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}


@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);

    if(mBottomView != null){
        mBottomViewMoveDistance = mBottomView.getMeasuredHeight();
    }

    if(mTopView != null){
        mTopViewMoveDistance = mTopView.getMeasuredHeight();
    }

    if(mSuggestScaleEnable){
        setMinScale(getSuggestScale());
    }
}

</pre>
很简单,只说一点:mBottomViewMoveDistance, mTopViewMoveDistance 分别为bottomView, topView动画时位移的距离。

3.触摸事件的处理

重点来了这个也是核心部分了。

onInterceptTouchEvent

<pre>

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {

    boolean intercept = false;

    switch (ev.getAction()) {

        case MotionEvent.ACTION_DOWN:

            onTouchEvent(ev);
            mInitialMotionX = ev.getX();
            mInitialMotionY = ev.getY();
            break;

        case MotionEvent.ACTION_MOVE:
            final float deltaX = Math.abs(ev.getX() - mInitialMotionX);
            final float deltaY = Math.abs(ev.getY() - mInitialMotionY);

            if(mCanScaleListener != null
                    && !mCanScaleListener.onGetCanScale(ev.getX() - mInitialMotionX > 0)){
                intercept = false;
            }else {
                intercept = deltaY > deltaX && deltaY > mTouchSlop;
            }
            break;
    }
    return intercept;
}

</pre>
所有的down事件都不拦截,因此接下来的move, up事件,
都会先执行onInterceptTouchEvent的(move, up)
继而分发给子view的dispatchTouchEvent(move, up),
因此在onInterceptTouchEvent(move)事件中我们可以判断是否满足滑动条件,满足就拦截,拦截了之后move up事件就会都分发给自身的OnTouchEvent, 否则如上继续分发给子View.

intercept = deltaY > deltaX && deltaY > mTouchSlop;

即Y位移的距离大于X方向 ,并且Y方向位移的距离大于TouchSlop,则认为这是有效滑动。

 /**
     * 返回是否可以scale,主要为了适配部分有滑动冲突的view
     * 如TouchImageView, 甚至webView等
     * isScrollSown = true  代表向下,
     * isScrollSown = false 代表向上
     */
    public interface OnGetCanScaleListener{

        boolean onGetCanScale(boolean isScrollSown);
    }

if(mCanScaleListener != null 
          && !mCanScaleListener.onGetCanScale(ev.getX() - mInitialMotionX > 0)){ 
  intercept = false;
}

这下明白了吧,我是做了个接口,要不要拦截由你说了算,也算我偷懒了。

OnTouchEvent

 /**
     * 该方法中实现了
     * 上滑缩小下滑放大功能
     * 也可设置为 上滑放大下滑缩小
     * @param ev
     * @return
     */
    @Override
    public boolean onTouchEvent(MotionEvent ev) {

        if (!isEnabled() || !mSlideScaleEnable) {
            return super.onTouchEvent(ev);
        }



        switch (ev.getActionMasked()) {

            case MotionEvent.ACTION_DOWN:
                downY = ev.getY();
                return true;

            case MotionEvent.ACTION_MOVE:
                if(mCanScaleListener != null && !mCanScaleListener.onGetCanScale(ev.getY() - downY > 0)){
                    return super.onTouchEvent(ev);
                }
                if (Math.abs(ev.getY() - downY) > mTouchSlop) {

                    mSlopLength += (ev.getY() - downY);

                    float scale;
                    if (mSlideUpOrDownEnable) {

                        scale = 1 + (0.8f * mSlopLength / getMeasuredHeight());
                    } else {
                        scale = 1 - (0.8f * mSlopLength / getMeasuredHeight());
                    }

                    scale = Math.min(scale, 1f);

                    mCurrentScale = Math.max(mMinScale, scale);

                    doSetScale();

                    downY = ev.getY();
                }

                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                if (mCurrentScale > mMinScale && mCurrentScale < 1f) {

                    float half = (1 - mMinScale) / 2;

                    if (mCurrentScale >= mMinScale + half) {

                        setState(STATE_CLOSE, true);
                    } else {

                        setState(STATE_OPEN, true);
                    }
                }
                break;
        }

        return super.onTouchEvent(ev);
    }

这部分,首先是move的时候用mSlopLength计算滑动的距离向下滑就加正值,向上划值就减小,不断根据这个值计算当前的Scale. 应该缩放的比例,然后根据这个值计算topView bottomView 的透明度,位移距离,等等, 当UP的时候,根据当前的Scale决定是应该放大到原宽高还是缩小,以动画的形式。

···
/**
* 1.触发监听事件
* 2.计算scale的pivotX, pivotY(因为topView 和bottomView 的高度可能不一样,所以不能固定设置在中心点)
* 3.设置 mCenterView的scale
* 4.设置topView and BottomView 的动画(渐变和位移)
*/
private void doSetScale() {

    int scaleListenerCount = mScaleListenerList.size();

    OnScaleChangedListener mScaleChangedListener;
    for (int i = 0; i < scaleListenerCount; i++) {
        mScaleChangedListener = mScaleListenerList.get(i);
        if(mScaleChangedListener != null){
            mScaleChangedListener.onScaleChanged(mCurrentScale);
        }
    }

    if(mCurrentScale == mMinScale || mCurrentScale == 1f){
        int stateListenerCount = mStateListenerList.size();

        OnStateChangedListener mStateChangedListener;
        for (int i = 0; i < stateListenerCount; i++) {
            mStateChangedListener = mStateListenerList.get(i);
            if(mStateChangedListener != null){
                mStateChangedListener.onStateChanged(mCurrentScale == mMinScale);
            }
        }
    }

    doSetCenterView(mCurrentScale);
    doSetTopAndBottomView(mCurrentScale);
}

···

我把监听事件也贴上:

    /**
     * 当centerView 的scale变化的时候,通过这个
     * 接口外部的View可以做一些同步的事情,
     * 比如,你有一个其他的view要根据centerView的变化而变化
     */
    public interface OnScaleChangedListener{

        void onScaleChanged(float currentScale);
    }

    /**
     * state == false 当完全关闭(scale == 1f)
     * state == true  或当完全开启的时候(scale = mMinScale)
     */
    public interface OnStateChangedListener{

        void onStateChanged(boolean state);
    }

4.对外提供方法和接口

关于接口,代码我都无耻的贴上去了。

下面说说提供的几个简单的外部方法:

    /**
     * 设置最小scale
     * {@link #DEFAULT_MIN_SCALE}
     * @param minScale
     */
    public void setMinScale(float minScale){

        if(minScale > 0f && minScale < 1f){
            if(mMinScale != minScale){
                if(isOpen()){
                    if(animator != null){
                        animator.cancel();
                        animator = null;
                    }
                    animator = getAnimator(mMinScale, minScale);
                    animator.start();
                }
                mMinScale = minScale;
            }
        }
    }


    public float getMinScale(){
        return mMinScale;
    }

    public float getCurrentScale(){
        return mCurrentScale;
    }


    public void setSuggestScaleEnable(boolean enable){
        if(mSuggestScaleEnable != enable){
            mSuggestScaleEnable = enable;
            requestLayout();
        }
    }

    /**
     * 设置的scale不得当的话,有可能topView / bottomView被覆盖
     * 通过设置{@link #setSuggestScaleEnable(boolean)}启用
     * @return
     */
    private float getSuggestScale(){

        int height = 0;

        if(mTopView != null){
            height += mTopView.getMeasuredHeight();
        }

        if(mBottomView != null){
            height += mBottomView.getMeasuredHeight();
        }
        return 1 - height * 1f / (getMeasuredHeight() - getPaddingTop() - getPaddingBottom());
    }


    /**
     * 设置是否启用滑动缩小功能
     * @param enable
     */
    public void setSlideScaleEnable(boolean enable){
        this.mSlideScaleEnable = enable;
    }

    /**
     *   现在有这么几种情况, 默认第二种, 两者都可以的话,感觉好奇怪,
     *   比如一直下滑会由大变小后又变大,操作感觉不是很好
     *   1. 只上滑放大下滑缩小  false
     *   2. 只上滑缩小下滑放大  true
     */
    public void setSlideUpOrDownEnable(boolean enable){
        this.mSlideUpOrDownEnable = enable;
    }

    /**
     * add OnScaleChangedListener
     * @param listener
     */
    public void addOnScaleChangedListener(OnScaleChangedListener listener){
        if(listener != null){
            mScaleListenerList.add(listener);
        }
    }

    /**
     * add OnStateChangedListener
     * @param listener
     */
    public void addOnStateChangedListener(OnStateChangedListener listener){
        if(listener != null){
            mStateListenerList.add(listener);
        }
    }

    public void setOnGetCanScaleListener(OnGetCanScaleListener listener){
        mCanScaleListener = listener;
    }

    /**
     *  {@link #setState(int state, boolean animationEnable)}
     * @param state
     */
    public void setState(int state){
        setState(state, true);
    }

    /**
     * 设置状态变化
     * @param state open or close
     * @param animationEnable change state with or without animation
     */
    public void setState(final int state, boolean animationEnable) {

        if(!animationEnable)
        {
            if(state == STATE_CLOSE){
                mSlopLength = 0;
                mCurrentScale = 1;
            }else{
                if(mSlideUpOrDownEnable) {
                    mSlopLength = -getMeasuredHeight() * (1 - mMinScale) * 1.25f;
                }else{
                    mSlopLength = getMeasuredHeight() * (1 - mMinScale) * 1.25f;
                }
                mCurrentScale = mMinScale;
            }
            doSetScale();
            mState = state;

        }else{
            if(animator != null){
                animator.cancel();
                animator = null;
            }

            if(state == STATE_CLOSE && mCurrentScale != 1){

                mSlopLength = 0;
                animator = getAnimator(mCurrentScale, 1f);

            }else if(state == STATE_OPEN && mCurrentScale != mMinScale){

                if(mSlideUpOrDownEnable) {
                    mSlopLength = -getMeasuredHeight() * (1 - mMinScale) * 1.25f;
                }else{
                    mSlopLength = getMeasuredHeight() * (1 - mMinScale) * 1.25f;
                }
                animator = getAnimator(mCurrentScale, mMinScale);
            }

            if(animator != null) {
                animator.addListener(new AnimatorListenerAdapter() {

                    @Override
                    public void onAnimationEnd(Animator animation) {
                        mState = state;
                    }

                });
                animator.start();
            }
        }
    }

    /**
     * 获取当前状态开启或者关闭
     * @return
     */
    public boolean isOpen(){

        return mState == STATE_OPEN;
    }

代码贴完了。

如果感觉还行,到我的github star一下吧。 谢谢!

https://github.com/niniloveyou/ScaleLayout

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,464评论 25 707
  • 介绍自己负责的部分,如何实现的。 自定义view viewGroup activity的启动流程 事件传递及滑动冲...
    东经315度阅读 1,192评论 1 4
  • (36) 有一段时间,我会经常跑到往生的空间里面。只要看到他的最近访客里有新人,我都会把他(她)加为好友。 久而久...
    莫爱河往生阅读 716评论 3 4
  • 2017年10月25日,如是家人蔡小敏,种种子第63天。 发心:我今天不仅是为了我个人而闻思修,更是为了六道回一切...
    Rubywry阅读 105评论 0 0