如何写出自定义View——Google 转盘

本文详细介绍了如何一步步实现一个自定义View,以Google转盘为例,涵盖了从定义View子类到处理输入手势的全过程,包括自定义样式、添加属性、创建绘图对象、响应布局事件、实现平滑运动以及优化技巧。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

今天看了Google上关于View的教程,看的真是云里雾里。虽然篇幅很长,看完一遍实在不想再看第二遍,不过自己还是坚持看完了第二遍,第三遍,第四遍。。。后面几遍越看越熟练,我看着Google示例APP里面的代码,然后参考着Google教程里面的讲解,慢慢的和教程里面的一个个点对上号。到写这篇博客为止,我还是没有很好的把其中的原理融会贯通,但是还是想先记录一下目前从代码里学习到的知识,以后看到好的View代码再多练习。

比如自定义XML属性我都不是很熟悉,在这里只是熟悉它的大概流程,里真正学会还差很远。

正文

首先我们来看看效果图(动态图不会弄。。见谅):
这里写图片描述

这里写图片描述

我们可以用手指拖动这个圆盘开始旋转,然后过一段时间它会慢慢停下来,并且旋转过程中左边对应的饼图部分的文字一直在变化。

下面我就按照如何自己写出一个自定义View的过程来记录学到的东西:

定义一个View子类

自定义当然离不开自己创建一个类继承View,不过我们可以选择继承基础类view,也可以是基础类之上的类(如Button),如果UI复杂想在自定义View里继续嵌套子元素,还可以继承ViewGroup。比如在PieChart里面,是这么写的:

public class PieChart extends ViewGroup {

定义自定义样式

每一个View都有自己的样式,比如说我们在XML里面经常使用的 android:layout_width = “match_parent”,如果我们自己定制View,我们还可以自己定义自己的样式,习惯上来讲,我们把自定义view的自定义属性放在res/values/attrs.xml文件里面。我们需要在< resource >元素下面定义一个子元素 < declare-styleable >,然后写上这个样式的名字,一般和自定义View类的名字一样。在这个< declare-styleable >里面来定义有需求的属性,在pieChart里面是这样的:

    <declare-styleable name="PieChart">
        <attr name="autoCenterPointerInSlice" format="boolean"/>
        <attr name="highlightStrength" format="float"/>
        <attr name="labelColor" format="color"/>
        <attr name="labelHeight" format="dimension"/>
        <attr name="labelPosition" format="enum">
            <enum name="left" value="0"/>
            <enum name="right" value="1"/>
        </attr>
        <attr name="labelWidth" format="dimension"/>
        <attr name="labelY" format="dimension"/>
        <attr name="pieRotation" format="integer"/>
        <attr name="pointerRadius" format="dimension"/>
        <attr name="showText" format="boolean"/>
    </declare-styleable>

然后我们应该在XML文件里面应用这个样式,不过我们应该重新加入一个新的命名空间,比如之前我们用android:layout_weight,android:centerInParent之类的属性,其实都是因为先引入了xmlns:android=”http://schemas.android.com/apk/res/android”,”http://schemas.android.com/apk/res/android“是具体的URI,用指令xmlns可以指定一个别名还代替这一长串的URI,我们这里引入xmlns:custom=”http://schemas.android.com/apk/res/com.example.custom_view”,这样,我们就能用custom:XX=YY这样的自定义属性了。

注意:引入自己的命名空间,规则是在res后面加入APP的具体包名。这里的包名是com.example.custom_view。

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:custom="http://schemas.android.com/apk/res/com.example.custom_view"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <com.example.custom_view.PieChart
        android:id="@+id/Pie"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:padding="10dp"
        android:layout_weight="100"
        custom:showText="true"
        custom:labelHeight="20dp"
        custom:labelWidth="110dp"
        custom:labelY="85dp"
        custom:labelPosition="left"
        custom:highlightStrength="1.12"
        android:background="@android:color/white"
        custom:pieRotation="0"
        custom:labelColor="@android:color/black"
        custom:autoCenterPointerInSlice="true"
        custom:pointerRadius="4dp"
        />
    <Button
        android:id="@+id/Reset"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="@string/reset_button"
        />
</LinearLayout>

应用自定义样式

应用自定义样式要在自定义View的构造函数里面取出来,然后应用给View里面的成员变量,如果是代码里面直接新建View,一般都调用的是:

/**
     * Class constructor taking only a context. Use this constructor to create
     * {@link PieChart} objects from your own code.
     *
     * @param context
     */
    public PieChart(Context context) {
        super(context);
        init();
    }

只要传递一个上下文context进入就行了。

如果我们是在XML文件里面指定好了,然后我们就要调用另一构造函数,因为如果使用上一个构造函数的话,它单单是建立了一个含有默认样式的View,而我们在XML文件里面定制好的属性样式都完全没有应用给它。这个时候我们应该调用这个构造函数:

/**
     * Class constructor taking a context and an attribute set. This constructor
     * is used by the layout engine to construct a {@link PieChart} from a set of
     * XML attributes.
     *
     * @param context
     * @param attrs   An attribute set which can contain attributes from
     *                {@link com.example.android.customviews.R.styleable.PieChart} as well as attributes inherited
     *                from {@link android.view.View}.
     */
    public PieChart(Context context, AttributeSet attrs) {
        super(context, attrs);

        // attrs contains the raw values for the XML attributes
        // that were specified in the layout, which don't include
        // attributes set by styles or themes, and which may have
        // unresolved references. Call obtainStyledAttributes()
        // to get the final values for each attribute.
        //
        // This call uses R.styleable.PieChart, which is an array of
        // the custom attributes that were declared in attrs.xml.
        TypedArray a = context.getTheme().obtainStyledAttributes(
                attrs,
                R.styleable.PieChart,
                0, 0
        );

            try {
                // Retrieve the values from the TypedArray and store into
                // fields of this class.
                //
                // The R.styleable.PieChart_* constants represent the index for
                // each custom attribute in the R.styleable.PieChart array.
                mShowText = a.getBoolean(R.styleable.PieChart_showText, false);
                mTextY = a.getDimension(R.styleable.PieChart_labelY, 0.0f);
                mTextWidth = a.getDimension(R.styleable.PieChart_labelWidth, 0.0f);
                mTextHeight = a.getDimension(R.styleable.PieChart_labelHeight, 0.0f);
                mTextPos = a.getInteger(R.styleable.PieChart_labelPosition, 0);
                mTextColor = a.getColor(R.styleable.PieChart_labelColor, 0xff000000);
                mHighlightStrength = a.getFloat(R.styleable.PieChart_highlightStrength, 1.0f);
                mPieRotation = a.getInt(R.styleable.PieChart_pieRotation, 0);
                mPointerRadius = a.getDimension(R.styleable.PieChart_pointerRadius, 2.0f);
                mAutoCenterInSlice = a.getBoolean(R.styleable.PieChart_autoCenterPointerInSlice, false);
            } finally {
            // release the TypedArray so that it can be reused.
            a.recycle();
        }

        init();
    }

我们通过obtainStyledAttrubutes()把属性读取到TypeArray里面,需要传入构造函数的参数attrs,我们自定义的属性R.styleable.PieChart,两个0(不知道什么用)。然后这样我们就把XML里面定制好的属性读取到TypedArray数组里了。

TypedArray a = context.getTheme().obtainStyledAttributes(
                attrs,
                R.styleable.PieChart,
                0, 0
        );

接下来我们就要把需要读取的属性从获取到的TypedArray对象里获取出来,就像下面这样,好像取key_value对一样,其中R.styleable.PieChart_*这些是系统自动帮我们生成的R.styleable.PieChart对应属性的索引。如果取到了就是XML文件里面定制好的属性,如果没有取到我们就用默认的属性。

     mShowText = a.getBoolean(R.styleable.PieChart_showText, false);
     mTextY = a.getDimension(R.styleable.PieChart_labelY, 0.0f);

还有,最后一定要回收TypedArray资源,因为这是系统的一个共享资源。

a.recycle();

添加属性和事件

为了提供动态的行为,我们应该暴露出那些会影响VIew外观和行为的属性。比如说我们把showText改成false,那么我们就需要重新计算最好的尺寸和布局,我们就要调用 invalidate() 和 requestLayout()。忘记这些方法的调用我们就很难发现BUG。

当我们开发的时候,很容易忘记这一点。那我们应该遵循一个好的规则,总是把那些会影响View外观和行为的属性暴露出来,并调用invalidate()或是requestLayout()来让View变得可靠。

    public float getPointerRadius() {
        return mPointerRadius;
    }

    public void setPointerRadius(float pointerRadius) {
        mPointerRadius = pointerRadius;
        invalidate();
    }

创建绘图对象

在画图之前我们要先准备好画笔,在PieChart里面,我们要预先准备好画饼图的画笔,画字的画笔,画阴影的画笔。而且我们应该在构造函数里面完成这一操作,因为放在onDraw()方法里面会导致每次重绘都要重新初始化画笔,会让View变的拖沓。

private void init() {
        // Force the background to software rendering because otherwise the Blur
        // filter won't work.
        setLayerToSW(this);

        // Set up the paint for the label text
        mTextPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mTextPaint.setColor(mTextColor);
        if (mTextHeight == 0) {
            mTextHeight = mTextPaint.getTextSize();
        } else {
            mTextPaint.setTextSize(mTextHeight);
        }

        // Set up the paint for the pie slices
        mPiePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPiePaint.setStyle(Paint.Style.FILL);
        mPiePaint.setTextSize(mTextHeight);

        // Set up the paint for the shadow
        mShadowPaint = new Paint(0);
        mShadowPaint.setColor(0xff101010);
        mShadowPaint.setMaskFilter(new BlurMaskFilter(8, BlurMaskFilter.Blur.NORMAL));
        ... ...

处理布局事件

在初始化完画笔之后,画图之前我们还有一项工作,就是确定好布局大小。一般在View第一次被指定大小的时候,系统会调用onSizeChanged()方法,在这个方法里面我们要确定好View里面每一个子元素的大小,确定好每一个子元素和子元素之间的位置关系。

@Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);

        //
        // Set dimensions for text, pie chart, etc
        //
        // Account for padding
        float xpad = (float) (getPaddingLeft() + getPaddingRight());
        float ypad = (float) (getPaddingTop() + getPaddingBottom());

        // Account for the label
        if (mShowText) xpad += mTextWidth;

        float ww = (float) w - xpad;
        float hh = (float) h - ypad;

        // Figure out how big we can make the pie.
        float diameter = Math.min(ww, hh);
        mPieBounds = new RectF(
                0.0f,
                0.0f,
                diameter,
                diameter);
        mPieBounds.offsetTo(getPaddingLeft(), getPaddingTop());

        mPointerY = mTextY - (mTextHeight / 2.0f);
        float pointerOffset = mPieBounds.centerY() - mPointerY;

        // Make adjustments based on text position
        if (mTextPos == TEXTPOS_LEFT) {
            mTextPaint.setTextAlign(Paint.Align.RIGHT);
            if (mShowText) mPieBounds.offset(mTextWidth, 0.0f);
            mTextX = mPieBounds.left;

            if (pointerOffset < 0) {
                pointerOffset = -pointerOffset;
                mCurrentItemAngle = 225;
            } else {
                mCurrentItemAngle = 135;
            }
            mPointerX = mPieBounds.centerX() - pointerOffset;
        } else {
            mTextPaint.setTextAlign(Paint.Align.LEFT);
            mTextX = mPieBounds.right;

            if (pointerOffset < 0) {
                pointerOffset = -pointerOffset;
                mCurrentItemAngle = 315;
            } else {
                mCurrentItemAngle = 45;
            }
            mPointerX = mPieBounds.centerX() + pointerOffset;
        }

如果我们想要更加精细的去控制View的大小,我们要实现onMeasure()方法。覆写这个方法会有两个参数,这两个参数分别是系统想要这个View的宽度和高度。我们在确定这个View的大小的时候,我们应该注意一点,确定这个View的Padding是View的责任,千万不要忘记。

resolveSizeAndState()方法是通过比较View想要的值和传入的参数来返回一个 View.MeasureSpec 参数,传入的参数是两个int类型的参数。onMeasure()方法没有返回值,我们是通过setMeasuredDimension()把结果地交给系统,传入的两个View.MeasureSpec类型的值,分别是宽和高。

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // Try for a width based on our minimum
        int minw = getPaddingLeft() + getPaddingRight() + getSuggestedMinimumWidth();

        int w = Math.max(minw, MeasureSpec.getSize(widthMeasureSpec));

        // Whatever the width ends up being, ask for a height that would let the pie
        // get as big as it can
        int minh = (w - (int) mTextWidth) + getPaddingBottom() + getPaddingTop();
        int h = Math.min(MeasureSpec.getSize(heightMeasureSpec), minh);

        setMeasuredDimension(w, h);
    }

开始绘图

终于开始最重要的一步了,这个步骤在onDraw()里面完成。就是调用系统的一些方法,比如drawText(),drawRect(), drawOval(),drawPath(),drawBitmap()画出一些基本的图形,然后用setStyle(),等一些方法来确定样式。

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

        // Draw the shadow
        canvas.drawOval(mShadowBounds, mShadowPaint);

        // Draw the label text
        if (getShowText()) {
            canvas.drawText(mData.get(mCurrentItem).mLabel, mTextX, mTextY, mTextPaint);
        }

处理输入手势

当然,绘画当然是自定义View里面最重要的一步,但是能让View响应用户的操作也很重要,要不然和图片有什么区别。

我们知道用户的手势无非是这么几种,轻点,推,拉,快速移动,缩放,为了把这些原始的接触事件转化为手势,Android提供了 GestureDetector 。我们先要构造一个 GestureDetector ,然后通过这个 GestureDetector 来处理一些手势,如果处理不了,我们再自己自定义手势的处理方案。

首先要构造一个 GestureDetector ,我们要让一个类去继承 GestureDetector.OnGestureListener ,如果只想处理几个简单的事件我们可以继承GestureDetector.SimpleOnGestureListener,然后在这个类里面定义好处理各种手势的代码。

    private class GestureListener extends GestureDetector.SimpleOnGestureListener {
        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            // Set the pie rotation directly.
            float scrollTheta = vectorToScalarScroll(
                    distanceX,
                    distanceY,
                    e2.getX() - mPieBounds.centerX(),
                    e2.getY() - mPieBounds.centerY());
            setPieRotation(getPieRotation() - (int) scrollTheta / FLING_VELOCITY_DOWNSCALE);
            return true;
        }

        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            // Set up the Scroller for a fling
            float scrollTheta = vectorToScalarScroll(
                    velocityX,
                    velocityY,
                    e2.getX() - mPieBounds.centerX(),
                    e2.getY() - mPieBounds.centerY());
            mScroller.fling(
                    0,
                    (int) getPieRotation(),
                    0,
                    (int) scrollTheta / FLING_VELOCITY_DOWNSCALE,
                    0,
                    0,
                    Integer.MIN_VALUE,
                    Integer.MAX_VALUE);
                    ......

定义好这个类之后,我们就可以把这个类作为参数传递给GestureDetector:

mDetector = new GestureDetector(PieChart.this.getContext(), new GestureListener());

然后我们在onTouchEvent()方法里面,先把手势传递给GestureDetector,看它能不能解决,如果它能解决,会返回true,如果不能解决就会返回false,如果返回false,我们还需要自定义处理的代码。

@Override
    public boolean onTouchEvent(MotionEvent event) {
        // Let the GestureDetector interpret this event
        boolean result = mDetector.onTouchEvent(event);

        // If the GestureDetector doesn't want this event, do some custom processing.
        // This code just tries to detect when the user is done scrolling by looking
        // for ACTION_UP events.
        if (!result) {
            if (event.getAction() == MotionEvent.ACTION_UP) {
                // User is done scrolling, it's now safe to do things like autocenter
                stopScrolling();
                result = true;
            }
        }
        return result;
    }

创建直观正确的运动

我们在创建动画,不应该只满足于创建出来动画,而是让动画尽可能的和真实世界里一样,这样用户才不会觉得讶异。比如所快速推一个物体,它应该是先慢慢加速,匀速,然后再慢慢停下来。玩转盘的时候,它也是先快,然后慢慢停下来。

要想自己实现这样的效果能难,不过Android已经帮我们集成好了一些相关的方法,只要传入参数,就能帮我们计算好各个运动时刻的参数值。比如在GestureListener里面的onFling()方法里,我们将一些参数传入Scroller类里的fling()方法,就可以根据当前的速度,位置等信息,自动生成各个时刻的信息。

@Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            // Set up the Scroller for a fling
            mScroller.fling(
                    0,
                    (int) getPieRotation(),
                    0,
                    (int) scrollTheta / FLING_VELOCITY_DOWNSCALE,
                    0,
                    0,
                    Integer.MIN_VALUE,
                    Integer.MAX_VALUE);

当然fling()方法只是为我们提供了一个值,它并不会把这些时刻变化的位置信息应用到我们的View上,所以我们应该从Scroller里面得到这些值,然后更新View就好。

要去更新有两种方式,一种就是调用postInvalidate()去强制调用onDraw()方法去重绘,不过这个方法需要我们在onDraw()方法里面计算偏移量,然后每次改变的时候重新调用postInvalidate()。

第二种方式是添加一个ValueAnimator去监听滑动,添加一个监听器去处理动画的更新。

        if (Build.VERSION.SDK_INT >= 11) {
            mScrollAnimator = ValueAnimator.ofFloat(0, 1);
            mScrollAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                public void onAnimationUpdate(ValueAnimator valueAnimator) {
                    tickScrollAnimation();
                }
            });
        }

第二种方式虽然实现比较复杂(不过我觉得第一种更麻烦),但是它和系统结合的更紧密,而且不会产生多余的View。它在Android3.0的时候被引入(API11)。如果考虑低版本,我们应该在运行的时候检查一下版本。

            if (Build.VERSION.SDK_INT >= 11) {
                mScrollAnimator.setDuration(mScroller.getDuration());
                mScrollAnimator.start();
            }
            return true;

让你的过渡平滑

还有一个小小的知识点,就是在View的位置做任何改变的时候都不要突然性的改变位置信息,而是要通过动画的形式来改变。因为在真实世界里,东西是不会突然出现,消失的,不会突然停下,或是突然开始运动,都要一个过程。用户自己也会用真实世界里面的直觉来预判View的变化,所以我们应该动画的形式来完成,比如在PieChart里面,我们要实现停止之后,小圆点位于所在饼切片的中间,这时我们就要用动画的解决,而不是直接生硬的修改位置。

    private void centerOnCurrentItem() {
        Item current = mData.get(getCurrentItem());
        int targetAngle = current.mStartAngle + (current.mEndAngle - current.mStartAngle) / 2;
        targetAngle -= mCurrentItemAngle;
        if (targetAngle < 90 && mPieRotation > 180) targetAngle += 360;

        if (Build.VERSION.SDK_INT >= 11) {
            // Fancy animated version
            mAutoCenterAnimator.setIntValues(targetAngle);
            mAutoCenterAnimator.setDuration(AUTOCENTER_ANIM_DURATION).start();
        } else {
            // Dull non-animated version
            //mPieView.rotateTo(targetAngle);
        }
    }

优化View

简而言之:

  • 让你的布局尽可能浅从而减少系统确定大小时的历遍次数
  • 尽可能减少不必要的Invalidate()从未减少对onDraw()不必要的回调,
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值