Android自定义控件

导语

当系统控件不能满足我们的需求的时候,这时候我们就需要自定义控件,根据我们的需求来定制一个能满足我们需求的控件。一个让用户熟悉的控件才是一个好的控件,如果一味追求酷炫的效果,会让用户觉得华而不实。

主要内容

  • 了解自定义控件
  • 对现有控件进行拓展
  • 创建复合控件
  • 重写View来实现全新控件
  • 自定义ViewGroup

具体内容

自定义控件可以是对现有控件进行拓展、创建复合控件、重写View实现全新控件,可根据需要不同选择最佳方式进行自定义控件。

了解自定义控件

在自定义View时,我们通常会去重写onDraw()方法来绘制View的显示内容。如果该View还需要使用wrap_content属性,那么还必须重写onMeasure()方法。另外,通过自定义attrs属性,还可以设置新的属性配置值。
在View中通常有以下一些比较重要的回调方法:

  • onFinishInflate():从XML加载组件后回调。
  • onSizeChanged():组件大小改变时回调。
  • onMeasure():回调该方法来进行测量。
  • onLayout():回调该方法来确定显示的位置。
  • onTouchEvent():监听到触摸事件时回调。

当然,创建自定义View的时候,并不需要重写所有的方法,只需要重写特定条件的回调方法即可。这也是Android控件架构灵活性的体现。
在通常情况下,有以下三种方法来实现自定义的控件:

  • 对现有控件进行拓展
  • 创建复合控件
  • 重写View来实现全新控件

对现有控件进行拓展

这是一个非常重要的自定义View方法,它可以在原生控件的基础上进行拓展,增加新的功能、修改显示的UI等。一般来说我们可以在onDraw()方法中对原生控件行为进行拓展。
以一个TextView为例,使用Canvas对象来进行图像的绘制,然后利用Android的绘图机制,可以绘制出更加复杂丰富的图像。比如可以利用LinearGradient Shader和Matrix来实现一个动态的文字闪动效果,程序运行效果如下图所示。

闪动的文字

要想实现这一个效果,可以充分利用Android中Paint对象的Shader渲染器。通过设置一个不断变化的LinearGradient,并使用带有该属性的Paint对象来绘制要显示的文字。首先,在onSizeChanged()方法中进行一些对象的初始化工作,并根据View的宽度设置一个LinearGradient渐变渲染器,代码如下:

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh){
    super.onSizeChanged(w, h, oldw, oldh);
    if(mViewWidth  == 0) {
        mViewWidth = getMeasuredWidth();
        if(mViewWidth > 0) {
            mPaint = getPaint();
            // 创建mLinearGradient渐变渲染器并填充到画笔mPaint
            mLinearGradient = new LinearGradient(0, 0, mViewWidth, 0, 
                    new int[] {Color.BLUE, 0xffffffff, Color.BLUE}, 
                    null, Shader.TileMode.CLAMP);
            mPaint.setShader(mLinearGradient);
            mGradientMatrix = new Matrix();
        }
    }
}

其中最关键的就是使用getPaint()方法获取当前绘制TextView的Paint对象,并给这个Paint对象设置原生TextView没有的LinearGradient属性。最后,在onDraw()方法中,通过矩阵的方式来不断平移渐变效果,从而在绘制文字时,产生动态的冷却效果,代码如下所示:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    if(mGradientMatrix != null) {
        mTranslate += mViewWidth / 5;
        if(mTranslate > 2 * mViewWidth) {
            mTranslate = -mViewWidth;
        }
        mGradientMatrix.setTranslate(mTranslate, 0);  // 设置位移
        mLinearGradient.setLocalMatrix(mGradientMatrix);
        postInvalidateDelayed(100);  // 延时刷新
    }
}

这样就完成了对TextView进行拓展,制作一个动态文字闪动的TextView。

创建复合控件

创建复合控件可以很好地创建出具体重用功能的控件集合。这种方式通常需要继承一个合适的ViewGroup,再给它添加指定功能的控件,从而组合成新的复合控件。通过这种方式创建的控件,我们一般会给它指定一些可配置的属性,让它具有更强的拓展性。
下面就以一个通用的TopBar为示例,进行讲解如何创建复合控件。效果如下所示。

复合控件TopBar

首先新建一个TopBar类继承RelativeLayout。

public class TopBar extends RelativeLayout {

    public TopBar(Context context) {
        this.TopBar(context, null);
    }

    public TopBar(Context context, AttributeSet attrs) {
        super(context, attrs);
        // 初始化的方法
        // 初始化属性
        initAttr(context, attrs)
        // 初始化布局
        initView(context);
        // 初如化事件
        initEvent();
    }

}
定义属性

为一个View提供可自定义的属性非常简单,只需要在res资源目录的values目录下创建一个attrs.xml的属性定义文件,并在该文件中通过如下代码定义相应的属性即可。

<?xml version="1.0" encoding="utf-8"?>

<resources>

    <declare-styleable name="TopBar">
        <!-- 定义title文字,大小,颜色 -->
        <attr name="title" format="string" />
        <attr name="titleTextSize" format="dimension" />
        <attr name="titleTextColor" format="color" />
        <!-- 定义left 文字,大小,颜色,背景 -->
        <attr name="leftTextColor" format="color" />
        <attr name="leftTextSize" format="dimension" />
        <!-- 表示背景可以是颜色,也可以是引用 -->
        <attr name="leftBackground" format="reference|color" />
        <attr name="leftText" format="string" />
        <!-- 定义right 文字,大小,颜色,背景 -->
        <attr name="rightTextColor" format="color" />
        <attr name="rightTextSize" format="dimension"/>
        <attr name="rightBackground" format="reference|color" />
        <attr name="rightText" format="string" />
    </declare-styleable>

</resources>

我们在代码中通过<declare-styleable>标签声明了使用自定义属性,并通过name属性来确定引用的名称,通过<attr>标签来声明具体的自定义属性,比如在这里定义了标题文字的内容、大小、颜色,左右按钮的背景、文字内容、颜色等属性,并通过format属性来指定属性的类型。这里需要注意的是,有些属性可以是颜色属性,也可以是引用属性。比如按键的背景,所以使用“|”来分隔不同的属性——"reference|color"。
在确定好属性后,就可以创建一个自定义控件——TopBar,并让它继承自ViewGroup,从而组合一些需要的控件。这里为了简单,我们继承RelativeLayout。在构造方法中,通过如下所示代码来获取在XML布局文件中自定义的那些属性,即与我们使用系统提供的那些属性一样。

TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.TopBar);

系统提供了TypedArray这样的数据结构来获取自定义属性集,后面引用的styleable的TopBar,就是我们在XML中通过<declare-styleable name="TopBar">所指定的name名。接下来,通过TypeArray对象的getString()、getColor()等方法,就可以获取这些定义的属性值,代码如下所示。

private int mLeftTextColor;
private Drawable mLeftBackground;
private String mLeftText;
private float mLeftTextSize;

private int mRightTextColor;
private Drawable mRightBackground;
private String mRightTextSize;
private float mRightTextSize;

private String mTitleText;
private float mTitleTextSize;
private int mTitleTextColor;

private void initAttr(Context context, AttributeSet attrs) {
    // 通过这个方法,将你在attrs.xml中定义的declare-styleable的所有属性的值存储到TypedArray.
    TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.TopBar);
    // 从TypedArray中取出对应的值来为要设置的属性赋值
    mLeftTextColor = ta.getColor(R.styleable.TopBar_leftTextColor, 0);
    mLeftBackground = ta.getDrawable(R.styleable.TopBar_leftBackground);
    mLeftText = ta.getString(R.styleable.TopBar_leftText);
    mLeftTextSize = typed.getDimension(R.styleable.TitleBar_leftTextSize, 20);

    mRightTextColor = ta.getColor(R.styleable.TopBar_rightTextColor, 0);
    mRightBackground = ta.getDrawable(R.styleable.TopBar_rightBackground);
    mRightText = ta.getString(R.styleable.TopBar_rightText);
    mRightTextSize = typed.getDimension(R.styleable.TitleBar_rightTextSize, 20);

    mTitleText = ta.getString(R.styleable.TopBar_titleText);
    mTitleTextSize = ta.getDimension(R.styleable.TopBar_titleTextSize, 10);
    mTitleTextColor = ta.getColor(R.styleable.TopBar_titleTextColor, 0);

// 获取完TypedArray的值后,一般要调用recyle()方法来避免重新创建的时候的错误
ta.recycle();
}

这里需要注意的是,当获取完所有的属性值后,需要调用TypedArray的recyle()方法来完成资源的回收。

组合控件

接下来,我们就可以开始组合控件了。TopBar由三个控件组成,左边按钮mLeftButton、右边按钮mRightButton、中间标题栏mTitleView。通过动态添加控件的方式,使用addView()方法将三个控件加入到定义的TopBar模板中,并给它们设置我们前面所获取到的具体的属性值,比如标题的文字、颜色、大小等,代码如下所示。

private TextView mTitleView;
private Button mLeftButton;
private Button mRightButton;

private RelativeLayout.LayoutParams mLeftParams;
private RelativeLayout.LayoutParams mRightParams;
private RelativeLayout.LayoutParams mTitleParams;

private void initView(Context context) {
    mTitleView = new TextView(context);
    mLeftButton = new Button(context);
    mRightButton = new Button(context);

    // 为创建的组件赋值,值就来源于引用的xml文件中给对应属性的赋值
    mTitleView.setText(mTitleText);
    mTitleView.setTextSize(mTitleTextSize);
    mTitleView.setTextColor(mTitleTextColor);
    mTitleView.setGravity(Gravity.CENTER);

    mLeftButton.setText(mLeftText);
    mLeftButton.setTextColor(mLeftTextColor);
    mLeftButton.setBackgroundDrawable(mLeftBackground);
    mLeftButton.setTextSize(mLeftTextSize);

    mRightButton.setText(mRightText);
    mRightButton.setTextSize(mRightTextSize);
    mRightButton.setBackgroundDrawable(mRightBackground);
    mRightButton.setTextColor(mRightTextColor);

    // 为组件元素设置相应的布局元素
    // 设置布局的layout_width和layout_height属性
    mLeftParams = new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT);
    // 该方法表示所设置节点的属性必须关联其他兄弟节点或者属性值为布尔值。
    mLeftParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT, RelativeLayout.TRUE);
    // 动态添加组件
    addView(mLeftButton, mLeftParams);

    mRightParams= new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT);
    mRightParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT, RelativeLayout.TRUE);
    addView(mRightButton, mRightParams);

    mTitleParams= new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT);
    mTitleParams.addRule(RelativeLayout.CENTER_IN_PARENT, RelativeLayout.TRUE);
    addView(mTitleView, mTitleParams);
}

既然是UI模板,那么每个调用者所需要这些按钮的实现功能都是不一样的。因此,不能直接在UI模板里实现逻辑,可以通过接口回调的思想,实现逻辑交给调用者。实现过程如下所示。

  • 定义接口
    定义一个左右按钮点击的接口,并创建两个方法,分别用于左右两个按钮的点击,代码如下所示。
// 在类内部定义一个接口对象,实现回调机制,不用去考虑如何实现,具体实现由调用者去创建
public interface OnClickListener{
    // 左按钮点击事件
    void leftClick();
    // 右按钮点击事件
    void rightClick();
    }
  • 暴露接口给调用者
    在模板方法中,为左右按键增加点击事件,但不实现具体逻辑,而是调用接口中相应的点击方法,代码如下所示。
// 创建一个接口对象
private OnClickListener mListener;

// 暴露一个方法给调用者来注册接口,通过接口来获得回调者对接口方法的实现
public void setOnClickListener(OnClickListener listener) {
    this.mListener = listener;
}

private void initEvent(){
    // 按钮的点击事件,不需要具体的实现,只需要调用接口方法,回调的时候会有具体实现
    mLeftButton.setOnClickListener(new OnClickListener() {
        @Override
        public void onClick(View v) {
            mListener.leftClick();
        }
    });

    mRightButton.setOnClickListener(new OnClickListener() {
        @Override
        public void onClick(View v) {
            mListener.rightClick();
        }
    });
}
  • 实现接口回调
    在调用者的代码中,调用者需要实现这样一个接口,并完成接口中的方法,确定具体的实现逻辑,并使用第二步中暴露的方法,将接口的对象传递进去,从而完成回调。通常情况下,可以使用匿名内部类的形式来实现接口中的方法,代码如下所示。
mTopBar.setOnClickListener(new TopBar.OnClickListener() {
    @Override
    public void leftClick() {
        // 点击左边按钮
    }

    @Override
    public void rightClick() {
        // 点击右边按钮
    }
}

在方法中分别添加点击左右按钮之后的逻辑代码。

除了通过接口回调的方式来实现动态的控制UI模板,同样可以使用公共方法来动态地修改UI模板中的UI,这样就进一步提高了模板的可定制性,代码如下所示。

public static final int LEFT = 1;
public static final int RIGHT = 2;

/**
  * 设置按钮的显示与否通过常量区分,visible区分是否显示
  *
  * @param view  标记View
  * @param visible  是否显示
  * /
public void setVisable(int view, int visible){
    switch(view) {
        case LEFT:
            mLeftButton.setVisibility(visible);
        break;
        case RIGHT:
            mRightButton.setVisibility(visible);
        break;
    }
}

通过如上代码,调用者通过TopBar对象调用这个方法后,根据参数,可以动态地控制按钮的显示,代码如下。

// 控制TopBar上组件的状态
mTopBar.setVisable(TopBar.LEFT, View.VISIBLE);
mTopBar.setVisable(TopBar.RIGHT, View.GONE);
引用UI模板

最后一步,自然是在需要使用的地方引用UI模板,在引用前,需要指定引用第三方控件的名字空间。在布局文件中,可以看到如下一行代码。

xmlns:android="http://schemas.android.com/apk/res/android"

这行代码就是在指定引用的名字空间xmlns,即xml namespace。这里指定了名字空间为“android”,因此在接下来使用系统属性时,才可以使用“android:”来引用Android的系统属性。同样,如果要使用自定义属性,那么就需要创建自己的名字空间,在Android Stuido中,第三方控件都使用如下代码来引用名字空间。

xmlns:app="http://schemas.android.com/apk/res/res-auto"

这里我们将引入的第三方控件的名字空间取名为app,之后在XML文件中使用自定义的属性时,就可以通过这个名字空间来引用,代码如下所示。

<com.example.demo.TopBar
        android:id="@+id/tb"
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:layout_alignParentBottom="true"
        app:leftBackGround="#ff000000"
        app:leftText="Back"
        app:leftTextColor="#ffff6734"
        app:leftTextSize="25dp" 
        app:rightText="More"
        app:rightTextSize="25dp"
        app:rightTextColor="#ff123456"
        app:title="自定义标题"
        app:titleTextColor="#ff654321"/>

使用自定义的View与系统原生的View最大的区别就是在申明控件时,需要指定完整的包名,而在引用自定义的属性时,需要使用自定义的xmlns名字。

重写View来实现全新的控件

当Android系统原生控件无法满足我们的需求的时候,我们就可以完全创建一个新的自定义View来实现需要的功能。创建一个自定义View,难点在于绘制控件和实现交互,这也是评价一个自定义View优劣的标准之一。
同时需要继承View类,并重写它的onDraw()、onMeasure()等方法来实现绘制逻辑,同时通过重写onTouchEvent()等触控事件来实现交互逻辑。也可以像实现组合控件方式,通过引入自定义属性,丰富自定义View的可定制性。

弧线展示图

在PPT很多模板中,经常会有比例图,如下图所示。

比例图

这个自定义View其实分三个部分,分别是中间的圆形、中间显示的文字和外圈的弧线。为了简单,我们把View的绘制长度直接设置为屏幕的宽度。首先在初始化的时候,设置好绘制三种图形的参数,然后在onDraw()方法中去绘制。代码如下所示。

public class ScaleMap extends View {

    private int mMeasureHeigth;// 控件高度
    private int mMeasureWidth;// 控件宽度
    // 圆形
    private Paint mCirclePaint;
    private float mCircleXY;//圆心坐标
    private float mRadius;//圆形半径
    // 圆弧
    private Paint mArcPaint;
    private RectF mArcRectF;//圆弧的外切矩形
    private float mSweepAngle;//圆弧的角度
    private float mSweepValue;
    // 文字
    private Paint mTextPaint;
    private String mShowText;//文本内容
    private float mShowTextSize;//文本大小

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

    public ScaleMap(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public ScaleMap(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        this(context, attrs, defStyleAttr, 0);
    }
    
    // 如果不用后面的参数,就不需要重构后面的,直接将其内容写在第一个构造方法就可以,父类会自动执行后面的构造方法
    public ScaleMap(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
        // 初始化操作
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        mMeasureWidth = MeasureSpec.getSize(widthMeasureSpec);//获取控件宽度
        mMeasureHeigth = MeasureSpec.getSize(heightMeasureSpec);//获取控件高度
        setMeasuredDimension(mMeasureWidth, mMeasureHeigth);

        initPaint();  // 画笔中用到了宽高所以在此初始化画笔
    }

    /**
    * 准备画笔,
    */
    private void initPaint() {
        float length = Math.min(mMeasureWidth,mMeasureHeigth);
        // 圆的代码
        mCircleXY = length / 2;// 确定圆心坐标
        mRadius = (float) (length * 0.5 / 2);// 确定半径
        mCirclePaint = new Paint();
        mCirclePaint.setAntiAlias(true);// 去锯齿
        mCirclePaint.setColor(getResources().getColor(android.R.color.holo_green_dark));

        // 弧线,需要 指定其椭圆的外接矩形
        // 矩形
        mArcRectF = new RectF((float) (length * 0.1), (float) (length * 0.1), (float)(length * 0.9),(float) (length * 0.9));
        mSweepAngle = (mSweepValue / 100f) * 360f;
        mArcPaint = new Paint();
        mArcPaint.setColor(getResources().getColor(android.R.color.holo_blue_bright));
        mArcPaint.setStrokeWidth((float) (length * 0.1));//圆弧宽度
        mArcPaint.setStyle(Style.STROKE);//圆弧
        // 文字,只需要设置好文字的起始绘制位置即可
        mShowText = "Android Skill";
        mShowTextSize = 50;
        mTextPaint = new Paint();
        mTextPaint.setTextSize(mShowTextSize);
        mTextPaint.setTextAlign(Paint.Align.CENTER);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        // 绘制圆
        canvas.drawCircle(mCircleXY, mCircleXY, mRadius, mCirclePaint);
        // 绘制圆弧,逆时针绘制,角度跟
        canvas.drawArc(mArcRectF, 90, mSweepAngle, false, mArcPaint);
        // 绘制文字
        canvas.drawText(mShowText, 0, mShowText.length(), mCircleXY, mCircleXY + mShowTextSize / 4, mTextPaint);
    }

    // 让调用者来设置不同的状态值,使弧形弧度变化
    public void setSweepValue(float sweepValue) {
        if (sweepValue != 0) {
            mSweepValue = sweepValue;
        } else {
            mSweepValue = 25;
        }
        // 这个方法可以刷新UI
        this.invalidate();
    }

}

可以通过在修改UI后通过调用this.invalidate()方法来重绘。

音频条形图

只演示自定义View的用法,不真实地监听音频输入,随机模拟一些数字即可。先看一下最终实现的效果图,如下图所示。

音频图

如果要实现这样的效果,也就是绘制一个个的矩形,每个矩形之间稍微偏移一点距离即可。为了实现动态效果,只要在onDraw()方法中再去调用invalidate()方法通知View进行重绘就可以了。如果直接重绘会刷新太快影响效果,因此,可以使用postInvalidateDelayed(300)来进行延时重绘。代码如下所示。

this.invalidate();
this.postInvalidateDelayed(300);

并且给绘制的Paint对象可以增加一个LinearGradient渐变效果。代码如下所示。

private int mWidth;//控件的宽度
private int mRectWidth;// 矩形的宽度
private int mRectHeight;// 矩形的高度
private Paint mPaint;
private int mRectCount;// 矩形的个数
private int offset = 5;// 偏移
private double mRandom;
private LinearGradient lg;// 渐变

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

public ScaleMap(Context context, @Nullable AttributeSet attrs) {
    super(context, attrs);
    initPaint();  // 这些要在这里设置,因为渐变效果
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    // 设置宽高
    setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec));
}

// 初始化画笔
private void initPaint() {
    mPaint = new Paint();
    mPaint.setColor(Color.GREEN);
    mPaint.setStyle(Paint.Style.FILL);
    mRectCount = 12;
}

//重写onDraw方法
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    for (int i = 0; i < mRectCount; i++) {
        mRandom = Math.random();
        float currentHeight = (int) (mRectHeight * mRandom);
        canvas.drawRect((float) (mWidth * 0.4 / 2 + mRectWidth * i + offset * i), currentHeight,
        (float) (mWidth * 0.4 / 2 + mRectWidth * (i + 1) + offset * i), mRectHeight, mPaint);
    }
    postInvalidateDelayed(300);
}

//重写onSizeChanged方法,给画笔加上渐变
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    mWidth = getWidth();
    mRectHeight = getHeight();
    mRectWidth = (int) (mWidth * 0.6 / mRectCount);
    lg = new LinearGradient(0, 0, mRectWidth, mRectHeight, Color.GREEN, Color.BLUE, TileMode.CLAMP);
    mPaint.setShader(lg);
}

自定义View一步一步来,从最基本的效果开始,慢慢增加功能,绘制更复杂的效果。

自定义ViewGroup

ViewGroup存在的目的就是为了对其子View进行管理,为其子View添加显示、响应的规则。因此,自定义ViewGroup通常重写onMeasure()方法来对子View进行测量,重写onLayout()方法来确定子View的位置,重写onTouchEvent()方法增加响应事件。
准备实现一个类似Android原生控件ScrollView的自定义ViewGroup,自定义ViewGroup实现ScrollView所具有的上下滑动功能,但是在滑动的过程中增加一个黏性的效果,即当一个子View向上滑动大于一定距离后,松开手指,它将自动向上滑动,显示下 个子View。同理,如果滑动小于一定的距离,松开手指,它将自动滑动到开始的位置。效果图如下所示。

自定义ViewGroup效果图

首先让自定义ViewGroup能够实现类似ScrollView的功能。
在ViewGroup能够滚动之前,需要先放置好它的子View。使用遍历的方式来通知子View对自身进行测量,代码如下所示。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int count = getChildCount();  // 返回子View的数量
    for (int i = 0; i < count; ++i) {
        View childView = getChildAt(i);  // 获取子View
        measureChild(childView, widthMeasureSpec, heightMeasureSpec);  // 调用子View的测量方法
    }
}

接下来,就要对子View进行放置位置的设定。让每个子View都显示完整的一屏,这样在滑动的时候,可以比较好地实现后面的效果。在放置子View前,需要确定整个ViewGroup的高度。在本例中,由于让每个子View占一屏的高度,因此整个ViewGroup的高度即子View的个数乘以屏幕的高度,我们通过如下代码来确定整个ViewGroup的高度。

// 设置ViewGroup的高度
MarginLayoutParams mlp = (MarginLayoutParams) getLayoutParams();
mlp.height = mScreenHeight * childCount;
setLayoutParams(mlp);

在获取了整个ViewGroup的高度之后,就可以通过遍历来设定每个子View需要放置的位置了,直接通过调用子View的layout()方法,并将具体的位置作为参数传递进去即可,代码如下所示。

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childCount = getChildCount();
    // 设置ViewGroup的高度
    MarginLayoutParams mlp = (MarginLayoutParams) getLayoutParams();
    mlp.height = mScreenHeight * childCount;
    setLayoutParams(mlp);
    for (int i = 0; i < childCount; ++i) {
        View child = getChildAt(i);
        if (child.getVisibility() != View.GONE) {
            child.layout(l, i * mScreenHeight, r, (i + 1) * mScreenHeight);
        }
    }
}

在代码中主要是去修改每个子View的top和bottom这两个属性,让它们能依次排列下来。
通过上面的步骤,就可以将子View放置到ViewGroup中了。但此时的ViewGroup还不能响应任何触控事件,自然也不能滑动,因此我们需要重写onTouchEvent()方法,为ViewGroup添加响应事件。在ViewGroup中添加滑动事件,通常可以使用scrollBy()方法来辅助滑动。在onTouchEvent()的ACTION_MOVE事件中,只要使用scrollBy(0,dy)方法,让手指滑动的时候让ViewGroup中的所有子View也跟着滚动dy即可,计算dy的方法有很多,如下代码就提供了一种思路。

case MotionEvent.ACTION_DOWN:
    mLastY = y;
    break;
case MotionEvent.ACTION_MOVE:
    if (!mScroller.isFinished()) {
        mScroller.abortAnimation();
    }
    int dy = mLastY - y;
    if (getScrollY() < 0 || getScrollY() > getHeight() - mScreenHeight) {
        dy = 0;
    }
    scrollBy(0, dy);
    mLastY = y;
    break;

按如上方法操作就可以实现类似ScrollView的滚动效果了。当然,系统的原生ScrollView有更大的功能,比如滑动的惯性效果等,这些功能可以在后面慢慢添加,这也是一个控件的迭代过程。
最后,我们来实现这个自定义ViewGroup的黏性效果。要实现手指离开后ViewGroup的黏性效果,我们很自然地想到onTouchEvent()的ACTION_UP事件和Scroller类。在ACTION_UP事件中判断手指滑动的距离,如果超过一定距离,则使用Scroller类来平滑移动到下一个子View;如果小于一定距离,则回滚到原来的位置,代码如下所示。

@Override
public boolean onTouchEvent(MotionEvent event) {
    int y = (int) event.getY();
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            // 记录触摸起点
            mStart = getScrollY();
            mLastY = y;
            break;
        case MotionEvent.ACTION_MOVE:
            if (!mScroller.isFinished()) {
                mScroller.abortAnimation();
            }
            int dy = mLastY - y;
            if (getScrollY() < 0 || getScrollY() > getHeight() - mScreenHeight) {
                dy = 0;
            }
            scrollBy(0, dy);
            mLastY = y;
            break;
        case MotionEvent.ACTION_UP:
            // 记录触摸终点
            mEnd = getScrollY();
            int dScrollY = mEnd - mStart;
            if (dScrollY > 0) {
                if (dScrollY < mScreenHeight / 3) {
                    mScroller.startScroll(
                            0, getScrollY(),
                            0, -dScrollY);
                } else {
                    mScroller.startScroll(
                            0, getScrollY(),
                            0, mScreenHeight - dScrollY);
                }
            } else {
                if (-dScrollY < mScreenHeight / 3) {
                    mScroller.startScroll(
                            0, getScrollY(),
                            0, -dScrollY);
                } else {
                    mScroller.startScroll(
                            0, getScrollY(),
                            0, -mScreenHeight - dScrollY);
                }
            }
            break;
    }
    postInvalidate();
    return true;
}

当然,最后不要忘记加上computeScroll()的代码,如下所示。

// 由父视图调用,用来请求子视图根据偏移值 mScrollX,mScrollY 重新绘制
@Override
public void computeScroll() {
    super.computeScroll();
    if(mScroller.computeScrollOffset()){
        scrollTo(0,mScroller.getCurrY());
        postInvalidate();
    }
}

为了易于控制滑屏控制,Android框架提供了 computeScroll()方法去控制这个流程。在绘制View时,会在draw()过程调用该方法。因此, 再配合使用Scroller实例,我们就可以获得当前应该的偏移坐标,手动使View/ViewGroup偏移至该处。

更多内容戳这里(整理好的各种文集)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343

推荐阅读更多精彩内容