Android 自定義帶動畫的柱狀圖

齐行超發表於2024-06-26

功能分析

假設要使用柱狀圖展示使用者一週的資料,通用的做法是對接三方圖表SDK或者自己透過程式碼繪製。

1、三方SDK通常包體較大,且定製性差,對特定的UI需求相容性差;
2、自己繪製,比較複雜,而且要考慮各種相容適配;

今天,我們使用一種簡單的方式,來製作柱狀圖,不僅程式碼簡單,而且支援UI樣式、動畫自定義,更難得的是可以自由擴充套件 😁

如何實現?

另闢蹊徑。

統計圖表裡,無非就是一個個表示資料的柱子而已。根據數值的大小,展示不同的高度柱子即可。

我們可使用ProgressBar元件表示柱子,其progress值對應實際的數值大小;
然後根據真實資料條數,建立對應數量的ProgressBar元件,加入到容器元件中,就可以實現柱狀圖了。

1. 自定義柱子

ProgressBar通常只有橫向線條、圓圈樣式,沒有垂直的樣式。
檢視其樣式原始碼,不難發現,progressDrawable是用來繪製進度條的,其實現是個layer-list

<style name="Widget.ProgressBar.Horizontal">
        <item name="indeterminateOnly">false</item>
        <item name="progressDrawable">@drawable/progress_horizontal</item>
        <item name="indeterminateDrawable">@drawable/progress_indeterminate_horizontal</item>
        <item name="minHeight">20dip</item>
        <item name="maxHeight">20dip</item>
        <item name="mirrorForRtl">true</item>
</style>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:id="@android:id/background">
    ...

據此,我們完全可自定義progressDrawable,來實現縱向線條繪製,裡面也可任意定義線條的顏色等樣式屬性。

下面,我們製作兩種縱向線條繪製drawable:

progress_vertical_shade_drawable.xml 【案例中深色線條樣式,用於表示數值較大的線條效果。請在頂部gif圖上檢視】

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:id="@android:id/background">
        <shape>
            <solid android:color="#00E3DBF0" />
            <corners
                android:bottomLeftRadius="0dp"
                android:bottomRightRadius="0dp"
                android:topLeftRadius="0dp"
                android:topRightRadius="0dp" />
        </shape>
    </item>
    <item android:id="@android:id/progress">
        <scale
            android:scaleWidth="0%"
            android:scaleHeight="100%"
            android:scaleGravity="bottom">
            <shape>
                <!--這裡是設定填充顏色和方向-->
                <gradient
                    android:angle="270"
                    android:endColor="#D2D1E6"
                    android:centerColor="#C0B3EA"
                    android:startColor="#C0B3EA"
                    android:type="linear" />
                <corners
                    android:bottomLeftRadius="8dp"
                    android:bottomRightRadius="8dp"
                    android:topLeftRadius="8dp"
                    android:topRightRadius="8dp" />
            </shape>
        </scale>
    </item>
</layer-list>

progress_vertical_tint_drawable.xml【案例中淺色線條樣式,用於表示數值較小的線條效果。請在頂部gif圖上檢視】

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:id="@android:id/background">
        <shape>
            <solid android:color="#00E3DBF0" />
            <corners
                android:bottomLeftRadius="0dp"
                android:bottomRightRadius="0dp"
                android:topLeftRadius="0dp"
                android:topRightRadius="0dp" />
        </shape>
    </item>
    <item android:id="@android:id/progress">
        <scale
            android:scaleWidth="0%"
            android:scaleHeight="100%"
            android:scaleGravity="bottom">
            <shape>
                <!--這裡是設定填充顏色和方向-->
                <gradient
                    android:angle="270"
                    android:endColor="#E3DBF0"
                    android:centerColor="#E3DBF0"
                    android:startColor="#E3DBF0"
                    android:type="linear" />
                <corners
                    android:bottomLeftRadius="8dp"
                    android:bottomRightRadius="8dp"
                    android:topLeftRadius="8dp"
                    android:topRightRadius="8dp" />
            </shape>
        </scale>
    </item>
</layer-list>

我們找個佈局測試一下:

堪稱完美。

2. 自定義柱子元件

寫過RecyclerView的大佬們,都知道列表item要單獨定義出來的意義。

我們的柱子,不僅要展示顏色條,還要展示文字,新增動畫、繫結資料等。
所以,我們單獨寫一個柱子元件,來做這些事情

DayView.java

package com.qxc.muyu.main.view;

import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.animation.LinearInterpolator;
import android.widget.ProgressBar;
import android.widget.RelativeLayout;
import android.widget.TextView;

import androidx.core.content.ContextCompat;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;

import com.qxc.muyu.R;

public class DayView extends RelativeLayout {
    TextView tv_title;
    TextView tv_text;
    ProgressBar pb;

    public DayView(Context context) {
        super(context);
        initView(context);
    }

    public DayView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initView(context);
    }

    void initView(Context context) {
        View view = LayoutInflater.from(context).inflate(R.layout.view_statis_day, this);
        tv_title = view.findViewById(R.id.tv_title);
        tv_text = view.findViewById(R.id.tv_text);
        pb = view.findViewById(R.id.pb);
    }

    public void setData(String title, String text, int maxProgress, int progress, int styleProgressBar, boolean hasAnim) {
        tv_title.setText(title);
        tv_text.setText(text);
        pb.setMax(maxProgress);

        int drawableId = styleProgressBar == 1 ? R.drawable.progress_vertical_shade_drawable : R.drawable.progress_vertical_tint_drawable;
        Drawable customDrawable = ContextCompat.getDrawable(getContext(), drawableId);
        pb.setProgressDrawable(customDrawable);
        if (hasAnim) {
            startAnim(0, progress, 500);
        } else {
            pb.setProgress(progress);
        }
    }

    public void startAnim(int from, int to, int duration) {
        ObjectAnimator alphaTitle = ObjectAnimator.ofFloat(tv_title, "alpha", 0, 1);
        alphaTitle.setInterpolator(new LinearInterpolator());

        ObjectAnimator alphaText = ObjectAnimator.ofFloat(tv_text, "alpha", 0, 1);
        alphaText.setInterpolator(new LinearInterpolator());

        ValueAnimator animProgress = ValueAnimator.ofFloat(from, to);
        animProgress.setInterpolator(new FastOutSlowInInterpolator());
        animProgress.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                try {
                    float animatedValue = (float) animation.getAnimatedValue();
                    pb.setProgress((int) animatedValue);
                } catch (Exception ex) {
                    ex.printStackTrace();
                }
            }
        });

        AnimatorSet set = new AnimatorSet();
        set.play(alphaTitle).with(alphaText).with(animProgress);
        set.setDuration(duration);
        set.start();
    }
}

其佈局檔案:
view_statis_day.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_weight="1"
    android:layout_marginLeft="5dp"
    android:layout_marginRight="5dp"
    android:background="@drawable/shape_week_bg">

    <TextView
        android:id="@+id/tv_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:layout_centerHorizontal="true"
        android:text=""
        android:layout_marginBottom="10dp"
        android:letterSpacing="0.05"
        android:textColor="@color/colorBlack"
        android:textSize="11sp" />

    <ProgressBar
        android:id="@+id/pb"
        style="@android:style/Widget.ProgressBar.Horizontal"
        android:layout_width="50dp"
        android:layout_height="match_parent"
        android:layout_above="@id/tv_title"
        android:layout_centerInParent="true"
        android:layout_marginBottom="10dp"
        android:layout_marginTop="10dp"
        android:max="100"
        android:progress="50"
        android:progressDrawable="@drawable/progress_vertical_shade_drawable" />

    <TextView
        android:id="@+id/tv_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignBottom="@id/pb"
        android:layout_centerHorizontal="true"
        android:layout_marginBottom="10dp"
        android:letterSpacing="0.05"
        android:text=""
        android:textColor="@color/colorBlack"
        android:textSize="12sp"/>
</RelativeLayout>

3. 自定義容器元件

有了一個個柱子,我們的柱子物件是不是需要管理起來,我們就需要一個容器,來放置這些柱子。

假設,我們有一週的資料,展示7個柱子就可以了,使用LinearLayout作為容器就行;
假設,我們要展示一個月的資料,使用RecyclerView、SrcollView作為容器都可以,因為都支援滑動;
更多的場景,大佬們請自個思考吧,怕想多了,傷我腦仁

如題,本案例中我們選擇LinearLayout作為容器。
實現邏輯:

  1. 接收外界資料
  2. 遍歷資料,動態建立、新增柱子元件

WeekView.java

package com.qxc.muyu.main.view;

import android.content.Context;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.RelativeLayout;

import com.qxc.muyu.R;

import java.util.Collections;
import java.util.List;

public class WeekView extends RelativeLayout {
    LinearLayout ll_week;
    boolean hasAnim = true;

    public WeekView(Context context) {
        super(context);
        initView(context);
    }

    public WeekView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initView(context);
    }

    void initView(Context context) {
        View view = LayoutInflater.from(context).inflate(R.layout.view_statis_week, this);
        ll_week = view.findViewById(R.id.ll_week);
    }

    //接收外界資料,動態建立 & 載入資料條物件
    public void setData(List<String> titles, List<Integer> numbers) {
        if (titles == null || numbers == null || numbers.size() == 0 || titles.size() != numbers.size()) {
            return;
        }
        ll_week.removeAllViews();
        int max = Collections.max(numbers);
        for (int i = 0; i < numbers.size(); i++) {
            String title = titles.get(i);
            int num = numbers.get(i);
            String text = formatNumber(num);
            DayView dayView = new DayView(getContext());
            int styleProgressBar = max / 2 > num ? 2 : 1;
            dayView.setData(title, text, max, num, styleProgressBar, hasAnim);
            LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1);
            ll_week.addView(dayView,params);
        }
    }

    private String formatNumber(int num) {
        if (num < 1000) {
            return String.valueOf(num);
        } else if (num < 10000) {
            double value = num / 1000.0;
            return String.format("%.2fk", value);
        } else if (num < 100000000) {
            double value = num / 10000.0;
            return String.format("%.2f萬", value);
        } else {
            double value = num / 100000000.0;
            return String.format("%.2f億", value);
        }
    }
}

其佈局檔案(只有一個容器,簡單的都沒法說)
view_statis_week.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal"
    android:paddingLeft="5dp"
    android:paddingRight="5dp"
    android:id="@+id/ll_week"
    android:background="@drawable/shape_week_bg">

</LinearLayout>

至此,週資料柱形圖表功能已寫完了。

4. 如何使用

在頁面佈局中,使用我們的自定義元件:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#ffffff"
    android:paddingLeft="15dp"
    android:paddingTop="50dp"
    android:paddingRight="15dp">

    <com.qxc.muyu.main.view.WeekView
        android:id="@+id/week"
        android:layout_width="match_parent"
        android:layout_height="300dp"
        android:layout_centerInParent="true" />

    <Button
        android:id="@+id/btn"
        android:layout_width="200dp"
        android:layout_height="50dp"
        android:layout_below="@id/week"
        android:layout_centerHorizontal="true"
        android:layout_marginTop="50dp"
        android:background="#00000000"
        android:text="重新整理資料" />

</RelativeLayout>

程式碼中,給自定義元件設定資料:

WeekView weekView = view.findViewById(R.id.week);
        Button btn = view.findViewById(R.id.btn);
        List<String> titles = new ArrayList<>();
        titles.add("週一");
        titles.add("週二");
        titles.add("週三");
        titles.add("週四");
        titles.add("週五");
        titles.add("週六");
        titles.add("週日");
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1200);
        numbers.add(800);
        numbers.add(500);
        numbers.add(400);
        numbers.add(2200);
        numbers.add(2000);
        numbers.add(888);
        btn.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                weekView.setData(titles, numbers);
            }
        });

就是這麼簡單,UI樣式想怎麼調都行,好用到飛起,簡直了,哈哈~

相關文章