Implementation of custom View circular progress bar with heartbeat animation effect!

Today, let's implement a custom view progress bar with a gap, with a heartbeat animation effect

Take a look at the effect of the screenshot

When it comes to custom view s, we need to implement the onDraw() method to operate the Canvas parameters it carries, and draw the layout style we want through the drawXXX() series of methods.

How to realize a progress bar with gap down and heartbeat animation?

thinking

1. Draw two hollow fan circles by drawArc() method

The first is an unloaded style, and the second is a progress bar style

 RectF rect = new RectF(left, top, right, bottom);
        canvas.drawArc(rect, startPosition, endPosition, false, paint);
        canvas.save(); // Save it.
        //
        paint.setColor(Color.parseColor(progressColor));
        //paint.setDither(true);
        if(!TextUtils.isEmpty(shadowColor))
        paint.setShadowLayer(shadowSize,1,1,Color.parseColor(shadowColor));
        canvas.drawArc(rect, startPosition, progress, false, paint);
        canvas.save(); // Save it.

It should be noted that the two parameters startposition & endposition are calculated. First, understand canvas The drawarc parameters mean:

The first parameter rect is to determine the position. Several position information parameters of RectF represent:

left = distance from the leftmost view boundary to the leftmost side of the drawing canvas;
top = distance from the boundary of the topmost view view to the topmost edge of the drawing canvas;
right = distance from the boundary of the leftmost view to the rightmost side of the drawing canvas;
Bottom = distance from the boundary of the topmost view view to the bottom of the drawing canvas

The second parameter starts to draw the angle of the sector. This angle is 0 in the 3 o'clock direction, rotates 360 degrees clockwise, and then returns to the 3 o'clock direction.

The third parameter is the angle at which the sector ends.

The fourth parameter is whether to draw a line to connect with the center point, which is easy to understand.

The fifth parameter is brush paint, which is specially used to set some brush attributes.

Now let's calculate the position of the notch. The calculation method is also very simple. Assuming that our notch size is 30 degrees and the notch position is vertically downward, we need to determine that the vertical downward angle is 90 degrees, That is, in the 6 o'clock direction (3 o'clock is the starting position and 90 degrees to 6 o'clock). This line divides the gap we need to show into two, and the left and right sides account for 15 degrees respectively. Then the calculation method of the starting fan position is 90.0f + (30/2). After determining the position of starting to draw the sector, we can calculate the angular position of ending the sector. The size of the gap is 30 degrees. Drawing 360 degrees clockwise from 105 degrees is a complete circle. Of course, we don't need a complete circle, so we use 360 degrees to subtract the size of the gap, which is the end position of the gap.

Through calculation, the following methods can be provided to throw out the method of setting the notch size.

   /**
     *  Calculate notch position
     * @param gap
     */
    private void setGapPosition(float gap){
        startPosition = 90.0f + (gap/2);
        endPosition = 360 - gap;
    }

Idea: realize heartbeat animation by changing the radius of the circle

In the onDraw() method, first determine the radius of the circle. Here (l+levelOffset) represents the dynamic calculation of the radius of the circle. To realize the heartbeat animation, you must dynamically change the radius of the circle.

 float r = getMeasuredWidth() / (l+levelOffset)-5; //

"l" in (l+levelOffset) is the level size of the circle, the lowest level is 2, and 2 is the largest circle. It is as large as the layout. The larger the level, the smaller the circle drawn:

    // The size of the circle is the minimum size by default
    private int level = XXXXXX;
    // XX = control / 2 = radius of circle
    public static final int XX = 2;
    // XXX = control / 3 = radius of circle
    public static final int XXX = 3;
    // XXXX = control / 4 = radius of circle
    public static final int XXXX = 4;
    // XXXXX = control / 5 = radius of circle
    public static final int XXXXX = 5;
    // XXXXXX = control / 6 = radius of circle
    public static final int XXXXXX = 6;
    // Modify the width of the brush and the thickness of the progress bar

Calculation method of levelOffset:

   /**
     *  Calculate offset / dynamically control the size of the circle
     */
    public void reconOffset(){
        if(isStarAnimation){
            if(levelOffset <= 0f || isAdd){
                // increase
                isAdd = true;
                levelOffset +=0.02f;
                if(levelOffset>=0.3)isAdd=false;
            }else if(levelOffset>=0f && !isAdd){
                //reduce
                isAdd = false;
                levelOffset -=0.02f;
            }
        }else{
            levelOffset = 0;
        }
    }

The above describes the implementation ideas and calculation methods. For the reconOffset() method, you can try to modify the value and run to see what the effect is.

The complete code is as follows:

package com.gongjiebin.drawtest;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.os.Handler;
import android.os.Message;
import android.text.TextUtils;
import android.util.AttributeSet;
import android.util.TypedValue;
import android.view.View;

import java.text.DecimalFormat;

/**
 * @author gongjiebin
 */
public class JJumpProgress extends View {

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

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

    public JJumpProgress(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initView();
    }


    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);

    }


    public void initView(){
        setGapPosition(this.gap);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);

        /**
         *  Get the width mode of the view
         *  Value - measurespec The size of unspecified view is unlimited and can be any size
         *  Value - measurespec The current size of exctly is the size of view, which is equivalent to match_ This is also true if parent is a specific value
         *  Value - measurespec AT_ The size that most view can take cannot exceed the current value, which is equivalent to wrap_content
         */
//        int widthSize = getSize(100, widthMeasureSpec);
        int heightSize = getSize(200, heightMeasureSpec);

        int widthSize = getDefaultSize(getSuggestedMinimumWidth(),widthMeasureSpec);
        int heightSize = getDefaultSize(getSuggestedMinimumHeight(),heightMeasureSpec);
//        int widthSize =  MeasureSpec.getSize(widthMeasureSpec);
//        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        // Log.i("GJB", "widthSize=" + widthSize + "  heightSize=" + heightSize);

        // Fill size
        setMeasuredDimension(widthSize, heightSize);

    }


    /**
     * Get the final width of the control through the default value and widthmeasurespec / hightmeasurespec
     *
     * @param defSize Default value
     */
    public int getSize(int defSize, int measureSpec) {
        /**
         *  Get the control size. Determine mode
         */
        int widthModel = MeasureSpec.getMode(measureSpec);

        int size = defSize;
        switch (widthModel) {
            case MeasureSpec.UNSPECIFIED: //Fill the layout.
                // No size specified. The default size is used
                size = defSize;
                break;
            case MeasureSpec.EXACTLY:
                size = MeasureSpec.getSize(measureSpec);
                break;
            case MeasureSpec.AT_MOST:// Content package
                // If the size is specified, return the specified size - fixed value, and also take the size of the current view
                size = MeasureSpec.getSize(measureSpec);

        }

        return size;
    }


    /**
     * progress bar
     */
    private float progress = 0f;
    // Notch end position
    private float endPosition;
    // Notch start position
    private float startPosition;
    // Progress text
    private String progressTxt = "";
    // Offset
    private float offset = 5f;
    // Gap. Set the size of the circular notch, 30 degrees by default
    private float gap = 30;
    // Color #000000 of progress bar - hexadecimal representation
    private String progressColor;
    // Color of progress bar #000000 - color of background circle
    private String bg_color;
    // The color of the shadow
    private String shadowColor;
    // The size of the shadow
    private float shadowSize = 5;
    // Font color -- the default is black.
    private String textColor = "#000000";
    // Default display percentage / false do not display
    private boolean isShowPercentage;
    // font size
    private float textSize;

    // The size of the circle is the minimum size by default
    private int level = XXXXXX;
    // Level animation offset
    private float levelOffset=0;
    // XX = control / 2 = radius of circle
    public static final int XX = 2;
    // XXX = control / 3 = radius of circle
    public static final int XXX = 3;
    // XXXX = control / 4 = radius of circle
    public static final int XXXX = 4;
    // XXXXX = control / 5 = radius of circle
    public static final int XXXXX = 5;
    // XXXXXX = control / 6 = radius of circle
    public static final int XXXXXX = 6;
    // Modify the width of the brush and the thickness of the progress bar
    private float strokeWidth=10;

    public void setStrokeWidth(float strokeWidth) {
        this.strokeWidth = strokeWidth;
    }

    private boolean isStarAnimation;

    public boolean isStarAnimation() {
        return isStarAnimation;
    }

    public void setStarAnimation(boolean starAnimation) {
        isStarAnimation = starAnimation;
    }

    public void setLevel(int level) {
        this.level = level;
    }


    public void setProgressTxt(String progressTxt) {
        this.progressTxt = progressTxt;
    }

    public int getLevel() {
        return level;
    }

    public void setTextSize(int textSize) {
        this.textSize = textSize;
    }

    public void setShowPercentage(boolean percentage) {
        this.isShowPercentage = percentage;
    }



    public void setTextColor(String textColor) {
        this.textColor = textColor;
    }

    public void setShadowSize(float shadowSize) {
        this.shadowSize = shadowSize;
    }

    public void setShadowColor(String shadowColor) {
        this.shadowColor = shadowColor;
    }

    public void setBg_color(String bg_color) {
        this.bg_color = bg_color;
    }

    public void setProgressColor(String progressColor) {
        // Turn off hardware acceleration
        setLayerType(LAYER_TYPE_SOFTWARE, null);
        this.progressColor = progressColor;
    }

    public void setGap(float gap) {
        this.gap = gap;
        setGapPosition(this.gap);
    }


    /**
     *  Toggles the size of the circle
     * @param level
     */
    public void changeLevel(int level){
        this.setLevel(level);
        // Do not animate when switching
        this.setStarAnimation(false);

        // Refresh Ui
        invalidate();
    }


    /**
     *  Calculate notch position
     * @param gap
     */
    private void setGapPosition(float gap){
        startPosition = 90.0f + (gap/2);
        endPosition = 360 - gap;
    }

    /**
     *  Set the offset for each.. The smaller the animation, the slower the execution, and the larger the animation, the faster the execution
     * @param offset
     */
    public void setOffset(float offset) {
        this.offset = offset;
    }


    /**
     * Set progress
     *
     * @param progress Value: 0-100
     */
    public void setProgress(final float progress) {
        // Calculate current value
        this.progress = endPosition / 100 * progress;
        //Refresh view
        invalidate();
    }


    /**
     * Animated execution
     *
     * @param progress Progress bar position to be reached
     */
    public void animationSetProgress(float progress) {
        // Convert to values that can be recognized by view
        float jd = progress * (endPosition / 100);
        if(jd == 0){
            setProgress(jd);
        }else{
            animationStart(jd, this.offset,progress);
        }
    }


    /**
     * @param progress Progress bar position to be reached
     */
    public void animationSetProgress(float progress,boolean isStarAnimation) {
        this.setStarAnimation(isStarAnimation);
        this.animationSetProgress(progress);
    }


    /**
     *  Change text
     * @param offset
     */
    public void setTextCenter(float progress,float offset,float thasProgress){
        if(!isShowPercentage)return;
        //Log.i("TAG",isShowPercentage+"");
        float b =  (offset / progress * thasProgress);
        DecimalFormat df = new DecimalFormat("#.00");
        String t = df.format(b);
        progressTxt = t+"%";
    }

    /**
     * @param progress Overall progress
     * @param offset   Offset the angle of each offset
     */
    private void animationStart(final float progress, float offset,float thasProgress) {
        // Get percentage
        float tPro =  (offset / progress * thasProgress);
        if(offset == progress){
            this.progress = offset;
            setTextCenter(progress,offset,thasProgress);
            invalidate();
            isStarAnimation = false;
            if(onChangeListener!=null)onChangeListener.onProgress(tPro);
            return;
        }

        // Conversion percentage.
        if(offset >= progress){
            // modify parameters
            offset = progress;
            this.progress = offset;
            setTextCenter(progress,offset,thasProgress);
        }else{
            this.progress = offset;
            setTextCenter(progress,offset,thasProgress);
           //("GJB","offset" + offset +"progress =" +progress);
            offset+=this.offset;
        }
        //
        if(onChangeListener!=null)onChangeListener.onProgress(tPro);
        invalidate();
        Message message = new Message();
        float[] sets = new float[]{progress,offset,thasProgress};
        message.obj = sets;
        handler.sendMessage(message);
    }

   Handler handler = new Handler(new Handler.Callback() {
       @Override
       public boolean handleMessage(Message msg) {
           float set[] = (float[]) msg.obj;
           animationStart(set[0],set[1],set[2]);
           return false;
       }
   });



    public boolean isAdd = true;

    /**
     *  Calculate offset / control circle size
     */
    public void reconOffset(){
        if(isStarAnimation){
            if(levelOffset <= 0f || isAdd){
                // increase
                isAdd = true;
                levelOffset +=0.02f;
                if(levelOffset>=0.3)isAdd=false;
            }else if(levelOffset>=0f && !isAdd){
                //reduce
                isAdd = false;
                levelOffset -=0.02f;
            }
        }else{
        //No animation required
            levelOffset = 0;
        }
    }



    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        float l = Float.valueOf(level);
        // Calculate the offset size of the radius
        this.reconOffset();
        // Calculate the radius of the circle
        float r = getMeasuredWidth() / (l+levelOffset)-5; //
      //  Log.i("GJB","r = "+r + " levelOffset" + levelOffset);
        // Middle position of x coordinate
        int c_x = getLeft() + (getMeasuredWidth() / 2);
        // Middle position of y coordinate
        int c_y = getTop() + (getMeasuredHeight() / 2);

        // Create brush
        Paint paint = new Paint();
        // Brush color-
        paint.setColor(Color.parseColor(bg_color));
        // **When the drawing style is, setStrokeJoin is used to specify the line connection corner style: equivalent to fillet
        //paint.setStrokeJoin(Paint.Join.ROUND);
        // The shape of the brush paint Cap. Round, cap Square party
        paint.setStrokeCap(Paint.Cap.ROUND);
        //Brush style. Paint.Style.STROKE is a line. Paint.Style.FILL starts from the starting point. Until the end, a fan-shaped drawing area is formed. Paint.Style.FILL_AND_STROKE is the sector plus a circle
        paint.setStyle(Paint.Style.STROKE); // hollow
        paint.setStrokeWidth(strokeWidth);
        paint.setAntiAlias(true); // Set the value of sawtooth effect to true

        // Draw a red circle in the middle of the hollow
        float left = c_x - r;
        float top = c_y - r;
        float right = c_x + r;
        float bottom = c_y + r;
        // RectF - left is calculated from the left boundary to the leftmost position of the sector,
        // RectF -right is calculated from the left boundary to the rightmost position of the sector
        // RectF -top is calculated from the top boundary to the uppermost edge of the sector
        // RectF -bottom is calculated from the top boundary to the lowest edge of the sector
        RectF rect = new RectF(left, top, right, bottom);
        canvas.drawArc(rect, startPosition, endPosition, false, paint);
        canvas.save(); // Save it.
        //
        paint.setColor(Color.parseColor(progressColor));
        //paint.setDither(true);
        if(!TextUtils.isEmpty(shadowColor))
        paint.setShadowLayer(shadowSize,1,1,Color.parseColor(shadowColor));
        canvas.drawArc(rect, startPosition, progress, false, paint);
        canvas.save(); // Save it.


        if(!TextUtils.isEmpty(progressTxt)){
            // Draw a text at the center of the circle.
            Paint textPaint = new Paint();
            // Brush color

            textPaint.setColor(Color.parseColor(textColor));
            textPaint.setAntiAlias(true); // Set the value of sawtooth effect to true
            // The font size of the calculated textView changes with the layout
            float thasSize = 0;
            if(textSize == 0){
                thasSize = r / 6;
            }else{
                thasSize = textSize;
            }
           // Log.i("TAG",progressTxt+" progressTxt");
            textPaint.setTextSize(sp2px(thasSize));

            // Bold font
            textPaint.setFakeBoldText(true);
            // Measure the width of the text
            float textSize = textPaint.measureText(progressTxt); // Measure text size
            float center = c_x - (textSize/2);
            /**
             * Where y represents the coordinates of the baseline of the text. In other words, it is the kind of book with horizontal lines used for writing in our primary school (it is usually written according to a baseline, isn't it?),
             * It is used to standardize whether your words are in a straight line, otherwise many people will float upward. The x coordinate is the starting horizontal coordinate of text drawing, but there is a certain gap on both sides of each text itself,
             * Therefore, the position of the actual text will be more to the right than the position of x.
             */
            canvas.drawText(progressTxt,center,c_y,textPaint);
            canvas.save();
        }
    }


    private int sp2px(float spValue) {
        return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, spValue, getResources().getDisplayMetrics());
    }


    private OnChangeListener onChangeListener;

    public void setOnChangeListener(OnChangeListener onChangeListener) {
        this.onChangeListener = onChangeListener;
    }

    public interface OnChangeListener{
        /**
         *  Current progress callback
         * @param progress
         */
        void onProgress(float progress);
    }
}

The complete code of MainActivity is as follows (usage method)

package com.gongjiebin.drawtest;

import androidx.appcompat.app.AppCompatActivity;

import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;

public class MainActivity extends AppCompatActivity {

    private JJumpProgress jumpProgress;

    private Button btn_pull;

    private Button btn_level;


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        ViewGroup rootView = (ViewGroup) LayoutInflater.from(getBaseContext()).inflate(R.layout.activity_main, null);
        setContentView(rootView);

        btn_pull = rootView.findViewById(R.id.btn_pull);
        btn_level = rootView.findViewById(R.id.btn_level);


        jumpProgress = rootView.findViewById(R.id.dv_jd);
        jumpProgress.setGap(30f);// Set the size of the notch.
        jumpProgress.setOffset(5f); // The higher the value, the faster the animation effect
        jumpProgress.setBg_color("#E9E9E9"); 
        jumpProgress.setProgressColor("#FFDF2C32");
        jumpProgress.setShadowColor("#FFDF2C32");
        jumpProgress.setShadowSize(8);
        jumpProgress.setShowPercentage(true);
//        dv_jd.setTextSize(8);
        jumpProgress.setTextColor("#FFDF2C32");
//        dv_jd.setStrokeWidth(20);
        btn_pull.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                jumpProgress.animationSetProgress(100f, true);
            }
        });

        btn_level.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                int level = jumpProgress.getLevel();
                if (level == JJumpProgress.XX) {
                    level = JJumpProgress.XXX;
                } else if (jumpProgress.getLevel() == JJumpProgress.XXX) {
                    level = JJumpProgress.XXXX;
                } else if (jumpProgress.getLevel() == JJumpProgress.XXXX) {
                    level = JJumpProgress.XXXXX;
                } else if (jumpProgress.getLevel() == JJumpProgress.XXXXX) {
                    level = JJumpProgress.XXXXXX;
                } else if (jumpProgress.getLevel() == JJumpProgress.XXXXXX) {
                    level = 10;
                } else if (jumpProgress.getLevel() == 10) {
                    level = 15;
                } else if (jumpProgress.getLevel() == 15) {
                    level = JJumpProgress.XX;
                }

                // Toggles the size of the progress bar circle
                jumpProgress.changeLevel(level);
            }
        });

        initListener();
    }





    public void initListener() {
        jumpProgress.setOnChangeListener(new JJumpProgress.OnChangeListener() {
            @Override
            public void onProgress(float progress) {
                // The text you want to display can be displayed here. If you have enabled the default opening percentage, please close it first. setShowPercentage(false)
//                float price = progress / 100 * countPrice;
//                Log.i("TAG", "text=" + price + "progress =" + progress);
//                DecimalFormat df = new DecimalFormat("#.0");
//                String t = df.format(price);
//                dv_jd.setProgressTxt("¥" + t);
            }
        });
    }

    @Override
    protected void onResume() {
        super.onResume();
        // 90% complete.
        jumpProgress.animationSetProgress(90f, true);
    }


    @Override
    protected void onPause() {
        super.onPause();
    }
}


Use in XML:

  <com.gongjiebin.drawtest.JJumpProgress
        android:id="@+id/dv_jd"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
/>

It should be noted that when using, you can look at the comments in the code and write them clearly. For example, for some parameter settings, just find the corresponding set method.

 //speed of progress
    private float progress = 0f;
    // Notch end position
    private float endPosition;
    // Notch start position
    private float startPosition;
    // Progress text
    private String progressTxt = "";
    // Offset
    private float offset = 5f;
    // Gap. Set the size of the circular notch, 30 degrees by default
    private float gap = 30;
    // Color #000000 of progress bar - hexadecimal representation
    private String progressColor;
    // Color #000000 of background circle - hexadecimal representation
    private String bg_color;
    // Shadow color #000000 - hexadecimal representation
    private String shadowColor;
    // The size of the shadow
    private float shadowSize = 5;
    // Font color -- the default is black.
    private String textColor = "#000000";
    // Default display percentage / false do not display
    private boolean isShowPercentage;
    // font size
    private float textSize;
    // The size of the circle is the minimum size by default
    private int level = XXXXXX;
    // Modify the width of the brush and the thickness of the progress bar
    private float strokeWidth=10;

The text in the middle is displayed as a percentage by default. If you want to display other text, you need to set it as follows.

 jumpProgress.setShowPercentage(false);

Monitoring method for dynamically changing text

jumpProgress.setOnChangeListener(new JJumpProgress.OnChangeListener() {
            @Override
            public void onProgress(float progress) {
                // The text you want to display can be displayed here. If you have enabled the default opening percentage, please close it first. setShowPercentage(false)
                float price = progress / 100 * countPrice;
                Log.i("TAG", "text=" + price + "progress =" + progress);
                DecimalFormat df = new DecimalFormat("#.0");
                String t = df.format(price);
                jumpProgress.setProgressTxt("¥" + t);
            }
        });

explain

For the setting of textSize, if this parameter is set, the font animation effect will disappear. If not set, it will change according to the size of the progress bar. The animation effect of this view is suitable for the situation where the percentage is known (similar to viewing today's steps and displaying Statistics). When the progress bar that needs to update data in real time is not suitable for this animation, or the effect is not very good, but we can call setProgress to complete the update, but there will be no heartbeat animation.

   /**
     * Set progress
     *
     * @param progress Value: 0-100
     */
    public void setProgress(final float progress) {
        // Calculate current value
        this.progress = endPosition / 100 * progress;
        //Refresh view
        invalidate();
    }

Keywords: Android android-studio

Added by Deviants on Mon, 03 Jan 2022 20:35:11 +0200