Android 自定義優雅的BezierSeekBar

fairytale110同學發表於2018-11-14

原創文章,轉載請註明出處,juejin.im/post/5bebed…

0x0 前言

某設計網經常會有很多優秀漂亮的互動設計作品,有一天,偶遇這樣的效果,動畫流暢,互動自然,於是埋頭自己解剖其中的元素,做了個開源控制元件,十來天有了一百來個star,覺得很受歡迎,今天專門寫這潦草幾筆,分享案發經過,希望對同行有所幫助。

0x1 準備

效果圖

preview

效果分析

曲線部分

兩個三階貝塞爾曲線

1、預設狀態:直線,首尾標註,預設值標註。

2、手指按下:曲線動畫執行,標識小圓規則地放大,值的標註根據曲線波峰相對位置不變,向上同速移動,同時,標註背景漸變加深,動畫結束。

3、手指拖動效果:曲線、小圓形、標註三者同時跟隨觸控點移動,同時更新標註值。

4、手指離開螢幕:曲線收回動畫執行,標識小圓規則縮小到預設狀態,選中的值跟隨波峰下沉到預設狀態,標註背景漸變消失,動畫結束。

圓形指示器部分

縮放效果

拆解狀態三部分:預設狀態、觸控過程中、觸控後狀態。其中預設狀態下指示器很小的圓形,距離水平曲線下方一個約定距離,當按下過程中,圓最下方座標不變,圓直徑逐漸增大,圓頂部與曲線的距離不變,直到動畫結束。

功能分析

1、控制元件內元素:標尺、標註用的小圓、選中值,均可配置各自的顏色,

2、可配置值範圍,

3、可配置預設值,

4、可實時監聽選中的值。

5、可顯示單位。

技術分析

曲線部分

通過靜態截圖可知本控制元件主要元素為觸控觸發的曲線和其伸縮效果。讓我們來簡單分析一下曲線部分的結構: //這裡展示曲線拆解圖 拆解後,觸控部分為六階貝塞爾曲線,五個基準點,四個控制點,我們將它拆分成兩個三階曲線即可。其中,首尾基準點的Y座標固定,X座標隨著觸控位置相對移動,剩下的基準點X座標相對固定,Y座標根據動畫規律升降。再說控制點,為保障預設狀態下,曲線部分為水平,首尾兩個控制點的Y座標固定,X座標相對固定,中間兩個控制點Y座標和中間那個基準點一致,X相對中間基準點固定。 (通過上面的拆解,可以讓曲線在預設狀態下是一條水平直線,並且在按下狀態下,與水平位置、波峰位置,能有比較自然的過度弧形,不至於那麼生硬。)

圓形指示器部分

普通圓形,相對自身底部向上變大、向下縮小還原。

動畫部分

按下時的動畫採用普通的ValueAnimator,勻速LinearInterpolator。另外還有個選中值的背景變化,根據動畫進度改變畫筆Alpha值即可。 //這裡寫點動畫程式碼囉。

0x2 程式碼實現

泡杯茶,挽起袖子開擼!

繼承View:

public class BezierSeekBar extends View {
    public BezierSeekBar(Context context) {
        super(context);
        init(context, null);

    }

    public BezierSeekBar(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init(context, attrs);
    }

    public BezierSeekBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs);
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    public BezierSeekBar(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        init(context, attrs);
    }

    private void init(Context context, AttributeSet attrs) {
        this.context = context;
    }
}
複製程式碼

先繪製出曲線效果:

        //第一個三階曲線
        bezierPath.moveTo(this.fingerX - circleRadiusMax * 2 * 3, (float) 2 * height / 3);
        bezierPath.cubicTo(this.fingerX - circleRadiusMax * 2 * 2, (float) 2 * height / 3, this.fingerX - circleRadiusMax * 2 * 1, (float) 2 * height / 3 - bezierHeight, this.fingerX, (float) 2 * height / 3 - bezierHeight);

        //第二個三階曲線
        bezierPath.moveTo(this.fingerX, (float) 2 * height / 3 - bezierHeight);
        bezierPath.cubicTo(this.fingerX + circleRadiusMax * 2, (float) 2 * height / 3 - bezierHeight, this.fingerX + circleRadiusMax * 2 * 2, (float) 2 * height / 3, this.fingerX + circleRadiusMax * 2 * 3, (float) 2 * height / 3);

複製程式碼

改變其Y座標,讓曲線恢復預設狀態: 繪製完整線條:

        //line1
        bezierPath.reset();
        bezierPath.moveTo(0, (float) 2 * height / 3);
        bezierPath.lineTo(this.fingerX - circleRadiusMax * 2 * 3, (float) 2 * height / 3);

        //bezier1
        bezierPath.moveTo(this.fingerX - circleRadiusMax * 2 * 3, (float) 2 * height / 3);
        bezierPath.cubicTo(this.fingerX - circleRadiusMax * 2 * 2, (float) 2 * height / 3, this.fingerX - circleRadiusMax * 2 * 1, (float) 2 * height / 3 - bezierHeight, this.fingerX, (float) 2 * height / 3 - bezierHeight);

        //bezier2
        bezierPath.moveTo(this.fingerX, (float) 2 * height / 3 - bezierHeight);
        bezierPath.cubicTo(this.fingerX + circleRadiusMax * 2, (float) 2 * height / 3 - bezierHeight, this.fingerX + circleRadiusMax * 2 * 2, (float) 2 * height / 3, this.fingerX + circleRadiusMax * 2 * 3, (float) 2 * height / 3);

        //line2
        bezierPath.lineTo(width, (float) 2 * height / 3);
        canvas.drawPath(bezierPath, bezierPaint);

複製程式碼

新增Touch事件攔截,按下時顯示曲線:

 @Override
    public boolean onTouchEvent(MotionEvent event) {

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                fingerX = event.getX();
                if (fingerX < 0F) fingerX = 0F;
                if (fingerX > width) fingerX = width;
                //觸控介質進入控制元件,開始動畫過渡
                this.animatorFingerIn.start();
                break;

            case MotionEvent.ACTION_MOVE:
                fingerX = event.getX();
                if (fingerX < 0F) fingerX = 0F;
                if (fingerX > width) fingerX = width;
                postInvalidate();
                break;

            case MotionEvent.ACTION_UP:
                //觸控介質離開控制元件,執行動畫
                this.animatorFingerOut.start();
                break;
        }
        valueSelected = Integer.valueOf(decimalFormat.format(valueMin + (valueMax - valueMin) * fingerX / width));

        if (selectedListener != null) {
            selectedListener.onSelected(valueSelected);
        }
        return true;
    }
複製程式碼

新增動畫效果:

        this.animatorFingerIn = ValueAnimator.ofFloat(0f, 1f);
        this.animatorFingerIn.setDuration(200L);
        this.animatorFingerIn.setInterpolator(new LinearInterpolator());

        this.animatorFingerOut = ValueAnimator.ofFloat(1f, 0f);
        this.animatorFingerOut.setDuration(200L);
        this.animatorFingerOut.setInterpolator(new LinearInterpolator());

        this.animatorFingerOut.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                float progress = (float) animation.getAnimatedValue();

                animInFinshed = (progress >= 0.15F);
                txtSelectedBgPaint.setAlpha((int) (255 * (progress - 0.15F)));
                if (progress >= 0.95F) {
                    textPaint.setColor(colorValueSelected);
                } else {
                    textPaint.setColor(colorValue);
                }

                bezierHeight = circleRadiusMax * 1.5F * progress;
                circleRadius = circleRadiusMin + (circleRadiusMax - circleRadiusMin) * progress;
                spaceToLine = circleRadiusMin * 2 * (1F - progress);
                postInvalidate();
            }
        });

複製程式碼

繪製圓形指示器,根據動畫進度改變其大小:

 canvas.drawCircle(this.fingerX, (float) 2 * height / 3 + spaceToLine + circleRadius, circleRadius, ballPaint);

複製程式碼

新增其它輔助元素後,配置通用屬性,丟擲公共方法:

 <declare-styleable name="BezierSeekBar">
       //曲線顏色
        <attr name="bsBar_color_line" format="reference|color" />
       //圓形指示器顏色
        <attr name="bsBar_color_ball" format="reference|color" />
       //閥值的文字顏色
        <attr name="bsBar_color_value" format="reference|color" />
       //選中值的文字顏色
        <attr name="bsBar_color_value_selected" format="reference|color" />
       //選中值的文字顏色背景
        <attr name="bsBar_color_bg_selected" format="reference|color" />
       //閥值最小
        <attr name="bsBar_value_min" format="integer" />
       //閥值最大
        <attr name="bsBar_value_max" format="integer" />
       //預設選中值
        <attr name="bsBar_value_selected" format="integer" />
       //單位
        <attr name="bsBar_unit" format="reference|string" />
 </declare-styleable>
複製程式碼
private void initAttr(Context context, AttributeSet attrs) {
        if (attrs != null) {
            TypedArray attributes = context.obtainStyledAttributes(attrs, R.styleable.BezierSeekBar);

            this.colorBall = attributes.getColor(R.styleable.BezierSeekBar_bsBar_color_ball, Color.BLACK);
            this.colorLine = attributes.getColor(R.styleable.BezierSeekBar_bsBar_color_line, Color.BLACK);
            this.colorValue = attributes.getColor(R.styleable.BezierSeekBar_bsBar_color_value, Color.BLACK);
            this.colorValueSelected = attributes.getColor(R.styleable.BezierSeekBar_bsBar_color_value_selected, Color.WHITE);
            this.colorBgSelected = attributes.getColor(R.styleable.BezierSeekBar_bsBar_color_bg_selected, Color.BLACK);
            this.valueMin = attributes.getInteger(R.styleable.BezierSeekBar_bsBar_value_min, 30);
            this.valueMax = attributes.getInteger(R.styleable.BezierSeekBar_bsBar_value_max, 150);
            this.valueSelected = attributes.getInteger(R.styleable.BezierSeekBar_bsBar_value_selected, 65);
            this.unit = attributes.getString(R.styleable.BezierSeekBar_bsBar_unit) + "";
            attributes.recycle();
        }
    }
複製程式碼

最後,測試一下:

 <tech.nicesky.bezierseekbar.BezierSeekBar
        android:id="@+id/bsBar_test"
        app:bsBar_color_ball="@android:color/white"
        app:bsBar_color_bg_selected="@android:color/white"
        app:bsBar_color_line="@android:color/white"
        app:bsBar_color_value="@android:color/white"
        app:bsBar_color_value_selected="#ef5350"
        app:bsBar_value_min="30"
        app:bsBar_value_max="120"
        app:bsBar_value_selected="65"
        app:bsBar_unit="kg"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        />
複製程式碼

完美 :) ! 附上demo APK:

demo apk

小結:

本控制元件主要涉及到貝塞爾曲線的基礎理解和應用、動畫的基礎應用、自定義控制元件的常規流程,重點還是熟練各種UI效果的分析拆解和思路整理。

歡迎Star,開源地址:

https://github.com/fairytale110/BezierSeekBar
複製程式碼

相關文章