前言

在之前的博客android如何给整个视图view圆角显示中有提到过如何实现对View显示进行圆角裁剪,其原理其实也比较简单。这里先看看动画效果。

View负责绘制显示的draw方法

因为View的draw方法是负责View绘制显示的,并且它是负责整体显示的,包括View的背景,内容,以及子View的递归显示等,因此要使当前View以及它包含的子View也实现裁剪的效果,就需要重写draw方法,而不是onDraw方法,onDraw方法只是负责View自身内容的显示的。下面是View的draw方法绘制流程描述

public void draw(Canvas canvas) {

    /*
     * Draw traversal performs several drawing steps which must be executed
     * in the appropriate order:
     *
     *      1. Draw the background
     *      2. If necessary, save the canvas' layers to prepare for fading
     *      3. Draw view's content
     *      4. Draw children
     *      5. If necessary, draw the fading edges and restore layers
     *      6. Draw decorations (scrollbars for instance)
     */

    ...
}

那么如何对View的显示进行裁剪呢?

对View绘制显示进行裁剪可以使用Canvas提供的clipXXX方法。像我们这里要显示水波纹效果,我选择的是clipPath方法,创建一个Path对象,然后addCircle得到一个圆形的Path。为了防止canvas.clipPath之后会影响其他View的绘制效果,这里需要先对当前Canvas的状态进行save保存,clipPath和draw绘制之后,再restore还原。因此draw方法实现如下

@Override public void draw(Canvas canvas) {
    int saveCount = canvas.save();

    checkPathChanged();

    canvas.clipPath(mPath);
    super.draw(canvas);

    canvas.restoreToCount(saveCount);
}

这里checkPathChanged是检测Path对象是否有变化,在这里也就是Path中的圆形的半径有没有发生变化,通常,如果是做水波纹展开收起动画的话,半径是不断变化的。此外,此处还做了点小优化,如果半径没有发生变化,就不用重新改变Path了。checkPathChanged如下

private void checkPathChanged() {
    if (mProgress == mBackProgress) {
      return;
    }
    mBackProgress = mProgress;

    mWidth = getWidth();
    mHeight = getHeight();
    setCenterXY(mWidth / 2, mHeight / 2);
    int maxRadius = (int) Math.hypot(mWidth, mHeight) / 2;
    mRadius = (int) (maxRadius * mProgress);

    mPath.reset();
    mPath.addCircle(mX, mY, mRadius, Path.Direction.CW);
}

这里的mProgress代表的是展开的进度,半径通过展开进度计算得出。同时记得每次变化Path时记得先调用reset重置Path状态。

什么,水波纹裁剪没有效果?

如果迫不及待的去尝试用以上原理代码去实现的话,你可能会发现,压根就没有裁剪效果!为什么没有效果,先不急,我们先给这个View设置一个背景试试,比较设置个颜色,再试试,是不是有效果了?好奇怪,为什么给这个View设置背景就有效果,不设置就没有?之前我也是被这个问题困扰了许久,后面通过查找找出了原因所在。下面说说查找的方法。

我在当前这个RippleLayout外层包装一个CustomRelativeLayout,它很简单,只是打印了一些信息负责调试。实现如下

public class CustomRelativeLayout extends RelativeLayout {
  private final String TAG = getClass().getSimpleName();


  public CustomRelativeLayout(Context context) {
    super(context);

    init();
  }

  public CustomRelativeLayout(Context context, AttributeSet attrs) {
    super(context, attrs);

    init();
  }

  private void init() {


  }

  @Override public void draw(Canvas canvas) {
    Log.e(TAG, "----------------draw");

    super.draw(canvas);
  }

  @Override protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    Log.e(TAG, "----------------onDraw");
  }

  @Override protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
    Log.e(TAG, "----------------drawChild");

    return super.drawChild(canvas, child, drawingTime);
  }

  @Override protected void dispatchDraw(Canvas canvas) {
    Log.e(TAG, "----------------dispatchDraw");

    super.dispatchDraw(canvas);
  }
}

除了打印信息,没有做任何的逻辑修改。当不给它设置背景时,查看打印信息。

com.test.ripple E/CustomRelativeLayout: ----------------dispatchDraw
com.test.ripple E/CustomRelativeLayout: ----------------drawChild
com.test.ripple E/CustomRelativeLayout: ----------------dispatchDraw
com.test.ripple E/CustomRelativeLayout: ----------------drawChild

会发现,并没有执行draw和onDraw方法!现在我给它设置一个背景颜色,再看看打印信息。

com.test.ripple E/CustomRelativeLayout: ----------------draw
com.test.ripple E/CustomRelativeLayout: ----------------onDraw
com.test.ripple E/CustomRelativeLayout: ----------------dispatchDraw
com.test.ripple E/CustomRelativeLayout: ----------------drawChild
com.test.ripple E/CustomRelativeLayout: ----------------draw
com.test.ripple E/CustomRelativeLayout: ----------------onDraw
com.test.ripple E/CustomRelativeLayout: ----------------dispatchDraw
com.test.ripple E/CustomRelativeLayout: ----------------drawChild

会发现这些方法都执行了,流程是draw->onDraw->dispatchDraw->drawChild,执行了两次,也就是刷新了两次。

我们知道,一般的绘制流程是从draw方法开始,然后是绘制自身背景,然后是onDraw绘制自身内容,然后dispatchDraw绘制包含的子View。但是这里如果没有设置背景的话,连draw方法都没有执行,而是执行了dispatchDraw和drawChild。因此还是要去看看源码是怎么实现的。通过查找dispatchDraw和drawChild方法,发现dispatchDraw除了被draw方法调用之外,还有在View.updateDisplayListIfDirty方法被调用

@NonNull
public RenderNode updateDisplayListIfDirty() {
    ...
    // Fast path for layouts with no backgrounds
    if ((mPrivateFlags & PFLAG_SKIP_DRAW) == PFLAG_SKIP_DRAW) {
        dispatchDraw(canvas);
        if (mOverlay != null && !mOverlay.isEmpty()) {
            mOverlay.getOverlayView().draw(canvas);
        }
    } else {
        draw(canvas);
    }
    ...
}

这里可以看出,如果没有设置背景,走的是dispatchDraw,有背景才走的draw。这样做应该是为了优化大部分View没有设置背景的情况,免去draw方法中处理绘制背景等逻辑。也就是说

默认View和View的子类,如果有设置背景,则正常会调用draw方法,如果没有设置背景,则不调用draw方法,而是调用dispatchDraw作子View的绘制。

OK,知道了原理之后,我们对RippleLayout初始化时针对没有设置背景的情况做特殊处理

public RippleLayout(Context context) {
    super(context);

    init();
}

public RippleLayout(Context context, AttributeSet attrs) {
    super(context, attrs);

    init();
}

private void init() {

    if(getBackground() == null){
      //需设置背景,否则无法显示圆角裁剪
      setBackgroundColor(Color.TRANSPARENT);
    }

    mPath = new Path();
    mPath.setFillType(Path.FillType.EVEN_ODD);

    setCornerRadius(dp2px(4));
}

如果当前没有背景,则给其设置一个透明颜色的背景,这样就解决了没有设置背景无法实现裁剪的问题。

如何实现水波纹展开收起动画?

通过上面对RippleLayout中设置它的mProgress可改变它裁剪半径大小,也就可以实现圆形裁剪,因此只要不停改变大小,就可以实现水波纹展开收起动画了,这里使用ValueAnimator做动画的渐变操作。

private void expand(){
    doRippleAnim(0.2f, 1);
}

private void unexpand(){
    doRippleAnim(1, 0.2f);
}

private void doRippleAnim(final float fromPercent, final float toPercent){
    ValueAnimator animator = ValueAnimator.ofFloat(fromPercent, toPercent).setDuration(ANIM_TIME);
    animator.setInterpolator(new AccelerateDecelerateInterpolator());
    animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
      @Override
      public void onAnimationUpdate(ValueAnimator animation) {

        final float progress = fromPercent + animation.getAnimatedFraction() * (toPercent - fromPercent);

        rippleView.setProgress(progress);

      }
    });
    animator.start();

}

通过ValueAnimator不断更新渐变值,在onAnimationUpdate回调中设置rippleView的setProgress来达到动画效果。

不过这里只是对视图的显示做了展开收起的操作,实际布局并没有变化,假如里面有按钮的话,点击该位置依然会产生点击事件,因此并不算真正的水波纹展开收起效果。因此我们这里再加上位移和缩放等属性动画,配合一起实现更真实的水波纹展开收起动画效果。

我们先看收起动画实现

/**
 * 收起动画
 */
private void unexpandOther(){
  final float fromPercent = 1f;
  final float toPercent = 0.1f;

  final float fromScale = 1f;
  final float toScale = 0.1f;

  final float fromX = 0;
  final float fromY = 0;
  final float toX = dp2px(100);
  final float toY = dp2px(100);

  final float fromProgress = 1;
  final float toProgress =
      (float) (rippleView.getWidth() / Math.hypot(rippleView.getWidth(), rippleView.getHeight()));

  ValueAnimator animator = ValueAnimator.ofFloat(fromPercent, toPercent).setDuration(ANIM_TIME);
  animator.setInterpolator(new AccelerateDecelerateInterpolator());
  animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {

      final float progress = animation.getAnimatedFraction();

      rippleView.setProgress((toProgress - fromProgress) * progress + fromProgress);

      rippleView.setX((toX - fromX) * progress + fromX);
      rippleView.setY((toY - fromY) * progress + fromY);
      rippleView.setScaleX((toScale - fromScale) * progress + fromScale);
      rippleView.setScaleY((toScale - fromScale) * progress + fromScale);

    }
  });
  animator.start();
}

根据动画进度0.1到1,不断对位置,缩放大小,水波纹裁剪度进行调整。同理,展开动画也是差不多的,参数不同而已。

/**
 * 展开动画
 */
private void expandOther(){
  final float fromPercent = 0.1f;
  final float toPercent = 1f;

  final float fromScale = 0.1f;
  final float toScale = 1f;

  final float fromX = dp2px(100);
  final float fromY = dp2px(100);
  final float toX = 0;
  final float toY = 0;

  final float fromProgress =
      (float) (rippleView.getWidth() / Math.hypot(rippleView.getWidth(), rippleView.getHeight()));
  final float toProgress = 1;

  ValueAnimator animator = ValueAnimator.ofFloat(fromPercent, toPercent).setDuration(ANIM_TIME);
  animator.setInterpolator(new AccelerateDecelerateInterpolator());
  animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {

      final float progress = animation.getAnimatedFraction();

      rippleView.setProgress((toProgress - fromProgress) * progress + fromProgress);

      rippleView.setX((toX - fromX) * progress + fromX);
      rippleView.setY((toY - fromY) * progress + fromY);
      rippleView.setScaleX((toScale - fromScale) * progress + fromScale);
      rippleView.setScaleY((toScale - fromScale) * progress + fromScale);

    }
  });
  animator.start();
}

现在再点击RippleLayout中的按钮所在的位置,就不会产生点击事件了,因为他们整理都进行缩放移动了,而不只是是显示的变化。

总结

本文讲解了,View绘制的大致流程,水波纹展开收起动画是根据对Canvas的在draw绘制之前做裁剪操作来实现的。然后分析了当没有设置背景时无法实现裁剪效果的问题,原因和解决办法。最后是如何实现水波纹展开收起动画,这里分为显示上的展开收起以及真正意义上的展开收起(涉及到属性动画)。水波纹展开动画效果在android原生的Google桌面上很好的展示效果。