• Skip to primary navigation
  • Skip to main content
  • Skip to primary sidebar

陈文管的博客

分享有价值的内容

  • Android
  • Affiliate
  • SEO
  • 前后端
  • 网站建设
  • 自动化
  • 开发资源
  • 关于

Android 自定义棱形样式进度条控件

2024年7月12日发布 | 最近更新于 2024年8月5日

业务上需要实现自定义进度条的样式,且样式不是常规的形状,需要自定义绘制实现,先来看下效果

Android自定义菱形进度条

一、实现效果说明

进度条背景和进度由自定义Path区域去绘制实现,Path区域菱形倾斜X轴方向的偏移量由直角三角形的方式去计算。

1. 渐变滑块实现问题

因为滑块是菱形渐变色,处理起来稍微麻烦点,从实际的实现测试来看,不能用图片,渐变滑块图片配置上去之后,中间部分有一条明显的竖线无法去除。

之后用LinearGradient去实现渐变色,但是LinearGradient的渐变色是在固定区域的,比如配置了X轴0到25的渐变色,滑块滑出的范围超过25之后显示就有异常,之后使用折衷的方式,把整个进度条拆分成50份渐变色区域,滑动的时候根据当前的进度获取对应的渐变LinearGradient,这种实现方式需要缓存50份LinearGradient对象,浪费资源。

最终是使用canvas.translate的方式,只需要创建一个LinearGradient对象,通过移动画布的方式来实现渐变滑块的绘制。

2. 滑动进度不跟随鼠标点击位置问题

当进度条最大值比较小时,问题会比较明显,如进度条最大值设置为30,那么每一个进度值就对应一小段进度区间,鼠标点击的时候,获取当前的进度值,绘制的进度可能是在这一小段进度区间的起始位置,而实际鼠标点击的位置可能在这一小段偏后部分,看起来就是当前显示的进度没有跟随鼠标的点击位置。

这边使用的容错方式是监听onTouchEvent,获取当前鼠标的点击位置来校正进度绘制的参数值。

二、具体代码实现

这边的控件实现是继承AppCompatSeekBar的方式去实现的,你可以继承其他的进度条控件,因为这边主要是自定义进度条的样式绘制,重写了onDraw方法,不影响继承的进度条控件原有的接口逻辑。

在集成使用的时候,在资源文件里面自己加下prismatic_seekbar_thumb_min_width(滑块最小宽度)、prismatic_seekbar_background(默认进度条背景色)、prismatic_seekbar_progress(默认进度条颜色)的参数值。另外也自己定义下PrismaticSeekBar的style样式,加下seekBarType的参数类型值,现在实现的提供normal和colorful两种自定义样式。normal为0,colorful为1。

在XML布局中配置的时候,需要指定下width和height的具体大小,不能设置为wrap_content,因为这边没有实现控件宽高的最小值显示,可以自己优化下处理逻辑。

1. Java实现

import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.LinearGradient;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Shader;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

public class PrismaticSeekBar extends androidx.appcompat.widget.AppCompatSeekBar {
    public static final int TYPE_NORMAL = 0;
    public static final int TYPE_COLORFUL = 1;
    private Paint mPaint;
    private Path mBackgroundPath = null;
    private Path mProgressPath = null;
    private Path mThumbPath = null;
    private LinearGradient mProgressGradient;
    private LinearGradient mThumbGradientCache;
    private float mAreaOffsetX = 0, mAreaOffsetHalfX = 0;

    private final int THUMB_WIDTH = getContext().getResources().getDimensionPixelSize(R.dimen.prismatic_seekbar_thumb_min_width);

    private int COLOR_BACKGROUND_NORMAL = getContext().getResources().getColor(R.color.prismatic_seekbar_background);
    private int COLOR_PROGRESS_NORMAL = getContext().getResources().getColor(R.color.prismatic_seekbar_progress);

    private int mSeekBarType = 0;

    private float mCurrentProgressPos = 0f;

    private float mTouchPos = 0f;

    private boolean mIsClick = false;
    private boolean mIsTouch = false;

    private float mDownX = 0;
    private float mDownY = 0;
    private float mTempX = 0;
    private float mTempY = 0;
    private static final int MAX_DISTANCE_FOR_CLICK = 5;

    public PrismaticSeekBar(Context context) {
        this(context, null);
    }

    public PrismaticSeekBar(Context context, AttributeSet attrs) {
        this(context, attrs, -1);
    }

    public PrismaticSeekBar(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs);
    }

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

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        mOnTouchListener.onTouch(this, event);
        return super.onTouchEvent(event);
    }

    private OnTouchListener mOnTouchListener = new OnTouchListener() {

        @Override
        public boolean onTouch(View view, MotionEvent event) {
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    mIsTouch = true;
                    mDownX = event.getX();
                    mDownY = event.getY();
                    trackTouchEvent(event, true);
                    break;
                case MotionEvent.ACTION_MOVE:
                    mIsTouch = true;
                    mTempX = event.getX();
                    mTempY = event.getY();
                    trackTouchEvent(event, true);
                    break;
                case MotionEvent.ACTION_UP:
                    mTempX = (int) event.getX();
                    mTempY = (int) event.getY();
                    if (Math.abs(mTempX - mDownX) < MAX_DISTANCE_FOR_CLICK
                            && Math.abs(mTempY - mDownY) < MAX_DISTANCE_FOR_CLICK) {
                        onClick(event);
                    }
                    trackTouchEvent(event, false);
                    mIsTouch = false;
                    break;
                case MotionEvent.ACTION_CANCEL:
                    mIsTouch = false;
                    trackTouchEvent(event, false);
                    break;
            }
            return true;
        }
    };

    private void onClick(MotionEvent event) {
        mIsClick = true;
        mTouchPos= event.getX();
        invalidate();
    }

    private void trackTouchEvent(MotionEvent event, boolean mIsTouch) {
        if (!mIsTouch) {
            mCurrentProgressPos = mTouchPos;
        }
        mTouchPos = event.getX();
        invalidate();
    }

    private void initPath() {
        int width = getMeasuredWidth();
        int height = getMeasuredHeight();
        int halfHeight = height / 2;
        mAreaOffsetX = (int) (height / Math.sqrt(3));
        mAreaOffsetHalfX = (int) (height / (2 * Math.sqrt(3)));

        mBackgroundPath = new Path();
        mBackgroundPath.moveTo(0, height);
        mBackgroundPath.lineTo(mAreaOffsetHalfX, halfHeight);
        mBackgroundPath.lineTo(width - mAreaOffsetHalfX, halfHeight);
        mBackgroundPath.lineTo(width - mAreaOffsetX, height);
        mBackgroundPath.close();
        // 设置绘制为填充效果
        mBackgroundPath.setFillType(Path.FillType.WINDING);

        mProgressPath = new Path();
        mThumbPath = new Path();
        mCurrentProgressPos = getMeasuredWidth() * getProgress() / getMax();
    }

    private void init(Context context, AttributeSet attrs) {
        TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.PrismaticSeekBar);
        if (a != null) {
            try {
                mSeekBarType = a.getInt(R.styleable.PrismaticSeekBar_seekBarType, 0);
            } catch (UnsupportedOperationException | Resources.NotFoundException e) {
                e.printStackTrace();
            } finally {
                a.recycle();
            }
        }

        mPaint = new Paint();
        mPaint.setAntiAlias(true);
    }

    @Override
    public synchronized void setProgress(int progress) {
        super.setProgress(progress);
        mCurrentProgressPos = getMeasuredWidth() * progress / getMax();
        if (mIsClick) {
            mIsClick = false;
            mCurrentProgressPos = mTouchPos;
        }
        invalidate();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // Draw background
        mPaint.reset();
        mPaint.setAntiAlias(true);
        mPaint.setColor(COLOR_BACKGROUND_NORMAL);
        canvas.drawPath(mBackgroundPath, mPaint);
        // Draw progress
        float progressRight = 0f;
        if (mIsTouch) {
            progressRight = mTouchPos;
            mCurrentProgressPos = mTouchPos;
        } else {
            progressRight = mCurrentProgressPos;
        }
        drawProgress(canvas, progressRight, getMeasuredHeight(), mPaint);

        // Draw thumb
        int position = getProgress() * 50 / getMax();
        float progressRatio = getProgress() / (float) getMax();
        int gradientRight = (int) (getMeasuredWidth() * progressRatio);
        drawThumb(canvas, progressRight, getMeasuredHeight(), mPaint, position, gradientRight);
    }

    private void drawProgress(Canvas canvas, float currentPosition, int height, Paint paint) {
        if (currentPosition > mAreaOffsetX) {
            paint.reset();
            paint.setAntiAlias(true);
            if (mSeekBarType == TYPE_NORMAL) {
                paint.setColor(COLOR_PROGRESS_NORMAL);
            } else if (mSeekBarType == TYPE_COLORFUL) {
                if (mProgressGradient == null) {
                    mProgressGradient = new LinearGradient(0, 0, getWidth(), 0,
                            new int[]{0xFF67A7F6, 0xFFFEAF6F}, null, Shader.TileMode.CLAMP);
                }
                paint.setShader(mProgressGradient);
            }

            mProgressPath.reset();
            float rightPosX = currentPosition > getMeasuredWidth() ? getMeasuredWidth() : currentPosition;
            float leftPosX = (rightPosX - mAreaOffsetX) > 0 ? (rightPosX - mAreaOffsetX) : 0;
            mProgressPath.moveTo(0, height);
            mProgressPath.lineTo(mAreaOffsetX, 0);
            mProgressPath.lineTo(rightPosX, 0);
            mProgressPath.lineTo(leftPosX, height);
            mProgressPath.close();
            canvas.drawPath(mProgressPath, paint);
        }
    }

    /**
     * 使用画布translate的方式移动之后绘制渐变色滑块
     * 使用PNG图片存在渐变色中间部分有明显数显分隔显示的问题,不能用资源图片
     *
     * @param canvas
     * @param currentPosition
     * @param height
     * @param paint
     * @param position
     */
    private void drawThumb(Canvas canvas, float currentPosition, int height, Paint paint, int position, int gradientRight) {
        if (currentPosition > mAreaOffsetX) {
            paint.reset();
            paint.setAntiAlias(true);

            if (mThumbGradientCache == null) {
                mThumbGradientCache = new LinearGradient(0, 0, THUMB_WIDTH, 0,
                        new int[]{0x00FFFFFF, 0xFFFFFFFF}, null, Shader.TileMode.CLAMP);
            }
            paint.setShader(mThumbGradientCache);

            mThumbPath.reset();
            float rightPosX = currentPosition > getMeasuredWidth() ? getMeasuredWidth() : currentPosition;

            if (rightPosX > THUMB_WIDTH) {
                canvas.save();
                canvas.translate(rightPosX - THUMB_WIDTH, 0);
                mThumbPath.moveTo(THUMB_WIDTH, 0);
                mThumbPath.lineTo(THUMB_WIDTH - mAreaOffsetX, height);
                mThumbPath.lineTo(0, height);
                mThumbPath.lineTo(mAreaOffsetX, 0);
                mThumbPath.close();
                canvas.drawPath(mThumbPath, paint);
                canvas.restore();
            } else {
                mThumbPath.moveTo(rightPosX, 0);
                mThumbPath.lineTo(rightPosX - mAreaOffsetX, height);
                mThumbPath.lineTo(0, height);
                mThumbPath.lineTo(mAreaOffsetX, 0);
                mThumbPath.close();
                canvas.drawPath(mThumbPath, paint);
            }
        }
    }
}

2. Kotlin实现

import android.content.Context
import android.content.res.Resources
import android.content.res.TypedArray
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import androidx.appcompat.widget.AppCompatSeekBar

class PrismaticSeekBar @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = -1
) : AppCompatSeekBar(context, attrs, defStyleAttr) {

    companion object {
        const val TYPE_NORMAL = 0
        const val TYPE_COLORFUL = 1
        const val MAX_DISTANCE_FOR_CLICK = 5
    }

    private val mPaint: Paint = Paint().apply { isAntiAlias = true }
    private var mBackgroundPath: Path? = null
    private var mProgressPath: Path? = null
    private var mThumbPath: Path? = null
    private var mProgressGradient: LinearGradient? = null
    private var mThumbGradientCache: LinearGradient? = null
    private var mAreaOffsetX = 0f
    private var mAreaOffsetHalfX = 0f

    private val THUMB_WIDTH = context.resources.getDimensionPixelSize(R.dimen.prismatic_seekbar_thumb_min_width)
    private val COLOR_BACKGROUND_NORMAL = context.resources.getColor(R.color.prismatic_seekbar_background)
    private val COLOR_PROGRESS_NORMAL = context.resources.getColor(R.color.prismatic_seekbar_progress)

    private var mSeekBarType = 0
    private var mCurrentProgressPos = 0f
    private var mTouchPos = 0f
    private var mIsClick = false
    private var mIsTouch = false
    private var mDownX = 0f
    private var mDownY = 0f
    private var mTempX = 0f
    private var mTempY = 0f

    init {
        init(context, attrs)
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        initPath()
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        mOnTouchListener.onTouch(this, event)
        return super.onTouchEvent(event)
    }

    private val mOnTouchListener = OnTouchListener { view, event ->
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                mIsTouch = true
                mDownX = event.x
                mDownY = event.y
                trackTouchEvent(event, true)
            }
            MotionEvent.ACTION_MOVE -> {
                mIsTouch = true
                mTempX = event.x
                mTempY = event.y
                trackTouchEvent(event, true)
            }
            MotionEvent.ACTION_UP -> {
                mTempX = event.x
                mTempY = event.y
                if (Math.abs(mTempX - mDownX) < MAX_DISTANCE_FOR_CLICK &&
                    Math.abs(mTempY - mDownY) < MAX_DISTANCE_FOR_CLICK) {
                    onClick(event)
                }
                trackTouchEvent(event, false)
                mIsTouch = false
            }
            MotionEvent.ACTION_CANCEL -> {
                mIsTouch = false
                trackTouchEvent(event, false)
            }
        }
        true
    }

    private fun onClick(event: MotionEvent) {
        mIsClick = true
        mTouchPos = event.x
        invalidate()
    }

    private fun trackTouchEvent(event: MotionEvent, mIsTouch: Boolean) {
        if (!mIsTouch) {
            mCurrentProgressPos = mTouchPos
        }
        mTouchPos = event.x
        invalidate()
    }

    private fun initPath() {
        val width = measuredWidth
        val height = measuredHeight
        val halfHeight = height / 2
        mAreaOffsetX = (height / Math.sqrt(3.0)).toFloat()
        mAreaOffsetHalfX = (height / (2 * Math.sqrt(3.0))).toFloat()

        mBackgroundPath = Path().apply {
            moveTo(0f, height.toFloat())
            lineTo(mAreaOffsetHalfX, halfHeight.toFloat())
            lineTo((width - mAreaOffsetHalfX), halfHeight.toFloat())
            lineTo((width - mAreaOffsetX), height.toFloat())
            close()
            fillType = Path.FillType.WINDING
        }

        mProgressPath = Path()
        mThumbPath = Path()
        mCurrentProgressPos = measuredWidth * progress / max.toFloat()
    }

    private fun init(context: Context, attrs: AttributeSet?) {
        val a: TypedArray = context.obtainStyledAttributes(attrs, R.styleable.PrismaticSeekBar)
        a?.run {
            try {
                mSeekBarType = getInt(R.styleable.PrismaticSeekBar_seekBarType, 0)
            } catch (e: UnsupportedOperationException) {
                e.printStackTrace()
            } catch (e: Resources.NotFoundException) {
                e.printStackTrace()
            } finally {
                recycle()
            }
        }
    }

    override fun setProgress(progress: Int) {
        super.setProgress(progress)
        mCurrentProgressPos = measuredWidth * progress / max.toFloat()
        if (mIsClick) {
            mIsClick = false
            mCurrentProgressPos = mTouchPos
        }
        invalidate()
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // Draw background
        mPaint.reset()
        mPaint.isAntiAlias = true
        mPaint.color = COLOR_BACKGROUND_NORMAL
        canvas.drawPath(mBackgroundPath!!, mPaint)
        // Draw progress
        val progressRight: Float = if (mIsTouch) {
            mTouchPos
            mCurrentProgressPos = mTouchPos
        } else {
            mCurrentProgressPos
        }
        drawProgress(canvas, progressRight, measuredHeight, mPaint)

        // Draw thumb
        val position = progress * 50 / max
        val progressRatio = progress / max.toFloat()
        val gradientRight = (measuredWidth * progressRatio).toInt()
        drawThumb(canvas, progressRight, measuredHeight, mPaint, position, gradientRight)
    }

    private fun drawProgress(canvas: Canvas, currentPosition: Float, height: Int, paint: Paint) {
        if (currentPosition > mAreaOffsetX) {
            paint.reset()
            paint.isAntiAlias = true
            paint.color = if (mSeekBarType == TYPE_NORMAL) COLOR_PROGRESS_NORMAL else {
                if (mProgressGradient == null) {
                    mProgressGradient = LinearGradient(0f, 0f, width.toFloat(), 0f,
                        intArrayOf(0xFF67A7F6.toInt(), 0xFFFEAF6F.toInt()), null, Shader.TileMode.CLAMP)
                }
                paint.shader = mProgressGradient
                paint.color
            }

            mProgressPath!!.reset()
            val rightPosX = if (currentPosition > measuredWidth) measuredWidth.toFloat() else currentPosition
            val leftPosX = if ((rightPosX - mAreaOffsetX) > 0) (rightPosX - mAreaOffsetX) else 0f
            mProgressPath!!.moveTo(0f, height.toFloat())
            mProgressPath!!.lineTo(mAreaOffsetX, 0f)
            mProgressPath!!.lineTo(rightPosX, 0f)
            mProgressPath!!.lineTo(leftPosX, height.toFloat())
            mProgressPath!!.close()
            canvas.drawPath(mProgressPath!!, paint)
        }
    }

    private fun drawThumb(canvas: Canvas, currentPosition: Float, height: Int, paint: Paint, position: Int, gradientRight: Int) {
        if (currentPosition > mAreaOffsetX) {
            paint.reset()
            paint.isAntiAlias = true

            if (mThumbGradientCache == null) {
                mThumbGradientCache = LinearGradient(0f, 0f, THUMB_WIDTH.toFloat(), 0f,
                    intArrayOf(0x00FFFFFF, 0xFFFFFFFF.toInt()), null, Shader.TileMode.CLAMP)
            }
            paint.shader = mThumbGradientCache

            mThumbPath!!.reset()
            val rightPosX = if (currentPosition > measuredWidth) measuredWidth.toFloat() else currentPosition

            if (rightPosX > THUMB_WIDTH) {
                canvas.save()
                canvas.translate(rightPosX - THUMB_WIDTH, 0f)
                mThumbPath!!.moveTo(THUMB_WIDTH.toFloat(), 0f)
                mThumbPath!!.lineTo(THUMB_WIDTH - mAreaOffsetX, height.toFloat())
                mThumbPath!!.lineTo(0f, height.toFloat())
                mThumbPath!!.lineTo(mAreaOffsetX, 0f)
                mThumbPath!!.close()
                canvas.drawPath(mThumbPath!!, paint)
                canvas.restore()
            } else {
                mThumbPath!!.moveTo(rightPosX, 0f)
                mThumbPath!!.lineTo(rightPosX - mAreaOffsetX, height.toFloat())
                mThumbPath!!.lineTo(0f, height.toFloat())
                mThumbPath!!.lineTo(mAreaOffsetX, 0f)
                mThumbPath!!.close()
                canvas.drawPath(mThumbPath!!, paint)
            }
        }
    }
}

扩展阅读:

  • Android 弧形 RecyclerView 实现(Kotlin)
  • 美图手机音乐Widget动画实现
  • Android 心率动画自定义控件实现
  • Android 卡片旋转切换动效实现详解
  • Android 残影数字动画实现详解
  • Android 自定义菱形横向滑动指示器控件

博客公众号

微信公众号

转载请注明出处:陈文管的博客 – Android 自定义棱形样式进度条控件

文章目录

  • 一、实现效果说明
    • 1. 渐变滑块实现问题
    • 2. 滑动进度不跟随鼠标点击位置问题
  • 二、具体代码实现
    • 1. Java实现
    • 2. Kotlin实现
博客公众号

闽ICP备18001825号-1 · Copyright © 2025 · Powered by chenwenguan.com