打造一款 ZoomLayout

1. ZoomLayout 需要实现的功能

1.1 需求列表

  1. 触摸滑动及惯性滑动
  2. 多指缩放
  3. 双击缩放

除了实现这些主要功能外,还需要处理一下的细节

  1. ZoomLayout的宽高大于子 View 时,子 View 居中显示
  2. ZoomLayout 需要响应事件,但是不能把事件拦截掉
  3. 处理滑动冲突,比如将 ZoomLayout 放在ViewPager

1.2 使用举例

<?xml version="1.0" encoding="utf-8"?>
<com.xuliwen.zoom.ZoomLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:max_zoom="3.0"
    app:min_zoom="1.0"
    app:double_click_zoom="2.0">
    
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">
        
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:scaleType="fitXY"
            android:adjustViewBounds="true"
            android:src="@mipmap/image1"/>

        <ImageView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:scaleType="fitXY"
            android:adjustViewBounds="true"
            android:src="@mipmap/image2"/>
        
    </LinearLayout>

1.3 效果和源码

效果如下:

zoomlayout.gif

ZoomLayout 源码 里面有完整的代码、Demo、使用说明

2. 实现

2.1 基础知识

在讲具体实现之前,先提一下会用到的一些基础的知识,不了解的同学可以先去了解一下

  1. GestureDetector 用于获取单击、双击、滚动、抛掷 等动作
  2. ScaleGestureDetector 用于获取缩放的动作
  3. OverScroller 滚动的辅助类

2.2 重写 measureChildWithMargins

@Override
    protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed,
                                           int parentHeightMeasureSpec, int heightUsed) {
        final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin
                        + widthUsed, lp.width);
        final int usedTotal = getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin +
                heightUsed;
        final int childHeightMeasureSpec;
        if (lp.height == WRAP_CONTENT) {
            childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
                    Math.max(0, MeasureSpec.getSize(parentHeightMeasureSpec) - usedTotal),
                    MeasureSpec.UNSPECIFIED);
        } else {
            childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
                    getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin
                            + heightUsed, lp.height);
        }
        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }

ZoomLayout 继承了 LinearLayout 后,发现屏幕外的 View 是没有绘制出来的,但是我们平时使用 ScrollView 的时候,屏幕外的 View 也能绘制出来,查看 ScrollView 的源码,发现它重写了 measureChildWithMargins 方法。

2.3 实现滚动

private GestureDetector.SimpleOnGestureListener mSimpleOnGestureListener = new GestureDetector.SimpleOnGestureListener() {
        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            if (!isEnabled()) {
                return false;
            }
            processScroll((int) distanceX, (int) distanceY, getScrollRangeX(), getScrollRangeY());
            return true;
        }
    };
    
private void processScroll(int deltaX, int deltaY,
                               int scrollRangeX, int scrollRangeY) {
        int oldScrollX = getScrollX();
        int oldScrollY = getScrollY();
        int newScrollX = oldScrollX + deltaX;
        int newScrollY = oldScrollY + deltaY;
        final int left = 0;
        final int right = scrollRangeX;
        final int top = 0;
        final int bottom = scrollRangeY;

        if (newScrollX > right) {
            newScrollX = right;
        } else if (newScrollX < left) {
            newScrollX = left;
        }

        if (newScrollY > bottom) {
            newScrollY = bottom;
        } else if (newScrollY < top) {
            newScrollY = top;
        }
        if (newScrollX < 0) {
            newScrollX = 0;
        }
        if (newScrollY < 0) {
            newScrollY = 0;
        }
        scrollTo(newScrollX, newScrollY);
    }
    

滚动可以在 onScroll 回调拿到,distanceXdistanceY 分别是 X 轴和 Y 轴上拿到的
滚动距离,getScrollX()getScrollY() 则拿到了当前的滚动距离,这样就可以算出
newScrollXnewScrollY 了,最后调用 scrollTo 进行滚动 。还有一点要注意的是,scrollXscrollY都有滚动范围,实现如下:

// mCurrentZoom 是当前的缩放值;ScrollRange 大于 0 的时候说明可以滚动

private int getScrollRangeX() {
        final int contentWidth = getWidth() - getPaddingRight() - getPaddingLeft();
        return (getContentWidth() - contentWidth);
    }

    private int getContentWidth() {
        return (int) (child().getWidth() * mCurrentZoom);
    }

    private int getScrollRangeY() {
        final int contentHeight = getHeight() - getPaddingBottom() - getPaddingTop();
        return getContentHeight() - contentHeight;
    }

    private int getContentHeight() {
        return (int) (child().getHeight() * mCurrentZoom);
    }

    private View child() {
        return getChildAt(0);
    }

2.4 实现 Fling(抛掷)滚动

Fling 滚动就是我们往某个方向快速滑动,当我们手指抬起后,View 还会沿着某个方向继续滚动。

private GestureDetector.SimpleOnGestureListener mSimpleOnGestureListener = new GestureDetector.SimpleOnGestureListener() {
        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            if (!isEnabled()) {
                return false;
            }
            fling((int) -velocityX, (int) -velocityY);
            return true;
        }
    };
    
private boolean fling(int velocityX, int velocityY) {
        if (Math.abs(velocityX) < mMinimumVelocity) {
            velocityX = 0;
        }
        if (Math.abs(velocityY) < mMinimumVelocity) {
            velocityY = 0;
        }
        final int scrollY = getScrollY();
        final int scrollX = getScrollX();
        final boolean canFlingX = (scrollX > 0 || velocityX > 0) &&
                (scrollX < getScrollRangeX() || velocityX < 0);
        final boolean canFlingY = (scrollY > 0 || velocityY > 0) &&
                (scrollY < getScrollRangeY() || velocityY < 0);
        boolean canFling = canFlingY || canFlingX;
        if (canFling) {
            velocityX = Math.max(-mMaximumVelocity, Math.min(velocityX, mMaximumVelocity));
            velocityY = Math.max(-mMaximumVelocity, Math.min(velocityY, mMaximumVelocity));
            int height = getHeight() - getPaddingBottom() - getPaddingTop();
            int width = getWidth() - getPaddingRight() - getPaddingLeft();
            int bottom = getContentHeight();
            int right = getContentWidth();
            mOverScroller.fling(getScrollX(), getScrollY(), velocityX, velocityY, 0, Math.max(0, right - width), 0,
                    Math.max(0, bottom - height), 0, 0);
            notifyInvalidate();
            return true;
        }
        return false;
    }
    
 private void notifyInvalidate() {
        // 效果和 invalidate 一样,但是会使得动画更平滑
        ViewCompat.postInvalidateOnAnimation(this);
    }
    
@Override
    public void computeScroll() {
        super.computeScroll();
        if (mOverScroller.computeScrollOffset()) { // 判断是否可以滚动
            int oldX = getScrollX();
            int oldY = getScrollY();
            int x = mOverScroller.getCurrX();
            int y = mOverScroller.getCurrY();
            if (oldX != x || oldY != y) {
                final int rangeY = getScrollRangeY();
                final int rangeX = getScrollRangeX();
                processScroll(x - oldX, y - oldY, rangeX, rangeY);
            }
            if (!mOverScroller.isFinished()) { // 如果滚动没有停止,那就再调用一次 notifyInvalidate(),会触发下一次的 computeScroll()
                notifyInvalidate();
            }
        }
    }    

大体的思路是通过 onFling() 拿到手势,然后计算是否能够滑动,可以的话就调用
mOverScroller.fling() 开始滚动,最后不要忘了调用 notifyInvalidate()。调用
notifyInvalidate() 后我们就可以在 computeScroll() 回调中使用 processScroll()进行滚动

2.5 手势缩放

 private ScaleGestureDetector.SimpleOnScaleGestureListener mSimpleOnScaleGestureListener = new ScaleGestureDetector.SimpleOnScaleGestureListener() {
        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            if (!isEnabled()) {
                return false;
            }
            float newScale;
            newScale = mCurrentZoom * detector.getScaleFactor();
            if (newScale > mMaxZoom) {
                newScale = mMaxZoom;
            } else if (newScale < mMinZoom) {
                newScale = mMinZoom;
            }
            setScale(newScale, (int) detector.getFocusX(), (int) detector.getFocusY());
            return true;
        }

        @Override
        public boolean onScaleBegin(ScaleGestureDetector detector) {
            return true;
        }

        @Override
        public void onScaleEnd(ScaleGestureDetector detector) {
        }
    };
    
public void setScale(float scale, int centerX, int centerY) {
        float preScale = mCurrentZoom;
        mCurrentZoom = scale;
        int sX = getScrollX();
        int sY = getScrollY();
        int dx = (int) ((sX + centerX) * (scale / preScale - 1));
        int dy = (int) ((sY + centerY) * (scale / preScale - 1));
        child().setPivotX(0);
        child().setPivotY(0);
        child().setScaleX(mCurrentZoom);
        child().setScaleY(mCurrentZoom);
        processScroll(dx, dy, getScrollRangeX(), getScrollRangeY());
        notifyInvalidate();
    }    

实现思路是 onScale() 中拿到缩放手势,缩放不仅会影响到 scale,其实还会影响到
scrollXscrollY,所以缩放的时候,也要调用 processScroll() 。由于我们的 scrollXscrollY 是基于 ZoomLayout 的左上角计算的(这里先默认子 View 左上角和 ZoomLayout 左上角已知,后面还需要适配这一点),所以我们这里的缩放也要基于左上角计算,通过 setPivotX(0)setPivotY(0) 设置缩放中心点为左上角

2.6 双击缩放

private GestureDetector.SimpleOnGestureListener mSimpleOnGestureListener = new GestureDetector.SimpleOnGestureListener() {
        @Override
        public boolean onDoubleTap(MotionEvent e) {
            float newScale;
            if (mCurrentZoom < 1) {
                newScale = 1;
            } else if (mCurrentZoom < mDoubleClickZoom) {
                newScale = mDoubleClickZoom;
            } else {
                newScale = 1;
            }
            smoothScale(newScale, (int) e.getX(), (int) e.getY());
            return true;
        }
    };

public void smoothScale(float newScale, int centerX, int centerY) {
        if (mCurrentZoom > newScale) {
            if (mAccelerateInterpolator == null) {
                mAccelerateInterpolator = new AccelerateInterpolator();
            }
            mScaleHelper.startScale(mCurrentZoom, newScale, centerX, centerY, mAccelerateInterpolator);
        } else {
            if (mDecelerateInterpolator == null) {
                mDecelerateInterpolator = new DecelerateInterpolator();
            }
            mScaleHelper.startScale(mCurrentZoom, newScale, centerX, centerY, mDecelerateInterpolator);
        }
        notifyInvalidate();
    }

@Override
    public void computeScroll() {
        super.computeScroll();
        if (mScaleHelper.computeScrollOffset()) {
            setScale(mScaleHelper.getCurScale(), mScaleHelper.getStartX(), mScaleHelper.getStartY());
        }
    }    
    

双击缩放和手势缩放都是缩放,不同点在于双击缩放我们需要自己去计算每个时间点的
scale,比如说双击后,View 会在 200 ms 内从 1倍 scale 变成 2倍 scale,那么我们就要自己去计算 200ms,scale 的变化。看到这里大家都应该想到了其实就是对 scale 这个值做一个属性动画嘛。这里将其封装在了 ScaleHelper 中。跟 Fling 的思路一样,通过 notifyInvalidate() 和 computeScroll() 实现循环。

3. 其他功能

3.1 适配 ViewPager

很简单,ViewPager 会通过子 ViewcanScrollHorizontallycanScrollVertically 判断是否可以横向、竖向滚动,ZoomLayout 重写他们就是了

    @Override
    public boolean canScrollHorizontally(int direction) {
        if (direction > 0) {
            return getScrollX() < getScrollRangeX();
        } else {
            return getScrollX() > 0 && getScrollRangeX() > 0;
        }
    }

    @Override
    public boolean canScrollVertically(int direction) {
        if (direction > 0) {
            return getScrollY() < getScrollRangeY();
        } else {
            return getScrollY() > 0 && getScrollRangeY() > 0;
        }
    }

3.2 事件传递

我们不希望 ZoomLayout 或者子 View 把事件消耗掉,而是两者都能收到事件。
下面是我的实现:
ZoomLayoutdispatchTouchEvent 去接收事件,因为这样即使子 View 消耗了事件,事件依然会经过这里。
然后在 onDraw 设置 child().setClickable(true),这里是为了让事件能被
子 View 消耗掉,因为只有子 View 消耗了事件,事件才能一直传递到 ZoomLayout 中去。

@Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        mGestureDetector.onTouchEvent(ev);
        mScaleDetector.onTouchEvent(ev);
        return super.dispatchTouchEvent(ev);
    }
    
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        child().setClickable(true);
    }

3.3 布局的控制

我们希望 ZoomLayout 的宽高比子 View 的宽高大的时候,居中显示,否则就显示为
left|top,我的思路是我们可以在 onDraw 中拿到准确的宽、高,通过宽高的对比,决定使用什么布局

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        child().setClickable(true);
        if (child().getHeight() < getHeight() || child().getWidth() < getWidth()) {
            setGravity(Gravity.CENTER);
        } else {
            setGravity(Gravity.TOP);
        }
    }

当适配到这里的时候,我们会发现一个问题,比如子 View 的高度小于 ZoomLayout 的时候,我们是安装子 View 的中心放大,如果这是我们放大子 View,放大到子 View 高度大于 ZoomLayout,我们这时候需要将子 View translate 到 ZoomLayout 的顶部,原因是我们第 2.5 步说到的 scroolX、scrollY 是基于左上角计算的。所以适配后的代码是这样的

    public void setScale(float scale, int centerX, int centerY) {
        float preScale = mCurrentZoom;
        mCurrentZoom = scale;
        int sX = getScrollX();
        int sY = getScrollY();
        int dx = (int) ((sX + centerX) * (scale / preScale - 1));
        int dy = (int) ((sY + centerY) * (scale / preScale - 1));
        if (getScrollRangeX() < 0) {
            child().setPivotX(child().getWidth() / 2);
            child().setTranslationX(0);
        } else {
            child().setPivotX(0);
            int willTranslateX = -(child().getLeft());
            child().setTranslationX(willTranslateX);
        }
        if (getScrollRangeY() < 0) {
            child().setPivotY(child().getHeight() / 2);
            child().setTranslationY(0);
        } else {
            int willTranslateY = -(child().getTop());
            child().setTranslationY(willTranslateY);
            child().setPivotY(0);
        }
        child().setScaleX(mCurrentZoom);
        child().setScaleY(mCurrentZoom);
        processScroll(dx, dy, getScrollRangeX(), getScrollRangeY());
        notifyInvalidate();
    }

在适配业务的过程,遇到了另外一个问题,就是 ZoomLayout 的高度有可能是会发生变化的,比如键盘弹出来的时候,ZoomLayout 可能会被压小,
我的思路是在 onDraw 中监听宽高的变化,有变化的时候,调用 setScale 去设置为正确的状态。

public void setScale(float scale, int centerX, int centerY) {
        // 记下最近一次的状态
        mLastCenterX = centerX;
        mLastCenterY = centerY;
        mCurrentZoom = scale;
    }


    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        super.onLayout(changed, l, t, r, b);
        if (mNeedReScale) {
            // 需要重新刷新,因为宽高已经发生变化
            setScale(mCurrentZoom, mLastCenterX, mLastCenterY);
            mNeedReScale = false;
        }
    }    
    
@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (mLastChildWidth != child().getWidth() || mLastChildHeight != child().getHeight() || mLastWidth != getWidth()
            || mLastHeight != getHeight()) {
            // 宽高变化后,记录需要重新刷新,放在下次 onLayout 处理,避免 View 的一些配置:比如 getTop() 没有初始化好
            // 下次放在 onLayout 处理的原因是 setGravity 会在 onLayout 确定完位置,这时候去 setScale 导致位置的变化就不会导致用户看到
            // 闪一下的问题
            mNeedReScale = true;
        }
        mLastChildWidth = child().getWidth();
        mLastChildHeight = child().getHeight();
        mLastWidth = child().getWidth();
        mLastHeight = getHeight();
        if (mNeedReScale) {
            notifyInvalidate();
        }
    }    

上面有个小细节是发现 mNeedReScale 为 true 时没有立即调用 setScale,因为这时候 setGravity 还没有生效,我把它放在了下一次
onLayout

总结

实现一个 ZoomLayout 主要是需要熟悉手势的使用,然后实现过程中比较难也比较麻烦的是各种坐标相关的计算,以及各种细节的适配。实现过程中很多代码都参考了 LargeImageViewScrollView
ZoomLayout 的实现还有很多改进的地方,比如事件的处理等,欢迎交流~

ZoomLayout 源码 里面有完整的代码、Demo、使用说明

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

推荐阅读更多精彩内容