Android 自定義優雅的BezierSeekBar 之擼碼解析

2021-02-14 安卓巴士Android開發者門戶
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坐標,讓曲線恢復默認狀態:
繪製完整線條:

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

        
        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);

        
        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:

小結:

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

歡迎Star,開源地址:
https://github.com/fairytale110/BezierSeekBar

相關焦點

  • 自定義 EditText 樣式
    自定義EditText 背景一、自定義EditText 圓角矩形背景自定義圓角矩形custom_edittext_background.xml <EditTextandroid:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginLeft="10dp" android:layout_marginRight="10dp" android:layout_marginTop
  • Android Design Support Library之TabLayout
    註:tab2 和tab4 需要自定義。TabItem 屬性text:標籤文字;icon:圖標;layout:自定義布局。view.findViewById(R.id.viewPager); setupViewPager(viewPager); // 設置ViewPager的數據等 tabLayout.setupWithViewPager(viewPager); }上面的效果圖,tab2 和 tab4 是需要自定義的,自定義可以直接寫在
  • Android 界面高亮,如何優雅實現呢?
    一個好的代碼肯定要有拓展的能力,我們能否將圖形的方法自定義?,接下來我們自定義一個Shape:public interface Shape {    /**     * 畫你想要的任何形狀     */    void drawShape(Canvas canvas, Paint paint, HollowInfo info);}在HolloInfo中增加Shape
  • Android自定義實現酷炫的提交完成按鈕
    ><RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" xmlns:app="http://schemas.android.com/apk/res-auto" android:id=
  • JAVA 開發 SpringBoot RestTemplate自定義請求失敗異常處理
    二、源碼解析-默認實現首先程序中99%的異常都是可以自定義處理的,RestTemplate請求結果的異常自然也是可以自定義處理的。在開始自定義之前,我們來探究一下異常的默認處理實現,以此來說明為什麼會出現這樣的現象?
  • 手機閱讀器哪個好之——Android篇
    iReaderiReader是安卓平臺一款非常出名的手機閱讀軟體。也是個人最喜歡的一款安卓手機閱讀器。iReader支持TXT, UMD, CHM, HTML, PDB格式的電子書閱讀,支持手勢翻頁,並且支持書籤、全屏、自定義看書文字等功能。以及白天與夜間看書模式、自定定縮放、自定義看書時的屏幕亮度,還能自定義編碼等,功能非常之強大。
  • Android Flutter 混合開發高仿大廠 App
    gson json解析,配合Android插件使用,可快速生成實體類smartRefreshLayout 智能下拉刷新框架,攜程二樓及下拉刷新加載更多就是用這個實現的eventbus 發布/訂閱事件總線,優雅的完成組件之間通信arouter 依賴注入、路由跳轉、註冊service,優雅的完成模塊之間的通信agentweb webview框架,進行簡單的二次封裝可優雅的進行網頁跳轉
  • Android Studio 4.1終於發布了!
    無論你的應用使用 Jetpack Room 庫還是直接使用 SQLite 的 Android 平臺版本,現在都可以輕鬆地檢查正在運行的應用中的資料庫和表,或運行自定義查詢。由於 Android Studio 在檢查應用時會保持實時連接,因此你還可以使用資料庫檢查器修改值,並在運行的應用中查看這些更改。
  • Android | 爆肝兩天!我寫了一個支持圓角、描邊的UI庫
    代碼在  Github 和 Gitee(碼雲)均已經開源。讀者可以點擊下方連結查看更加詳細的使用方法,本文只做粗略的介紹。最重要的一點是,這種方式一點也不優雅。<?xml version="1.0" encoding="utf-8"?
  • Android-6步教會你自定義View,自定義View就是這麼簡單
    mouthInset, mouthInset, mRadius * 2 - mouthInset, mRadius * 2 - mouthInset);    canvas.drawArc(mArcBounds, 45f, 90f, false, mEyeAndMouthPaint);}6.添加你的View<FrameLayout    xmlns:android
  • Android之AppBarLayout實現懸停吸附伸縮效果
    ,繼承自LinearLayout,實際上就是一個垂直分布的LinearLayout.父類視圖結構如下:public class AppBarLayout extends LinearLayoutjava.lang.Object    ↳  android.view.View      ↳ android.view.ViewGroup
  • 自定義通過PopupWindow實現通用菜單
    會經常用戶到菜單選項提供給用戶選擇,例如選擇圖片,圖庫和相機選擇等一系列場景吧,根據為了以後更加方便使用通過自定義封裝了一個菜單,主要是通過一個列表展示,將菜單項列表傳入設置參數就可以顯示,使用方便簡單只需要幾行代碼就可以,可以顯示在底部,居中和某個控制項的下方。
  • Android Studio的一些小技巧
    這對於一些維護很久的老項目比較有用APK瘦身在Android Studio中我們可以開啟混淆,和自動刪除沒有Resources文件,來達到給APP瘦身的目的,這對於一些維護很久的老項目比較有用,裡面有很多無效的Resource, 刪除後生成的APK會小很多我們只需要在項目的build.gradle中加入android
  • Android開發樣式和主題背景
    如果希望子視圖繼承樣式,則應該改為應用具有 android:theme 屬性的樣式。不過,您通常不會將樣式應用於各個視圖,而是將樣式作為主題背景應用於整個應用、Activity 或視圖集合。擴展和自定義樣式創建自己的樣式時,應始終擴展框架或支持庫中的現有樣式,以保持與平臺界面樣式的兼容性。要擴展樣式,請使用 parent 屬性指定要擴展的樣式。
  • 深入探索 Android 包瘦身(上)
    同時,我們也可以從清單文件中很方便地查看 APK 文件的最終版本,因為 Analyze APK 能夠直接對清單文件進行解析。需要注意的是 目前資源壓縮器目前不會移除 values / 文件夾中定義的資源(例如字符串、尺寸、樣式和顏色)。開啟後,Android 構建工具會通過 ResourceUsageAnalyzer 來檢查哪些資源是無用的,當檢查到無用的資源時會把該資源替換成預定義的版本。
  • Android Material Design系列之Navigation Drawer
    今天我們講一下它們的自定義配置。DrawerLayout布局<?xml version="1.0" encoding="utf-8"?><android.support.v4.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"    xmlns:app="http://schemas.android.com/apk/res-auto"    xmlns:tools="http://schemas.android.com
  • Android仿全歷史——全沉浸時間軸實現
    android:layout_marginBottom="@dimen/margin_min_2" /></LinearLayout> 這個布局文件除了Tablayout外並沒有什麼好講的,所以我們直接進入第二部分2.Tablayout自定義全古蹟中的tablayout主要就是進行了tab切換後字體的大小、粗細的變化,tab採用了滾動的模式,下劃線Indicator是自定義的一個較短的下劃線
  • Android Studio 4.1重磅發布:支持內嵌安卓模擬器!
    近日,Android Studio 4.1 版本正式發布,本文翻譯自 Android 開發者博客。今天我們很高興地發布了穩定版的 Android Studio 4.1,其中包含針對常見的編輯、調試和優化用例的一系列特性。
  • Android 高新技術之SVG矢量動畫機制
    bitmap通過每個像素點上存儲色彩信息來表達圖像,而SVG是一個繪圖標準,與之相對,最大的優勢是SVG放大不會失真,而且bitmap需要不同解析度適配,SVG不需要。><vector xmlns:android="http://schemas.android.com/apk/res/android"    android:width="200dp"    android:height="200dp"    android:viewportHeight="100"    android:viewportWidth="100">    <group
  • Android NDK層編譯OpenCV代碼開發詳解
    代碼開發詳解使用Android NDK開發編譯OpenCV C++代碼,這個在OpenCV4Android開發中會經常遇到的要求,因為OpenCV4Android SDK多數Java代碼都是基於JNI調用,如果對於實時性與應用要求比較高的場合來說,多次頻繁調用JNI層本身就會導致很大的資源開銷,這個時候就需要將全部的處理封裝在C++層,在C++中調用OpenCV相關API函數,同時通過在JNI層面定義本地方法