仿Google原生桌面水波纹展开收起动画实现

前言

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

ripple_test.gif

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桌面上很好的展示效果。本文具体实现在这里。

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

推荐阅读更多精彩内容