可滑动并查看详情与设置最小值和最大值的ChartView

效果图

先上效果图:

分为三部分:

  1. 最上面的日,周,月,年可设置图表对应的横坐标的单位。如图所示是月的情况,一共有31天。
  2. 中间的显示选择的日期。
  3. 最下面为对应的图表内容。每段显示最小值到最大值,并且标注出了这段时间的最大值与最小值。支持滑动和点击对应的条目显示出具体时间和当时的最大值与最小值。

布局

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.leo.android.wheelview.MainActivity">

    <LinearLayout
        android:id="@+id/time_selected"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="horizontal">
        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="日"
            android:background="@drawable/time_background"/>
        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="周"
            android:background="@drawable/time_background"/>
        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="月"
            android:background="@drawable/time_background"/>
        <Button
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="年"
            android:background="@drawable/time_background"/>
    </LinearLayout>

    <RelativeLayout
        android:id="@+id/time_move"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@+id/time_selected">
        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentLeft="true"
            android:background="@mipmap/ic_launcher"
            android:visibility="gone"/>
        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="2018年6月27日"
            android:layout_centerInParent="true"
            android:padding="20dp"/>
        <ImageView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentRight="true"
            android:background="@mipmap/ic_launcher"
            android:visibility="gone"/>
    </RelativeLayout>

    <com.leo.android.wheelview.ChartView
        android:id="@+id/chartview"
        android:layout_width="match_parent"
        android:layout_height="400dp"
        android:layout_below="@+id/time_selected"/>

</RelativeLayout>
复制代码

最上层是 LinearLayout 包裹的时间段的四个按钮。中间是 RelativeLayout 包裹的具体时间(左右可以包含往左和往右的点击按钮,这边省略)。最下面是具体的图表。

自定义 ChartView

初始化

类继承结构和构造方法:

public class ChartView extends View {
    public ChartView(Context context) {
        this(context, null);
    }

    public ChartView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        if (isInEditMode()) {
            return;
        }

        ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
        //获取touchSlop。该值表示系统所能识别出的被认为是滑动的最小距离
        mTouchSlop = viewConfiguration.getScaledTouchSlop();
        //获取Fling速度的最小值和最大值
        mMaximumFlingVelocity = viewConfiguration.getScaledMaximumFlingVelocity();
        mMinimumFlingVelocity = viewConfiguration.getScaledMinimumFlingVelocity();
        //OverScroller类是为了实现View平滑滚动的一个Helper类。
        mScroller = new OverScroller(context);

        init();//初始化画笔与数据
    }
    
    private void init() {
        //绘制背景的画笔
        mBgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mBgPaint.setStrokeWidth(mBgPaintWidth);
        mBgPaint.setColor(mBgColor);

        //绘制纵坐标数值的画笔
        mHCoordinatePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mHCoordinatePaint.setTextSize(mAbscissaMsgSize);
        mHCoordinatePaint.setTextAlign(Paint.Align.CENTER);
        mHCoordinatePaint.setColor(mHCoordinateNumColor);

        //绘制横坐标数值的画笔
        mVCoordinatePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mVCoordinatePaint.setTextSize(mAbscissaMsgSize);
        mVCoordinatePaint.setTextAlign(Paint.Align.CENTER);
        mVCoordinatePaint.setColor(mVCoordinateNumColor);

        //绘制健康数据的画笔
        mHealthPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mHealthPaint.setColor(mHealthColor);
        mHealthPaint.setStyle(Paint.Style.FILL_AND_STROKE);
        mHealthPaint.setStrokeWidth(mHealthDataBarWidth);
        mHealthPaint.setStrokeCap(Paint.Cap.ROUND);

        //绘制健康数据最值的画笔
        mHealthNumPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mHealthNumPaint.setColor(mHealthColor);
        mHealthNumPaint.setTextSize(mAbscissaMsgSize);
        mHealthNumPaint.setTextAlign(Paint.Align.CENTER);

        //绘制详情数据背景的画笔
        mSelectedPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mSelectedPaint.setColor(mHealthColor);

        //绘制详情数据的画笔
        mSelectedDetailPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mSelectedDetailPaint.setColor(Color.WHITE);
        mSelectedDetailPaint.setTextSize(mAbscissaMsgSize);

        Random random = new Random();
        for (int i = 0; i < 31; i++) {
            mHealthDatas.add(new HealthData(100 + random.nextInt(60), 60 + random.nextInt(40), (i + 1) + "日", "2018年"));
        }
        setHealthDatas(mHealthDatas);
    }
}
复制代码

这里将数据封装成了 HealthData 数据结构:

private static class HealthData {
    private float upper;
    private float lower;
    private String time;
    private String time2;
    private RectF mRect = new RectF();

    public HealthData(float upper, float lower, String time, String time2) {
        this.upper = upper;
        this.lower = lower;
        this.time = time;
        this.time2 = time2;
    }

    public String getTime2() {
        return time2;
    }

    public void setTime2(String time2) {
        this.time2 = time2;
    }

    public float getUpper() {
        return upper;
    }

    public void setUpper(float upper) {
        this.upper = upper;
    }

    public float getLower() {
        return lower;
    }

    public void setLower(float lower) {
        this.lower = lower;
    }

    public String getTime() {
        return time;
    }

    public void setTime(String time) {
        this.time = time;
    }

    public RectF getRect() {
        return mRect;
    }

    public void setRect(RectF rect) {
        mRect = rect;
    }
}
复制代码

我们在给 ChartView 设置数据的时候需要做一些额外处理。例如,获得最大值和最小值的坐标,保存为 mLowerIndex 和 mUpperIndex:

public void setHealthDatas(List<HealthData> healthDatas) {
    mHealthDatas = healthDatas;
    float minLower = healthDatas.get(0).getLower();
    float maxUpper = healthDatas.get(0).getUpper();
    mLowerIndex = 0;
    mUpperIndex = 0;
    //获取数据中的最大值与最小值
    for (int i = 1; i < mHealthDatas.size(); i++) {
        HealthData healthData = mHealthDatas.get(i);
        if (healthData.getLower() < minLower) {
            minLower = healthData.getLower();
            mLowerIndex = i;
        }
        if (healthData.getUpper() > maxUpper) {
            maxUpper = healthData.getUpper();
            mUpperIndex = i;
        }
    }
}
复制代码

再来是一些帮助方法,主要是通过初始速度获取最终滑动距离,sp转换为px,dp转换为px:

@Override
protected void onAttachedToWindow() {
    super.onAttachedToWindow();
    final float ppi = getContext().getResources().getDisplayMetrics().density * 160.0f;
    mPhysicalCoeff = SensorManager.GRAVITY_EARTH // g (m/s^2)
            * 39.37f // inch/meter
            * ppi
            * 0.84f; // look and feel tuning
}

private static final float INFLEXION = 0.35f; // Tension lines cross at (INFLEXION, 1)

// A context-specific coefficient adjusted to physical values.
private float mPhysicalCoeff;


private static float DECELERATION_RATE = (float) (Math.log(0.78) / Math.log(0.9));

// Fling friction
private float mFlingFriction = ViewConfiguration.getScrollFriction();

/* Returns the duration, expressed in milliseconds */
//通过初始速度获取最终滑动距离
private int getSplineFlingDuration(int velocit) {
    final double l = getSplineDeceleration(velocit);
    final double decelMinusOne = DECELERATION_RATE - 1.0;
    return (int) (1000.0 * Math.exp(l / decelMinusOne));
}

private double getSplineDeceleration(float velocity) {
    return Math.log(INFLEXION * Math.abs(velocity) / (mFlingFriction * mPhysicalCoeff));
}

private float dp2px(float dp) {
    return TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_DIP,
            dp,
            getResources().getDisplayMetrics()
    );
}

private float sp2px(float sp) {
    return TypedValue.applyDimension(
            TypedValue.COMPLEX_UNIT_SP,
            sp,
            getResources().getDisplayMetrics()
    );
}
复制代码

绘制周期

接下来就是进入绘制周期了,首先是 onSizeChanged 方法:

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    //获取ChartView上方具体显示时间的控件。用来显示详情
    RelativeLayout parent = (RelativeLayout) getParent();
    int childCount = parent.getChildCount();
    for (int i = 0; i < childCount; i++) {
        View child = parent.getChildAt(i);
        if (child.getId() == R.id.time_move) {
            mMoveHeight = child.getMeasuredHeight();
            break;
        }
    }
    //记录ChartView的宽高
    mWidth = w;
    mHeight = h - mMoveHeight;//高度要减去ChartView上方具体显示时间的控件。用来显示详情
    //记录下纵坐标的坐标数的最大宽度和横坐标的坐标数的最大高度
    calculateTextSize();
    //计算坐标轴和长度和宽度
    mVCoordinate = mWidth - mMaxHCoordinateTextWidth;
    mHCoordinate = mHeight - mMaxHCoordinateTextHeight;
    //计算坐标轴可以滑动的最大距离
    calculateDistanceY();
    //计算出纵坐标坐标数的1代表多少像素
    int step = (int) (mHCoordinate / mHCoordinateNum);
    mPxToOne = step / mHCoordinateInternalSize;
    //内容显示的范围。包括横坐标的坐标数与坐标系的内容。因为要将canvas下移mMoveHeight,所以这边要对应的扩大绘制范围以显示详情
    mContentRect = new Rect(0, -mMoveHeight, mWidth - mMaxHCoordinateTextWidth, mHeight + mMoveHeight);
}
复制代码

接着是 onDraw 方法:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.translate(0, mMoveHeight);//下移mMoveHeight以显示详情
    drawBackgroud(canvas);//绘制背景
    drawVerticleNum(canvas);//绘制纵轴
    canvas.clipRect(mContentRect);//裁剪显示范围
    drawHealthData(canvas);//绘制数据
    drawCoordinateAxes(canvas);//绘制坐标轴
}
复制代码

绘制背景:

private void drawBackgroud(Canvas canvas) {
    int step = (int) mBgInternalSize;//每根背景线的距离
    int width = (int) mVCoordinate;
    int height = (int) mHCoordinate;

    int widthLineNums = width / step;//绘制的背景纵线
    for (int i = 0; i <= widthLineNums; i++) {
        canvas.drawLine(step * i, 0, step * i, height, mBgPaint);
    }
    int heightLineNums = height / step;
    for (int i = 0; i <= heightLineNums; i++) {//绘制的背景横线
        canvas.drawLine(0, step * i, width, step * i, mBgPaint);
    }
}
复制代码

绘制纵轴:

private void drawVerticleNum(Canvas canvas) {
    int step = (int) (mHCoordinate / mHCoordinateNum);
    for (int i = 0; i < mHCoordinateNum; i++) {
        //画文字
        canvas.drawText(String.valueOf(mHCoordinateStartNum + i * mHCoordinateInternalSize), mVCoordinate + mMaxHCoordinateTextWidth / 2, step * (mHCoordinateNum - i), mHCoordinatePaint);
    }
}
复制代码

绘制数据:

    private void drawHealthData(Canvas canvas) {
        //遍历设置的数据集
        for (int i = 0; i < mHealthDatas.size(); i++) {
            HealthData healthData = mHealthDatas.get(i);
            float lower = healthData.getLower();//最低值
            float upper = healthData.getUpper();//最高值
            String time = healthData.getTime();//横坐标时间
            float startX = mVCoordinate / mVCoordinateInternalNum * (i + 1) - mDistanceX;//绘制的横坐标
            float startY = mHCoordinate - (lower - mHCoordinateStartNum) * mPxToOne;//绘制的纵坐标的低点
            float endY = startY - (upper - lower) * mPxToOne;//绘制的纵坐标的高点
            //绘制竖线
            canvas.drawLine(startX, startY, startX, endY, mHealthPaint);
            //设置点击范围
            RectF rect = new RectF(startX - dp2px(mSelectedMargin), endY - dp2px(mSelectedMargin) + mMoveHeight, startX + dp2px(mSelectedMargin), startY + dp2px(mSelectedMargin) + mMoveHeight);
            healthData.setRect(rect);
            //绘制最大值和最小值
            if (i == mLowerIndex) {
                canvas.drawText("最低" + (int)lower, startX, startY + mMaxHCoordinateTextHeight, mHealthNumPaint);
            }
            if (i == mUpperIndex) {
                canvas.drawText("最高" + (int)upper, startX, endY - mMaxHCoordinateTextHeight / 2, mHealthNumPaint);
            }
            //绘制底部文字
            canvas.drawText(time, startX, mHCoordinate + mMaxHCoordinateTextHeight - mBgInternalSize, mVCoordinatePaint);
        }
        //如果有点击记录则显示详情
        if (mSelected != -1) {
            HealthData healthData = mHealthDatas.get(mSelected);
            float startX = mVCoordinate / mVCoordinateInternalNum * (mSelected + 1) - mDistanceX;//绘制的横坐标
            //绘制竖线
            canvas.drawLine(startX, 0, startX, mHCoordinate, mSelectedPaint);
            //移动回原点
            canvas.translate(0, -mMoveHeight);
            if (startX < mVCoordinate/4) {
                startX = mVCoordinate/4;
            }
            if (mVCoordinate - startX < mVCoordinate/4) {
                startX = mVCoordinate - mVCoordinate/4;
            }
            //绘制详细数据
            canvas.drawRoundRect(startX - mVCoordinate/4, 0, startX + mVCoordinate/4, mMoveHeight, dp2px(5), dp2px(5), mSelectedPaint);

            canvas.drawText(healthData.getTime2(), startX - mVCoordinate/4, mMoveHeight/2, mSelectedDetailPaint);
            canvas.drawText(healthData.getTime(), startX - mVCoordinate/4, mMoveHeight, mSelectedDetailPaint);

            canvas.drawLine(startX - mVCoordinate/4 + mVCoordinate/8 * 1.5f, 0, startX - mVCoordinate/4 + mVCoordinate/8 * 1.5f, mMoveHeight, mSelectedDetailPaint);

            canvas.drawText("最低", startX - mVCoordinate/4 + mVCoordinate/8*2, mMoveHeight/2, mSelectedDetailPaint);
            canvas.drawText(healthData.getLower()+"", startX - mVCoordinate/4 + mVCoordinate/8*2, mMoveHeight, mSelectedDetailPaint);

            canvas.drawText("最高", startX - mVCoordinate/4 + mVCoordinate/8*3, mMoveHeight/2, mSelectedDetailPaint);
            canvas.drawText(healthData.getUpper()+"", startX - mVCoordinate/4 + mVCoordinate/8*3, mMoveHeight, mSelectedDetailPaint);
            //移动回原位
            canvas.translate(0, mMoveHeight);
        }
    }
}
复制代码

绘制坐标轴:

private void drawCoordinateAxes(Canvas canvas) {
    mBgPaint.setStrokeWidth(mBgInternalSize);
    canvas.drawLine(0, mHCoordinate, mVCoordinate, mHCoordinate, mBgPaint);
    canvas.drawLine(mVCoordinate, 0, mVCoordinate, mHCoordinate, mBgPaint);
    mBgPaint.setStrokeWidth(mBgPaintWidth);
}
复制代码

动效监听

ACTION_DOWN:

initOrResetVelocityTracker();//初始化或重置速度跟踪器
mIsBeingFling = false;//不处于fling状态
// OverScroller 并不会为我们进行滚动处理,它只是提供了计算的模型,
// 通过调用 computeScrollOffset 进行计算,如果返回 true,表示计算还没结束,
// 然后通过 getCurrX 或 getCurrY 获取计算后的值,最后进行真正的滚动处理,比如调用 scrollTo 等等,
// 这里需要注意的是,需要调用 invalidate 来确保进行下一次的 computeScroll 调用,
// 这里使用的 postInvalidateOnAnimation 其作用是类似的。
mScroller.computeScrollOffset();
//确保每次点击会停止滑动
if (mIsBeingDragged = !mScroller.isFinished()) {
    mScroller.abortAnimation();
}
//请求父控件不拦截点击事件接下去的事件
if (mIsBeingDragged) {
    ViewParent parent = getParent();
    if (parent != null) {
        parent.requestDisallowInterceptTouchEvent(true);
    }
}
//获取第一个手势,支持多手势
mActivePointerId = event.getPointerId(0);
//获取点击的横坐标并记录为上一次坐标
mDownX = event.getX(0);
mLastX = mDownX;
break;
复制代码

ACTION_POINTER_DOWN:

//当有新的手指按下时分发的事件
final int actionIndex = event.getActionIndex();
mActivePointerId = event.getPointerId(actionIndex);
//获取点击的横坐标并记录为上一次坐标
mDownX = event.getX(actionIndex);
mLastX = mDownX;
复制代码

ACTION_MOVE:

//判断手势是否合法
final int activePointerId = mActivePointerId;
if (activePointerId == INVALID_POINTER) {
    break;
}
final int pointerIndex = event.findPointerIndex(activePointerId);
if (pointerIndex == -1) {
    Logger.e("onTouchEvent: invalid pointer index");
    break;
}
//获取滑动时的横坐标
final float moveX = event.getX(pointerIndex);
//之前不处于滑动状态且当前滑动的横坐标的值与之前记录的值的差的绝对值大于最小滑动值则视为滑动
if (!mIsBeingDragged && Math.abs(mDownX - moveX) > mTouchSlop) {
    mIsBeingDragged = true;//置状态为滑动
    //请求父控件不拦截点击事件接下去的事件
    ViewParent parent = getParent();
    if (parent != null) {
        parent.requestDisallowInterceptTouchEvent(true);
    }
}
//处于滑动状态
if (mIsBeingDragged) {
    //计算滑动的距离,不能超过设置的最大值和小于最小值
    mDistanceX += mLastX - moveX;
    if (mDistanceX > mMaximumDistanceX) {
        mDistanceX = mMaximumDistanceX;
    }
    if (mDistanceX < mMinimumDistanceX) {
        mDistanceX = mMinimumDistanceX;
    }
    //处理滑动更新。主要依靠 mDistanceX 来进行 onDraw 方法内容的更新
    postInvalidateOnAnimation();
}
//记录旧值
mLastX = moveX;
复制代码

ACTION_POINTER_UP:

//这个为多指触摸时,某个手指抬起时分发的事件。这段代码处理的是,当某个手指抬起时,而这个手指刚好是我们当前使用的,则重新初始化资源
int actionIndex = event.getActionIndex();
int pointerId = event.getPointerId(actionIndex);
if (pointerId == mActivePointerId) {
    actionIndex = actionIndex == 0 ? 1 : 0;
    mActivePointerId = event.getPointerId(actionIndex);
    mDownX = event.getX(actionIndex);
    mLastX = mDownX;
    mVelocityTracker.clear();
}
复制代码

ACTION_UP:

final VelocityTracker velocityTracker = mVelocityTracker;//获取速度跟踪器
// 设置VelocityTracker单位.1000表示1秒时间内运动的像素
velocityTracker.computeCurrentVelocity(1000, mMaximumFlingVelocity);
// 判断在1秒内X方向所滑动像素值是否大于最小的fling状态的速度
if (Math.abs(velocityTracker.getXVelocity()) > mMinimumFlingVelocity) {
    // 获取在1秒内X方向所滑动像素值 
    final int xVelocity = (int) velocityTracker.getXVelocity();
    //获取最终滑动距离
    mFlingDuration = Math.max(
            MAXIMUM_FLING_DURATION,
            getSplineFlingDuration(xVelocity)
    );
    //使用 OverScroll 的fling方法进行手指抬起后的滑动处理
    mScroller.fling(
            0,
            0,
            xVelocity,
            0,
            Integer.MIN_VALUE,
            Integer.MAX_VALUE,
            0,
            0
    );
    //OverScroll 使用fling方法后滑动的距离
    mFlingX = mScroller.getStartX();
    // 进行滑动距离的规范
    if (Math.abs(mMaximumDistanceX - mDistanceX) < getWidth()) {
        mFlingDuration = mFlingDuration / 3;
    }
    mIsBeingFling = true;//处于fling状态
    mStartFlingTime = SystemClock.elapsedRealtime();
    postInvalidateOnAnimation();//更新fling状态
}
mActivePointerId = INVALID_POINTER;
mIsBeingDragged = false;
resetVelocityTracker();
PointF tup = new PointF(event.getX(), event.getY());
clickWhere(tup);//判断是否点击到条状数据
invalidate();//更新
复制代码

ACTION_CANCEL:

mIsBeingFling = false;
mActivePointerId = INVALID_POINTER;
mIsBeingDragged = false;
resetVelocityTracker();
复制代码

onTouchEvent 方法的前置和后处理:

@Override
public boolean onTouchEvent(MotionEvent event) {
    if (!hasDataSource()) {
        return super.onTouchEvent(event);
    }
    ...//手势处理
    if (mVelocityTracker != null) {
        mVelocityTracker.addMovement(event);
    }
    return true;//返回 true 代表消费了事件
}
复制代码

根据点击的横纵坐标是否处于数据柱子的范围内,判断是否点击到条状数据,并记录下标:

private void clickWhere(PointF tup) {
    for (int i = 0; i < mHealthDatas.size(); i++) {
        HealthData healthData = mHealthDatas.get(i);
        RectF rect = healthData.getRect();
        if (tup.x > rect.left && tup.x < rect.right && tup.y > rect.top && tup.y < rect.bottom) {
            mSelected = i;
            break;
        }
        mSelected = -1;
    }
}
复制代码

处理fling方法的计算过程:

@Override
public void computeScroll() {
    super.computeScroll();
    if (mScroller.computeScrollOffset()) {
        final int currX = mScroller.getCurrX();
        mDistanceX += mFlingX - currX;
        if (mDistanceX > mMaximumDistanceX) {
            mDistanceX = mMaximumDistanceX;
        }
        if (mDistanceX < mMinimumDistanceX) {
            mDistanceX = mMinimumDistanceX;
        }
        mFlingX = currX;
        if ((SystemClock.elapsedRealtime() - mStartFlingTime) >= mFlingDuration || currX == mScroller.getFinalX()) {
            mScroller.abortAnimation();
        }
        postInvalidateOnAnimation();
    } else if (mIsBeingFling) {
        mIsBeingFling = false;
    }
}
复制代码

到此即完成所有东西的绘制。 代码上传至:https://github.com/jackzhengpinwen/kiwiViews/blob/master/app/src/main/java/com/zpw/views/exercise10/ChartView.java

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值