支持子控件视图缩放和点击事件的自定义控件

230 阅读8分钟

视图缩放分发

要实现视图缩放这个功能,我们要考虑需要缩放的视图包括什么。有可能只是一个ImageView,有可能是一个LinearLayout,或者FrameLayout。

所以我们的控件需要继承ViewGroup,这样才能支持放大所有控件。同时需要识别手势进行放大缩小,所以要实现系统接口ScaleGestureDetector.OnScaleGestureListener,同时监听手势的按下onScaleBegin,手势的移动onScale,手势结束onScaleEnd。

在构造方法中创建手势监听对象ScaleGestureDetector,并传入实现了系统接口ScaleGestureDetector.OnScaleGestureListener的自定义控件。

子类自己实现onMeasure、onLayout和onDraw方法进行测量和布局。我们只需要在dispatchDraw方法调用父类方法之前获取当前的缩放状态,然后设置给Canvas,进行对应的缩放。

然后就可以进行缩放的数据处理。

自从我们开始缩放,onScaleBegin将会被调用。当手指移动时。焦点也将移动。我们保持着陆焦点x,y以避免更改。

private final Matrix matrix=new Matrix();

@Override
public boolean onScaleBegin(ScaleGestureDetector detector) {
    //当前X和Y轴的缩放值,用Matrix存放
    float matrixScaleX = getLayoutScaleX();
    float matrixScaleY = getLayoutScaleY();
    scaleFocusX=detector.getFocusX()/matrixScaleX;
    scaleFocusY=detector.getFocusY()/matrixScaleY;
    return true;
}

public float getLayoutScaleX() {
    matrix.getValues(m);
    return m[Matrix.MSCALE_X];
}

public float getLayoutScaleY(){
    matrix.getValues(m);
    return m[Matrix.MSCALE_Y];
}

缩放的过程中将会调用onScale方法。获取到最新的缩放倍数,然后更新给Matrix。接着根据缩放的倍数和焦点滑动的距离计算出需要的横纵坐标的位移值,然后传递给子View,子View可以选择不做滑动,但是子View只要消耗位移值,就要进行递减,剩下的位移值留给我们自己的控件。

@Override
public boolean onScale(ScaleGestureDetector detector) {
    float scaleFactor=detector.getScaleFactor();
    matrix.postScale(scaleFactor, scaleFactor);
    setViewScaleInternal(oldMatrixScaleX,oldMatrixScaleY,scaleFocusX,scaleFocusY);
    return true;
}

public void setViewScaleInternal(float oldMatrixScaleX, float oldMatrixScaleY,float focusX,float focusY) {
    //Calculate the focus center location.
    float matrixScaleX = getLayoutScaleX();
    float matrixScaleY = getLayoutScaleY();

    float scrolledX = focusX+scaleScrollX;
    float scrolledY = focusY+scaleScrollY;
    int dx = Math.round(((matrixScaleX-oldMatrixScaleX) * scrolledX)/matrixScaleX);
    int dy = Math.round(((matrixScaleY-oldMatrixScaleY) * scrolledY)/matrixScaleY);
    scrollBy(dx,dy);
    ViewCompat.postInvalidateOnAnimation(this);
}

@Override
public void scrollBy(int x, int y) {
    final boolean canScrollHorizontal = canScrollHorizontally();
    final boolean canScrollVertical = canScrollVertically();
    if (canScrollHorizontal || canScrollVertical) {
        scrollByInternal(canScrollHorizontal ? x : 0, canScrollVertical ? y : 0,isScaleDragged);
    }
}

protected void scrollByInternal(int dx, int dy,boolean isScaleDragged) {
    int consumedX=0;
    if (dx != 0) {
        consumedX = scrollHorizontallyBy(dx,isScaleDragged);
    }
    int consumedY=0;
    if (dy != 0) {
        consumedY = scrollVerticallyBy(dy,isScaleDragged);
    }
    offsetScaleScroll(consumedX,consumedY);
}

protected void offsetScaleScroll(float x, float y){
    scaleScrollX+=x;
    scaleScrollY+=y;
}

缩放结束后会调用onScaleEnd方法,判断缩放的程度是否大于或小于规定值,然后进行Matrix的更新(可不处理)。

@Override
public void onScaleEnd(ScaleGestureDetector detector) {
    float matrixScaleX = getLayoutScaleX();
    if(zoomMinimum>matrixScaleX){
        scaleAnimationTo(zoomMinimum, scaleFocusX,scaleFocusY);
    } else if(zoomMaximum<matrixScaleX){
        scaleAnimationTo(zoomMaximum, scaleFocusX,scaleFocusY);
    }
}
    
private void scaleAnimationTo(float scale, final float focusX, final float focusY){
    float matrixScaleX = getLayoutScaleX();
    zoomAnimator = ValueAnimator.ofFloat(matrixScaleX, scale);
    zoomAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            float matrixScaleX = getLayoutScaleX();
            float matrixScaleY = getLayoutScaleY();
            float animatedValue= (float) animation.getAnimatedValue();
            matrix.setScale(animatedValue,animatedValue);
            setViewScaleInternal(matrixScaleX,matrixScaleY,focusX,focusY);
        }
    });
    zoomAnimator.start();
}

点击事件分发

由于我们需要包含子View,所以有可能子View要求不拦截点击事件,所以我们要重写requestDisallowInterceptTouchEvent方法,使用groupFlags标识位来记录是否子View不让我们拦截,子View自己要处理点击事件。

@Override
public void requestDisallowInterceptTouchEvent(boolean disallowIntercept) {
    super.requestDisallowInterceptTouchEvent(disallowIntercept);
    if (disallowIntercept) {
        groupFlags |= FLAG_DISALLOW_INTERCEPT;
    } else {
        groupFlags &= ~FLAG_DISALLOW_INTERCEPT;
    }
}

接来下就要重写dispatchTouchEvent方法进行点击事件的分发和处理。

首先需要获取当前的滑动状态,包括已经滑动的距离和当前的焦点。

int scrollX = getScrollX();
int scrollY = getScrollY();
final int action = ev.getActionMasked();
final float xf = ev.getX();
final float yf = ev.getY();
final float scrolledXFloat = xf + scrollX;
final float scrolledYFloat = yf + scrollY;
final Rect frame = tempRect;

点击事件最开始的是MotionEvent.ACTION_DOWN,如果子View需要拦截,他会调用requestDisallowInterceptTouchEvent方法进行请求,disallowIntercept就为true。

boolean disallowIntercept = (groupFlags & FLAG_DISALLOW_INTERCEPT) != 0;

然后就要处理MotionEvent.ACTION_DOWN事件。motionTarget代表处理点击事件的View。如果我们不允许拦截,或者如果我们允许并且我们没有拦截,那么事件就交给子View处理。将点击事件的坐标根据当前缩放和移动的状态进行处理,然后遍历子View,获取子View当前的坐标范围,看看是否包含再点击的坐标内,如果包含则调用子View的dispatchTouchEvent将事件分发给子View。然后将motionTarget设置为子View,返回true,代表有对应的View消费了这个事件了。

if (action == MotionEvent.ACTION_DOWN) {
    if (motionTarget != null) {
        // this is weird, we got a pen down, but we thought it was
        // already down!
        // XXX: We should probably send an ACTION_UP to the current
        // target.
        motionTarget = null;
    }
    // If we're disallowing intercept or if we're allowing and we didn't
    // intercept
    if (disallowIntercept || !onInterceptTouchEvent(ev)) {
        // reset this event's action (just to protect ourselves)
        ev.setAction(MotionEvent.ACTION_DOWN);
        // We know we want to dispatch the event down, find a child
        // who can handle it, start with the front-most child.
        final int scrolledXInt = (int) scrolledXFloat;
        final int scrolledYInt = (int) scrolledYFloat;
        final int count = getChildCount();

        float matrixScaleX = getLayoutScaleX();
        float matrixScaleY = getLayoutScaleY();
        for (int i = count - 1; i >= 0; i--) {
            final View child = getChildAt(i);
            if (child.getVisibility() == VISIBLE || child.getAnimation() != null) {
                child.getHitRect(frame);
                frame.set((int)(frame.left*matrixScaleX),
                        (int)(frame.top*matrixScaleY),
                        (int)(frame.right*matrixScaleX),
                        (int)(frame.bottom*matrixScaleY));
                if (frame.contains(scrolledXInt, scrolledYInt)) {
                    // offset the event to the view's coordinate system
                    final float xc = scrolledXFloat - frame.left;
                    final float yc = scrolledYFloat - frame.top;
                    ev.setLocation(xc/matrixScaleX, yc/matrixScaleY);
                    if (child.dispatchTouchEvent(ev)){
                        // Event handled, we have a target now.
                        motionTarget = child;
                        return true;
                    }
                    // The event didn't get handled, try the next view.
                    // Don't reset the event's location, it's not
                    // necessary here.
                }
            }
        }
    }
}

如果没有子View处理该事件,或者是事件变化了,那么就要进一步判断,首先,如果事件已经结束,以MotionEvent.ACTION_UP作为结束标志,或者取消了该事件,以MotionEvent.ACTION_CANCEL作为取消标志,需要清除我们的拦截事件标志groupFlags。

boolean isUpOrCancel = (action == MotionEvent.ACTION_UP) ||
        (action == MotionEvent.ACTION_CANCEL);

if (isUpOrCancel) {
    // Note, we've already copied the previous state to our local
    // variable, so this takes effect on the next event
    groupFlags &= ~FLAG_DISALLOW_INTERCEPT;
}

如果没有子View处理该事件,那么motionTarget变量就为null。只要不是MotionEvent.ACTION_CANCEL事件,那么我们就把事件自己处理,调用自身的onTouchEvent方法进行处理,然后返回。

// The event wasn't an ACTION_DOWN, dispatch it to our target if
// we have one.
View target = motionTarget;
if (target == null) {
    if ((groupFlags & FLAG_CANCEL_NEXT_UP_EVENT) != 0) {
        ev.setAction(MotionEvent.ACTION_CANCEL);
        groupFlags &= ~FLAG_CANCEL_NEXT_UP_EVENT;
    }
    ev.setLocation(xf, yf);
    //We handle this event by dispatching this event to this method.
    return onTouchEvent(ev);
}

如果我们有一个子View处理了MotionEvent.ACTION_DOWN事件,那么我们需要判断一下之后的事件我们是否需要拦截。如果需要拦截的话,就构造一个MotionEvent.ACTION_CANCEL事件,通知子View,我们要处理该事件,他的任务完成了。然后清空motionTarget,并返回true。

// if have a target, see if we're allowed to and want to intercept its
// events
if (!disallowIntercept && onInterceptTouchEvent(ev)) {
    int left = target.getLeft();
    int top = target.getTop();
    float matrixScaleX = getLayoutScaleX();
    float matrixScaleY = getLayoutScaleY();
    final float xc = scrolledXFloat - left*matrixScaleX;
    final float yc = scrolledYFloat - top*matrixScaleY;
    groupFlags &= ~FLAG_CANCEL_NEXT_UP_EVENT;
    ev.setAction(MotionEvent.ACTION_CANCEL);
    ev.setLocation(xc/matrixScaleX, yc/matrixScaleY);
    if (!target.dispatchTouchEvent(ev)) {
        // target didn't handle ACTION_CANCEL. not much we can do
        // but they should have.
    }
    // clear the target
    motionTarget = null;
    // Don't dispatch this event to our own view, because we already
    // saw it when intercepting; we just want to give the following
    // event to the normal onTouchEvent().
    return true;
}

if (isUpOrCancel) {
    motionTarget = null;
}

最后,如果不拦截接下去的事件,我们就进行事件的位置坐标转换,将事件分发给之前的target。

// finally offset the event to the target's coordinate system and
// dispatch the event.
int left = target.getLeft();
int top = target.getTop();
float matrixScaleX = getLayoutScaleX();
float matrixScaleY = getLayoutScaleY();
final float xc = scrolledXFloat - left*matrixScaleX;
final float yc = scrolledYFloat - top*matrixScaleY;
ev.setLocation(xc/matrixScaleX, yc/matrixScaleY);
//The target child view does not exist.
if (-1==indexOfChild(target)) {
    ev.setAction(MotionEvent.ACTION_CANCEL);
    motionTarget = null;
}
return target.dispatchTouchEvent(ev);

经过上面的处理,事件分发流程就结束了。我们可以看一下onInterceptTouchEvent方法,这个方法是让我们判断是否对于事件进行拦截的重要方法。

@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
    int action = ev.getActionMasked();
    if (super.onInterceptTouchEvent(ev)) {
        return true;
    }
    if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
        releaseDrag();
        return false;
    }
    if (action != MotionEvent.ACTION_DOWN&&isBeingDragged) {
        return true;
    }
    if(MotionEvent.ACTION_DOWN==action) {
        viewFlinger.abortAnimation();
        lastMotionX = ev.getX();
        lastMotionY = ev.getY();
    } else if(MotionEvent.ACTION_MOVE==action){
        float x = ev.getX();
        float y = ev.getY();
        float dx = x - lastMotionX;
        float dy = y - lastMotionY;
        if (Math.abs(dx) > touchSlop||Math.abs(dy) > touchSlop) {
            isBeingDragged = true;
            ViewParent parent = getParent();
            if(null!=parent){
                parent.requestDisallowInterceptTouchEvent(true);
            }
        }
    } else if(MotionEvent.ACTION_UP==action||MotionEvent.ACTION_CANCEL==action) {
        releaseDrag();
    }
    return isBeingDragged;
}

如果事件分发到我们自己处理,那么就需要处理缩放和移动事件。缩放事件主要是交给系统的ScaleGestureDetector处理,滑动事件主要使用系统的VelocityTracker处理。

@Override
public boolean onTouchEvent(MotionEvent ev) {
    //Process the scale gesture.
    if(zoomEnabled){
        scaleGestureDetector.onTouchEvent(ev);
    }
    if (velocityTracker == null) {
        velocityTracker = VelocityTracker.obtain();
    }
    velocityTracker.addMovement(ev);
    ...
}

当事件为MotionEvent.ACTION_DOWN时,需要获取父View,请求不拦截事件requestDisallowInterceptTouchEvent。当事件为MotionEvent.ACTION_MOVE时,需要更新位移值,然后获取父View,请求不拦截事件requestDisallowInterceptTouchEvent。然后判断不是缩放事件后进行滑动处理,并调用invalidate进行刷新视图。当事件为MotionEvent.ACTION_UP时,计算velocityTracker的方法computeCurrentVelocity计算当前速度,然后交给OverScroller处理滑动。

int action = ev.getActionMasked();
if(MotionEvent.ACTION_DOWN==action){
    lastMotionX = ev.getX();
    lastMotionY = ev.getY();
    viewFlinger.abortAnimation();
    invalidate();
    ViewParent parent = getParent();
    if(null!=parent){
        parent.requestDisallowInterceptTouchEvent(true);
    }
} else if(MotionEvent.ACTION_MOVE==action){
    float x = ev.getX();
    float y = ev.getY();
    float dx = x - lastMotionX;
    float dy = y - lastMotionY;
    if (!isScaleDragged&&!isBeingDragged&&(Math.abs(dx) > touchSlop||Math.abs(dy) > touchSlop)) {
        isBeingDragged = true;
        lastMotionX = x;
        lastMotionY = y;
        ViewParent parent = getParent();
        if(null!=parent){
            parent.requestDisallowInterceptTouchEvent(true);
        }
    }
    //To avoid the scale gesture. We check the pointer count.
    int pointerCount = ev.getPointerCount();
    if (1==pointerCount&&!isScaleDragged&&isBeingDragged) {
        lastMotionX = x;
        lastMotionY = y;
        float matrixScaleX = getLayoutScaleX();
        float matrixScaleY = getLayoutScaleY();
        int scaleDx = Math.round(dx / matrixScaleX);
        int scaleDy = Math.round(dy / matrixScaleY);
        scrollBy(-scaleDx,-scaleDy);
        invalidate();
    }
} else if(MotionEvent.ACTION_UP==action){
    if(!isScaleDragged&&null!=velocityTracker){
        float matrixScaleX = getLayoutScaleX();
        float matrixScaleY = getLayoutScaleY();
        velocityTracker.computeCurrentVelocity(1000,maximumVelocity);
        float xVelocity = velocityTracker.getXVelocity();
        float yVelocity = velocityTracker.getYVelocity();
        if(Math.abs(xVelocity)>minimumVelocity||Math.abs(yVelocity)>minimumVelocity){
            viewFlinger.fling(-xVelocity/matrixScaleX,-yVelocity/matrixScaleY);
        }
    }
    releaseDrag();
} else if(MotionEvent.ACTION_CANCEL==action){
    releaseDrag();
}
return true;
public class ViewFlinger implements Runnable{
    private final OverScroller overScroller;
    private int lastFlingX = 0;
    private int lastFlingY = 0;

    public ViewFlinger(Context context) {
        overScroller=new OverScroller(context);
    }

    @Override
    public void run() {
        if(!overScroller.isFinished()&&overScroller.computeScrollOffset()){
            int currX = overScroller.getCurrX();
            int currY = overScroller.getCurrY();
            int dx = currX - lastFlingX;
            int dy = currY - lastFlingY;
            lastFlingX = currX;
            lastFlingY = currY;
//                // We are done scrolling if scroller is finished, or for both the x and y dimension,
//                // we are done scrolling or we can't scroll further (we know we can't scroll further
//                // when we have unconsumed scroll distance).  It's possible that we don't need
//                // to also check for scroller.isFinished() at all, but no harm in doing so in case
//                // of old bugs in OverScroller.
//                boolean scrollerFinishedX = overScroller.getCurrX() == overScroller.getFinalX();
//                boolean scrollerFinishedY = overScroller.getCurrY() == overScroller.getFinalY();
//                final boolean doneScrolling = overScroller.isFinished()
//                        || ((scrollerFinishedX || dx != 0) && (scrollerFinishedY || dy != 0));
            scrollBy(dx,dy);
            invalidate();
            postOnAnimation();
        }
    }

    void startScroll(int startX,int startY,int dx,int dy) {
        lastFlingX = startX;
        lastFlingY = startY;
        overScroller.startScroll(startX, startY, dx, dy);
        if (Build.VERSION.SDK_INT < 23) {
            // b/64931938 before API 23, startScroll() does not reset getCurX()/getCurY()
            // to start values, which causes fillRemainingScrollValues() put in obsolete values
            // for LayoutManager.onLayoutChildren().
            overScroller.computeScrollOffset();
        }
        postOnAnimation();
    }

    /**
     * abort the animation
     */
    void abortAnimation(){
        if(!overScroller.isFinished()){
            overScroller.abortAnimation();
            postInvalidate();
        }
    }

    void fling(float velocityX,float velocityY) {
        lastFlingX = lastFlingY = 0;
        overScroller.fling(0, 0, (int)velocityX, (int)velocityY,
                Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
        postOnAnimation();
    }

    void postOnAnimation() {
        removeCallbacks(this);
        ViewCompat.postOnAnimation(ZoomLayout.this, this);
    }
}