『Android自定义View实战』让你的轮播指示器“粘”起来

前言

在现在的App设计中,轮播基本成为了每个应用的“标配”,有了轮播,就自然需要有对应的指示器,代表当前轮播的进度,现在市面上指示器的样式大部分都是基于小圆点的形式,实现这个基本的效果网上也有很多轮子,本文主要是在实现基本效果的基础上,在切换圆点之间添加一个粘性过渡的动画效果。
 

效果预览

粘性轮播指示器.gif

 

实现思路

绘制圆点

圆点的话基于画笔绘制,将控件宽度平分为N等份,且选中的圆点半径稍大。

圆点之间的联动滚动

支持设置最多显示N个圆点,当圆点总数超过N个时,暂时不显示在控件可见范围内,直到左/右滚动到靠近边界时,自动平移所有圆点,从而让最新选中的圆点再次回到居中的位置。使用属性动画结合横坐标偏移实现。

圆点过渡动画

圆点与圆点之间,如果单纯切换选中,会显得有些生硬,所以要为这个过程添加一些过渡的动画效果,这里采用当下常见的一种“粘性”效果,类似于我们在QQ联系人列表长按拖动未读消息数的效果:

qq未读数拖拽效果.jpg

这里基于贝塞尔曲线来实现,通过计算准备过渡的两个圆点的位置,以及它们之间的中心点,可以绘制出上下两条贝塞尔曲线,再闭合起来即可。然后结合属性动画进行移动,完成最终的过渡效果。

 

实现步骤

1.计算控件宽高

按照设计的效果,控件的宽高取决于小圆点的排列:

控件宽度 = 屏幕中可见的所有小圆点的宽度 * 可见小圆点的数量 + 小圆点之间的间距 * (可见小圆点的数量 - 1)

控件高度 = 最大的小圆点的高度

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int count = Math.min(totalCount, showCount);
    int width = (int) ((count - 1) * smallDotWidth + (count - 1) * dotPadding + bigDotWidth);
    int height = (int) bigDotWidth;
    final int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
    final int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
    final int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
    final int heightSpecSize = MeasureSpec.getMode(heightMeasureSpec);
    if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
        setMeasuredDimension(width, height);
    } else if (widthSpecMode == MeasureSpec.AT_MOST) {
        setMeasuredDimension(width, heightSpecSize);
    } else if (heightSpecMode == MeasureSpec.AT_MOST) {
        setMeasuredDimension(widthSpecSize, height);
    } else {
        setMeasuredDimension(width, height);
    }
}

另外注意最大显示数量的控制,即 int count = Math.min(totalCount, showCount); 如果当前圆点总数超过屏幕可见数,则基于最大可见数来计算控件的宽度。
 

2.绘制小圆点

在知道小圆点的数量之后,只需要遍历依次绘制即可。考虑到选中的圆点与其他圆点样式上的区别,因此针对当前选中圆点单独设置宽度 bigDotWidth,单独设置颜色 selectColor,如下:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    float startX = curX;
    float selectX = 0;
    for (int i = 0; i < totalCount; i++) {
        if (curIndex == i) {
            //绘制选中圆点
            paint.setColor(selectColor);
            paint.setStyle(Paint.Style.FILL);
            
            selectRectF.left = startX;
            selectRectF.top = getHeight() / 2f - bigDotWidth / 2;
            selectRectF.right = startX + bigDotWidth;
            selectRectF.bottom = getHeight() / 2f + bigDotWidth / 2;
            canvas.drawCircle(startX + (bigDotWidth) / 2, bigDotWidth / 2, (bigDotWidth) / 2, paint);
            selectX = startX + bigDotWidth / 2;
            startX += (bigDotWidth + dotPadding);
        } else {
            //绘制其它圆点
            paint.setColor(defaultColor);
            paint.setStyle(Paint.Style.FILL);

            startX += smallDotWidth / 2;
            canvas.drawCircle(startX, bigDotWidth / 2, (smallDotWidth) / 2, paint);
            startX += (smallDotWidth / 2 + dotPadding);
        }
    }
}

 

3.左右平移动画

从效果图可以看出,指示器平移的触发时机在于每一次的左右切换,具体需要满足如下条件:

1.当前圆点总数超过最大可见数

2.当前准备切换的下一个圆点在屏幕非中间的位置

第一个条件,圆点总数超过最大可见数才可平移,这个很好理解。第二个是切换的下一个圆点在屏幕非中间位置,这个是平移的一个规则,比如下面的例子:

指示器平移示意图.png

上图在切换之前,选中的是3,准备切换到4的过程中,由于当前总数为7个,超过最大可见数5个,满足第一个条件,同时由于在切换之前4是处在非屏幕中间的位置,因此满足第二个条件,需要整体向左平移一个单位,使得切换之后,4变成了屏幕中心的位置,逻辑如下:

public void setCurIndex(int index) {
    if (index == curIndex) {
        return;
    }
    //当前圆点总数超过最大可见数
    if (totalCount > showCount) {
        if (index > curIndex) {
            //往左边滑动
            int start = showCount % 2 == 0 ? showCount/2 - 1 : showCount / 2;
            int end = totalCount - showCount / 2;
            //判断是否需要先滚动
            if (index > start && index < end) {
                startScrollAnim(Duration.LEFT, () -> invalidateIndex(index));
            } else {
                invalidateIndex(index);
            }
        } else {
            //往右边滑动
            int start = showCount / 2;
            int end = showCount % 2 == 0 ? totalCount - showCount / 2 + 1 : totalCount - showCount / 2;
            //判断是否需要先滚动
            if (index > start - 1 && index < end - 1) {
                startScrollAnim(Duration.RIGHT, () -> invalidateIndex(index));
            } else {
                invalidateIndex(index);
            }
        }
    } else {
        invalidateIndex(index);
    }
}

 

4.圆点过渡动画

圆点之间的粘性动画,本质上是以前一个圆点作为基准位置,然后平移另外一个圆点的水平位置,使得它们之间的闭合曲线逐渐变化,直到平移到与下一个圆点位置重合,如下:

圆点过渡示意图.png

由红色圆点切换到绿色圆点的过程中,以A点为起始点,连接A点与C点绘制一条贝塞尔曲线,同样,底部B点和D点之间也绘制一条贝塞尔曲线,然后再把AB和CD也连接起来,四条路径形成一条闭合的曲线绘制出来,形成基本的形状。
然后再结合属性动画,使得C点和D点不断向右移动,直到与绿色圆完全重合。
如下:

设置粘性属性动画的起始和结束值:

//当前选中的圆点的水平中心 作为粘性动画起始点
float startValues = getCurIndexX() + bigDotWidth / 2;
//根据方向设置动画的结束值
if (index > curIndex) {
    stickAnimator.setFloatValues(startValues, startValues + dotPadding + smallDotWidth);
} else {
    stickAnimator.setFloatValues(startValues, startValues - dotPadding - smallDotWidth);
}

 
监听动画不断刷新粘性过渡的动画值:

ValueAnimator stickAnimator = new ValueAnimator();
stickAnimator.setDuration(animTime);
stickAnimator.addUpdateListener(animation -> {
    stickAnimX = (float) animation.getAnimatedValue();
    invalidate();
});
stickAnimator.removeAllListeners();
stickAnimator.addListener(new AnimatorListenerAdapter() {
    @Override
    public void onAnimationEnd(Animator animation) {
        isSwitchFinish = true;
        invalidate();
    }
});
stickAnimator.start();

 
绘制粘性曲线:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    
    if (isSwitchFinish) {
        //切换完成记得重置路径
        stickPath.reset();
    } else {
        paint.setColor(selectColor);
        //以当前选中的圆点为绘制起始点
        float quadStartX = selectX;
        float quadStartY = getHeight() / 2f - bigDotWidth / 2;
        stickPath.reset();
        //连接4个点
        stickPath.moveTo(quadStartX, quadStartY);
        stickPath.quadTo(quadStartX + (stickAnimX - quadStartX) / 2, bigDotWidth / 2, stickAnimX, quadStartY);
        stickPath.lineTo(stickAnimX, quadStartY + bigDotWidth);
        stickPath.quadTo(quadStartX + (stickAnimX - quadStartX) / 2, bigDotWidth / 2, quadStartX, quadStartY + bigDotWidth);
        //形成闭合曲线
        stickPath.close();
        //绘制过渡过程中的圆
        canvas.drawCircle(stickAnimX, bigDotWidth / 2, (bigDotWidth) / 2, paint);
        canvas.drawPath(stickPath, paint);
    }
}

 

结语

如果指定了最多显示多少个圆点,则当总数超过时会左右滚动,如果想要非滚动的形式也可以设置为最大圆点数。本控件主要还是通过贝塞尔曲线来制作粘性效果,让动画更为生动,支持设置是否开启粘性效果、粘性动画时长、小圆点选中与非选中时的样式等,后续会再根据需求扩充其它细节。</br>
完整代码已传至Github:一组实用炫酷自定义View的集合库(包括源码及demo)包括常见的支付、扫描、解锁动画、炫酷转盘式菜单等效果,欢迎issue和star~

 

欢迎关注 Android小Y 的简书,更多Android精选自定义View

『Android自定义View实战』实现一个小清新的弹出式圆环菜单
『Android自定义View实战』玩转PathMeasure之自定义支付结果动画
『Android自定义View实战』自定义弧形旋转菜单栏——卫星菜单
『Android自定义View实战』Android自定义带侧滑菜单的二维表格控件

GitHubGitHubZJY
简 书Android小Y

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

推荐阅读更多精彩内容