自定义View毕竟不是规范的控件,如果设计不好、不考虑性能反而会适得其反,另外适配起来可能也会产生问题。如果能用系统控件的情况还是应尽量用系统控件。

自定义View可以分为三大类

  • 第一种是自定义View,自定义View又分为继承系统控件(比如TextView)和继承View两种
  • 第二种是自定义ViewGroup,自定义ViewGroup也分为继承ViewGroup和继承系统特定的 ViewGroup(比如RelativeLayout
  • 第三种是自定义组合控件

1 继承系统控件的自定义View

这种自定义View在系统控件的基础上进行拓展,一般是添加新的功能或者修改显示的效果,一般情况下在onDraw()方法中进行处理。 这里举一个简单的例子,写一个自定义View,继承自TextView

public class InvalidTextView extends AppCompatTextView {

  private Paint mPatin = new Paint(Paint.ANTI_ALIAS_FLAG);

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

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

  public InvalidTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    initDraw();
  }

  private void initDraw() {
    mPatin.setColor(Color.RED);
    mPatin.setStrokeWidth((float) 1.5);
  }

  @Override
  protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    int width = getWidth();
    int height = getHeight();
    canvas.drawLine(0, height / 2, width, height / 2, mPatin);
  }
}

这个自定义View继承了TextView,并且在onDraw()方法中画了一条红色的横线。接下来在布局中引用这个InvalidTextView,代码如下所示:

<com.cah.androidtest.InvalidTextView
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:background="@android:color/holo_blue_light"
        android:gravity="center"
        android:textSize="16sp" />

运行程序:

ios 自定义view在xib使用 自定义view几种方式_自定义

2 继承View的自定义View

与继承系统控件的自定义View不同,继承View的自定义View实现起来要稍微复杂一些。其不只是要实现onDraw()方法,而且在实现过程中还要考虑到wrap_content属性以及padding属性的设置;为了 方便配置自己的自定义View,还会对外提供自定义的属性。另外,如果要改变触控的逻辑,还要重写onTouchEvent()等触控事件的方法。按照上面的例子再写一个RectView类继承View来画一个正方形。

2.1 简单实现继承View的自定义View
public class RectView extends View {

    private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    private int mColor = Color.RED;

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

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

    public RectView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initDraw();
    }

    private void initDraw() {
        mPaint.setColor(mColor);
        mPaint.setStrokeWidth((float) 1.5);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int width = getWidth();
        int height = getHeight();
        canvas.drawRect(0, 0, width, height, mPaint);
    }
}

最后在布局中引用RectView,如下所示:

<com.cah.androidtest.RectView
        android:layout_width="200dp"
        android:layout_height="200dp" />

运行程序:

ios 自定义view在xib使用 自定义view几种方式_ios_02

2.2 对padding属性进行处理

修改布局文件,设置padding属性,如下所示:

<com.cah.androidtest.RectView
        android:layout_width="200dp"
        android:layout_height="200dp"
        android:padding="20dp" />

运行程序,发现没有任何作用。看来还得对padding属性进行处理。只需要在onDraw()方法中稍加修改,在绘制正方形的时候考虑padding属性即可,代码如下所示:

@Override
protected void onDraw(Canvas canvas) {
  super.onDraw(canvas);
  int paddingLeft = getPaddingLeft();
  int paddingRight = getPaddingRight();
  int paddingTop = getPaddingTop();
  int paddingBottom = getPaddingBottom();
  int width = getWidth() - paddingLeft - paddingRight;
  int height = getHeight() - paddingTop - paddingBottom;
  canvas.drawRect(0 + paddingLeft, 0 + paddingTop, width + paddingRight, height + paddingBottom, mPaint);
}

运行程序,效果如图所示。对比一下,可以发现设置的padding属性生效了:

ios 自定义view在xib使用 自定义view几种方式_html5_03

2.3 对wrap_content属性进行处理示

修改布局文件,让RectView的宽度分别为wrap_contentmatch_parent时的效果都是一样的,如图所示:

<com.cah.androidtest.RectView
       android:layout_width="match_parent"
        android:layout_height="200dp"
        android:padding="20dp" />

<com.cah.androidtest.RectView
        ndroid:layout_width="wrap_content"
        android:layout_height="200dp"
        android:layout_marginTop="20dp"
        android:padding="20dp" />

ios 自定义view在xib使用 自定义view几种方式_自定义_04

对于这种情况需要在onMeasure方法中指定一个默认的宽和高,在设置wrap_content属性时设置此默认的宽和高就可以了:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
  super.onMeasure(widthMeasureSpec, heightMeasureSpec);
  int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
  int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
  int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
  int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
  if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
    setMeasuredDimension(300, 300);
  } else if (widthSpecMode == MeasureSpec.AT_MOST) {
    setMeasuredDimension(300, heightSpecSize);
  } else {
    setMeasuredDimension(widthSpecSize, 300);
  }
}

需要注意的是setMeasuredDimension()方法接收的参数单位是px,如图所示:

ios 自定义view在xib使用 自定义view几种方式_html5_05

2.4 自定义属性

Android系统的控件以android开头的(比如android:layout_width)都是系统自带的属性。为了方便配置RectView的属性,我们也可以自定义属性。首先在values目录下创建attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <declare-styleable name="RectView">
    <attr name="rect_color" format="color" />
  </declare-styleable>
</resources>

这个配置文件定义了名为RectView的自定义属性组合。定义了rect_color属性,它的格式为color。 接下来在RectView的构造方法中解析自定义属性的值,如下所示:

public RectView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
  super(context, attrs, defStyleAttr);
  TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.RectView);
  // 提取RectView属性集合的rect_color属性。如果没设置,默认值为Color.RED
  mColor = array.getColor(R.styleable.RectView_rect_color, Color.RED);
  // 获取资源后要及时回收
  array.recycle();

  initDraw();
}

TypedArray来获取自定义的属性集R.styleable.RectView,这个RectView就是我们在XML中定义的name的值,然后通过TypedArraygetColor方法来获取自定义的属性值。最后修改布局文件,如下所示:

<com.cah.androidtest.RectView
        android:layout_width="wrap_content"
        android:layout_height="200dp"
        android:padding="20dp"
        app:rect_color="@android:color/holo_blue_light" />

使用自定义属性需要添加schemas:xmlns:app="http://schemas.android.com/apk/res-auto",其中app是 我们自定义的名字。最后我们配置新定义的app:rect_color属性为android:color/holo_blue_light。运行程序发现RectView的颜色变成了蓝色:

ios 自定义view在xib使用 自定义view几种方式_android_06

RectView的完整代码,如下所示:

public class RectView extends View {

  private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
  private int mColor = Color.RED;

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

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

  public RectView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.RectView);
    // 提取RectView属性集合的rect_color属性。如果没设置,默认值为Color.RED
    mColor = array.getColor(R.styleable.RectView_rect_color, Color.RED);
    // 获取资源后要及时回收
    array.recycle();

    initDraw();
  }

  private void initDraw() {
    mPaint.setColor(mColor);
    mPaint.setStrokeWidth((float) 1.5);
  }

  @Override
  protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
    int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
    int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
    if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
      setMeasuredDimension(300, 300);
    } else if (widthSpecMode == MeasureSpec.AT_MOST) {
      setMeasuredDimension(300, heightSpecSize);
    } else {
      setMeasuredDimension(widthSpecSize, 300);
    }
  }

  @Override
  protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    int paddingLeft = getPaddingLeft();
    int paddingRight = getPaddingRight();
    int paddingTop = getPaddingTop();
    int paddingBottom = getPaddingBottom();
    int width = getWidth() - paddingLeft - paddingRight;
    int height = getHeight() - paddingTop - paddingBottom;
    canvas.drawRect(0 + paddingLeft, 0 + paddingTop, width + paddingRight, height + paddingBottom, mPaint);
  }
}

3 自定义组合控件

自定义组合控件就是多个控件组合起来成为一个新的控件,其主要用于解决多次重复地使用同一类型的布局。比如我们应用的顶部标题栏及弹出的固定样式的Dialog,这些都是常用的,所以把它们所需要的控件组合起来重新定义成一个新的控件。本节就来自定义一个顶部的标题栏。

首先,定义组合控件的布局:

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

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_alignParentLeft="true"
        android:layout_centerInParent="true"
        android:paddingLeft="15dp"
        android:paddingRight="15dp"
        android:src="@mipmap/ic_launcher_round" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:ellipsize="end"
        android:maxEms="11"
        android:singleLine="true"
        android:textStyle="bold" />

    <ImageView
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_alignParentRight="true"
        android:layout_centerInParent="true"
        android:padding="15dp"
        android:src="@mipmap/ic_launcher_round" />

</RelativeLayout>

这是很简单的布局,左右边各一个图标,中间是标题文字。接下来编写Java代码。因为组合控件整体布局是RelativeLayout,所以组合控件要继承RelativeLayout,代码如下所示:

public class TitleBar extends RelativeLayout {
  private ImageView iv_bar_left;
  private ImageView iv_bar_right;
  private TextView tv_bar_title;
  private RelativeLayout bar_root;

  private int mColor = Color.BLUE;
  private int mTextColor = Color.WHITE;

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

  public TitleBar(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
  }

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

  private void initView(Context context) {
    LayoutInflater.from(context).inflate(R.layout.title_bar, this, true);
    bar_root = findViewById(R.id.bar_root);
    iv_bar_left = findViewById(R.id.iv_bar_left);
    iv_bar_right = findViewById(R.id.iv_bar_right);
    tv_bar_title = findViewById(R.id.tv_bar_title);

    // 设置背景颜色
    bar_root.setBackgroundColor(mColor);
    // 设置标齐文字颜色
    tv_bar_title.setTextColor(mTextColor);
  }

  public void setTitle(String title) {
    if (!TextUtils.isEmpty(title)) {
      tv_bar_title.setText(title);
    }
  }

  public void setLeftListener(OnClickListener listener) {
    iv_bar_left.setOnClickListener(listener);
  }

  public void setRightListener(OnClickListener listener) {
    iv_bar_right.setOnClickListener(listener);
  }
}

这里重写了3个构造方法并在构造方法中加载布局文件,对外提供了3个方法,分别用来设置标题的名 字,以及左右按钮的点击事件。前面讲到了自定义属性,这里同样使用自定义属性,在values目录下创建attrs.xml,代码如下所示:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="TitleBar">
        <attr name="title_text_color" format="color" />
        <attr name="title_bg" format="color" />
        <attr name="title_text" format="string" />
    </declare-styleable>
</resources>

定义了3个属性,分别用来设置顶部标题栏的背景颜色、标题文字颜色和标题文字。为了引入自定义属性,需要在TitleBar的构造方法中解析自定义属性的值,代码如下所示:

public TitleBar(Context context, AttributeSet attrs, int defStyleAttr) {
  super(context, attrs, defStyleAttr);
  TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.TitleBar);
  mColor = array.getColor(R.styleable.TitleBar_title_bg, Color.BLUE);
  mTextColor = array.getColor(R.styleable.TitleBar_title_text_color, Color.WHITE);
  mTitleName = array.getString(R.styleable.TitleBar_title_text);
  array.recycle();

  initView(context);
}

接下来引用组合控件的布局。使用自定义属性需要添加schemas:xmlns: app="http://schemas.android.com/apk/res-auto"

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

    <com.cah.androidtest.TitleBar
        android:id="@+id/title"
        android:layout_width="match_parent"
        android:layout_height="45dp"
        app:title_bg="@android:color/holo_orange_dark"
        app:title_text="自定义组件控件"
        app:title_text_color="@android:color/holo_blue_dark" />

</RelativeLayout>

最后,在主界面调用自定义的TitleBar,并设置了左右两边按钮的点击事件:

public class MainActivity extends AppCompatActivity {
    
    private TitleBar mTitleBar;
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mTitleBar = findViewById(R.id.title);
        mTitleBar.setTitle("自定义组合控件");

        mTitleBar.setLeftListener(v -> Toast.makeText(MainActivity.this, "点击左键", Toast.LENGTH_SHORT).show());
        mTitleBar.setRightListener(v -> Toast.makeText(MainActivity.this, "点击右键", Toast.LENGTH_SHORT).show());
    }

}

运行程序:

ios 自定义view在xib使用 自定义view几种方式_ios 自定义view在xib使用_07

4 自定义ViewGroup

自定义ViewGroup又分为继承ViewGroup和继承系统特定的ViewGroup(比如RelativeLayout),其中继承系统特定的ViewGroup比较简单,主要介绍继承ViewGroup。本节的例子是一个自定义的ViewGroup,左右滑动切换不同的页面,类似一个特别简化的ViewPager

4.1 继承ViewGroup

要实现自定义的ViewGroup,首先要继承ViewGroup并调用父类的构造方法,实现抽象方法等:

public class HorizontalView extends ViewGroup {
  public HorizontalView(Context context) {
    this(context, null);
  }

  public HorizontalView(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
  }

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

  @Override
  protected void onLayout(boolean changed, int l, int t, int r, int b) {

  }
}

这里定义了名为HorizontalView的类并继承ViewGrouponLayout这个抽象方法是必须要实现的, 暂且什么都不做。

4.2 对wrap_content属性进行处理

接下来的代码对wrap_content属性进行处理,其中省略了此前的构造方法代码:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
  super.onMeasure(widthMeasureSpec, heightMeasureSpec);
  int widthMode = MeasureSpec.getMode(widthMeasureSpec);
  int widthSize = MeasureSpec.getSize(widthMeasureSpec);
  int heightMode = MeasureSpec.getMode(heightMeasureSpec);
  int heightSize = MeasureSpec.getSize(heightMeasureSpec);

  measureChildren(widthMeasureSpec, heightMeasureSpec);
  if (getChildCount() == 0) {
    setMeasuredDimension(0, 0);
  } else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
    View childOne = getChildAt(0);
    int childWidth = childOne.getMeasuredWidth();
    int childHeight = childOne.getMeasuredHeight();
    setMeasuredDimension(childWidth * getChildCount(), childHeight);
  } else if (widthMode == MeasureSpec.AT_MOST) {
    int childWidth = getChildAt(0).getMeasuredHeight();
    setMeasuredDimension(childWidth * getChildCount(), heightSize);
  } else if (heightMode == MeasureSpec.AT_MOST) {
    int childHeight = getChildAt(0).getMeasuredHeight();
    setMeasuredDimension(widthSize, childHeight);
  }
}

这里如果没有子元素,则采用简化的写法,将宽和高直接设置为0。正常的话,应该根据LayoutParams中的宽和高来做相应的处理。接着根据widthModeheightMode来分别设置HorizontalView的宽和高。另外,在测量时没有考虑HorizontalViewpadding和子元素的margin

4.3 实现onLayout

接下来实现onLayout来布局子元素。因为对每一种布局方式,子View的布局都是不同的,所以这是ViewGroup唯一一个抽象方法,需要去实现,代码如下所示:

int childWidth = 0;

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
  int childCount = getChildCount();
  int left = 0;
  View child;
  for (int i = 0; i < childCount; i++) {
    child = getChildAt(i);
    if (child.getVisibility() != View.GONE) {
      int width = child.getMeasuredWidth();
      childWidth = width;
      child.layout(left, 0, left + width, child.getMeasuredHeight());
      left += width;
    }
  }
}

遍历所有的子元素。如果子元素不是GONE,则调用子元素的layout方法将其放置到合适的位置上。这相当于默认第一个子元素占满了屏幕,后面的子元素就是在第一个屏幕后面紧挨着和屏幕一样大小的后续 元素。所以left是一直累加的,top保持为0bottom保持为第一个元素的高度,right就是left + width元素的宽度。同样,这里没有处理HorizontalViewpadding以及子元素的margin

4.4 处理滑动冲突

这个自定义ViewGroup为水平滑动,如果里面是ListViewListView则为垂直滑动,这样会导致滑动的冲突。解决的方法就是,如果我们检测到的滑动方向是水平的话,就让父View进行拦截,确保父View用来进行View的滑动切换:

private int lastInterceptX;
private int lastInterceptY;
private int lastX;
private int lastY;

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
  boolean intercept = false;
  int x = (int) event.getX(); // 1
  int y = (int) event.getY();
  switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:

      break;
    case MotionEvent.ACTION_MOVE:
      int deltaX = x - lastInterceptX;
      int deltaY = y - lastInterceptY;
      if (Math.abs(deltaX) - Math.abs(deltaY) > 0) { // 2
        intercept = true;
      }
      break;
    case MotionEvent.ACTION_UP:

      break;
  }

  lastX = x;
  lastY = y;
  lastInterceptX = x;
  lastInterceptY = y;

  return intercept;
}

@Override
public boolean onTouchEvent(MotionEvent event) {
  return super.onTouchEvent(event);
}

在上面代码注释1处,在刚进入onInterceptTouchEvent方法时就得到了点击事件的坐标,在MotionEvent.ACTION_MOVE中计算每次手指移动的距离,并在注释2处判断用户是水平滑动还是垂直滑动,如果是水平滑动则设置intercept = true来进行拦截,这样事件则由HorizontalViewonTouchEvent方法来处理。

4.5 弹性滑动到其他页面

接着处理onTouchEvent事件,在onTouchEvent方法里需要进行滑动切换页面,这里就需要用到Scroller

int currentIndex = 0; // 当前子元素
private Scroller scroller;

@Override
public boolean onTouchEvent(MotionEvent event) {
  int x = (int) event.getX();
  int y = (int) event.getY();
  switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:

      break;
    case MotionEvent.ACTION_MOVE:
      int deltaX = x - lastX;
      scrollBy(-deltaX, 0);
      break;
    case MotionEvent.ACTION_UP:
      int distance = getScrollX() - currentIndex * childWidth;
      if (Math.abs(distance) > childWidth / 2) { // 1
        if (distance > 0) {
          currentIndex++;
        } else {
          currentIndex--;
        }
      }
      smoothScrollTo(currentIndex * childWidth, 0); // 2
      break;
  }
  lastX = x;
  lastY = y;
  return super.onTouchEvent(event);
}

@Override
public void computeScroll() {
  super.computeScroll();
  if (scroller.computeScrollOffset()) {
    scrollTo(scroller.getCurrX(), scroller.getCurrY());
    postInvalidate();
  }
}

// 弹性滑动到指定位置
public void smoothScrollTo(int destX, int destY) {
  scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(), destY - getScrollY(), 1000);
  invalidate();
}

同样,在刚进入onTouchEvent方法时就得到点击事件的坐标,在MotionEvent.ACTION_MOVE中用scrollBy方法来处理HorizontalView控件随手指滑动的效果。在上面代码注释1处判断滑动的距离是否大于宽度的1/2,如果大于则切换到其他页面,然后调用Scroller来进行弹性滑动。

4.6 快速滑动到其他页面

通常情况下,只在滑动超过一半时才切换到上/下一个页面是不够的。如果滑动速度很快的话,也可以判定为用户想要滑动到其他页面,这样的体验也是好的。需要在onTouchEvent方法的ACTION_UP中对快速滑动进行处理。在这里又需要用到VelocityTracker,它是用来测试滑动速度的。使用方法也很简单, 首先在构造方法中进行初始化,也就是在前面的init方法中增加一条语句,代码如下所示:

private VelocityTracker tracker;

public void init() {
  scroller = new Scroller(getContext());
  tracker = VelocityTracker.obtain();
}

init方法中对VelocityTracker进行初始化。接着开始改写onTouchEvent部分,如下所示:

@Override
public boolean onTouchEvent(MotionEvent event) {
  int x = (int) event.getX();
  int y = (int) event.getY();
  switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:

      break;
    case MotionEvent.ACTION_MOVE:
      int deltaX = x - lastX;
      scrollBy(-deltaX, 0);
      break;
    case MotionEvent.ACTION_UP:
      int distance = getScrollX() - currentIndex * childWidth;
      if (Math.abs(distance) > childWidth / 2) { // 1
        if (distance > 0) {
          currentIndex++;
        } else {
          currentIndex--;
        }
      } else {
        tracker.computeCurrentVelocity(1000); // 1
        float xV = tracker.getXVelocity();
        if (Math.abs(xV) > 50) { // 2
          if (xV > 0) { // 切换到上一个页面
            currentIndex--;
          } else { // 切换到下一个页面
            currentIndex++;
          }
        }
      }
      currentIndex = currentIndex < 0 ? 0 : currentIndex > getChildCount() - 1 ? getChildCount() - 1 : currentIndex;
      smoothScrollTo(currentIndex * childWidth, 0); // 2
      tracker.clear(); // 3
      break;
  }
  lastX = x;
  lastY = y;
  return super.onTouchEvent(event);
}

在上面代码注释1处获取水平方向的速度;接着在注释2处,如果速度的绝对值大于50的话,就被认为是“快速滑动”,执行切换页面。不要忘了在注释3处重置速度计算器。

4.7 再次触摸屏幕阻止页面继续滑动

如果有如下的场景:当快速向左滑动切换到下一个页面时,在手指释放以后,页面会弹性滑动到下一个页面,这可能需要1秒才完成滑动,在这个时间内,再次触摸屏幕,希望能拦截这次滑动,然后再次去操作页面。要实现在弹性滑动过程中再次触摸拦截,肯定要在onInterceptTouchEventACTION_DOWN中去判断。如果在ACTION_DOWN的时候,Scroller还没有执行完毕,说明上一次的滑动还正在进行中,则直接中断Scroller,代码如下所示:

@Override
public boolean onInterceptTouchEvent(MotionEvent event) {
  boolean intercept = false;
  int x = (int) event.getX();
  int y = (int) event.getY();
  switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
      intercept = false;
      if (!scroller.isFinished()) { // 1
        scroller.abortAnimation();
      }
      break;
    case MotionEvent.ACTION_MOVE:
      int deltaX = x - lastInterceptX;
      int deltaY = y - lastInterceptY;
      if (Math.abs(deltaX) - Math.abs(deltaY) > 0) {
        intercept = true;
      } else {
        intercept = false;
      }
      break;
    case MotionEvent.ACTION_UP:
      intercept = false;
      break;
  }

  lastX = x; // 2
  lastY = y;
  lastInterceptX = x;
  lastInterceptY = y;

  return intercept;
}

主要逻辑在上面代码注释1处,如果Scroller没有执行完成,则调用ScrollerabortAnimation方法来打断Scroller。因为 onInterceptTouchEvent方法的ACTION_DOWN返回false,所以在onTouchEvent方法中无法获取DOWN事件,故而需要在注释2处设置lastXlastY这两个参数。

4.8 应用HorizontalView

首先,在主布局中引用HorizontalView,它作为父容器,里面有两个ListView。布局文件如下所示:

<?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=".MainActivity">

    <com.cah.androidtest.HorizontalView
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ListView
            android:id="@+id/lv_one"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

        <ListView
            android:id="@+id/lv_two"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

    </com.cah.androidtest.HorizontalView>

</RelativeLayout>

接着,在代码中为ListView填加数据:

public class MainActivity extends AppCompatActivity {

  private ListView lv_one;
  private ListView lv_two;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    lv_one = findViewById(R.id.lv_one);
    lv_two = findViewById(R.id.lv_two);
    String[] strs1 = {"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15"};
    ArrayAdapter<String> adapter1 = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, strs1);
    lv_one.setAdapter(adapter1);
    String[] strs2 = {"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O"};
    ArrayAdapter<String> adapter2 = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, strs2);
    lv_two.setAdapter(adapter2);
  }
}

运行程序,效果如图所示。当向右滑动的时候,界面会滑动到第二个ListView,如图所示:

ios 自定义view在xib使用 自定义view几种方式_android_08

ios 自定义view在xib使用 自定义view几种方式_html5_09

最后贴上HorizontalView的整个源码:

public class HorizontalView extends ViewGroup {

  private int lastX;
  private int lastY;
  int currentIndex = 0; // 当前子元素
  int childWidth = 0;
  private Scroller scroller;
  private VelocityTracker tracker;
  private int lastInterceptX = 0;
  private int lastInterceptY = 0;


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

  public HorizontalView(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
  }

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

  public void init() {
    scroller = new Scroller(getContext());
    tracker = VelocityTracker.obtain();
  }

  @Override
  protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);

    measureChildren(widthMeasureSpec, heightMeasureSpec);
    if (getChildCount() == 0) {
      setMeasuredDimension(0, 0);
    } else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
      View childOne = getChildAt(0);
      int childWidth = childOne.getMeasuredWidth();
      int childHeight = childOne.getMeasuredHeight();
      setMeasuredDimension(childWidth * getChildCount(), childHeight);
    } else if (widthMode == MeasureSpec.AT_MOST) {
      int childWidth = getChildAt(0).getMeasuredHeight();
      setMeasuredDimension(childWidth * getChildCount(), heightSize);
    } else if (heightMode == MeasureSpec.AT_MOST) {
      int childHeight = getChildAt(0).getMeasuredHeight();
      setMeasuredDimension(widthSize, childHeight);
    }
  }

  @Override
  protected void onLayout(boolean changed, int l, int t, int r, int b) {
    int childCount = getChildCount();
    int left = 0;
    View child;
    for (int i = 0; i < childCount; i++) {
      child = getChildAt(i);
      if (child.getVisibility() != View.GONE) {
        int width = child.getMeasuredWidth();
        childWidth = width;
        child.layout(left, 0, left + width, child.getMeasuredHeight());
        left += width;
      }
    }
  }

  @Override
  public boolean onInterceptTouchEvent(MotionEvent event) {
    boolean intercept = false;
    int x = (int) event.getX();
    int y = (int) event.getY();
    switch (event.getAction()) {
      case MotionEvent.ACTION_DOWN:
        intercept = false;
        if (!scroller.isFinished()) { // 1
          scroller.abortAnimation();
        }
        break;
      case MotionEvent.ACTION_MOVE:
        int deltaX = x - lastInterceptX;
        int deltaY = y - lastInterceptY;
        intercept = Math.abs(deltaX) - Math.abs(deltaY) > 0;
        break;
      case MotionEvent.ACTION_UP:
        intercept = false;
        break;
    }

    lastX = x; // 2
    lastY = y;
    lastInterceptX = x;
    lastInterceptY = y;

    return intercept;
  }

  @Override
  public boolean onTouchEvent(MotionEvent event) {
    tracker.addMovement(event);
    int x = (int) event.getX();
    int y = (int) event.getY();
    switch (event.getAction()) {
      case MotionEvent.ACTION_DOWN:
        if (!scroller.isFinished()) {
          scroller.abortAnimation();
        }
        break;
      case MotionEvent.ACTION_MOVE:
        int deltaX = x - lastX;
        scrollBy(-deltaX, 0);
        break;
      case MotionEvent.ACTION_UP:
        int distance = getScrollX() - currentIndex * childWidth;
        if (Math.abs(distance) > childWidth / 2) { // 1
          if (distance > 0) {
            currentIndex++;
          } else {
            currentIndex--;
          }
        } else {
          tracker.computeCurrentVelocity(1000); // 1
          float xV = tracker.getXVelocity();
          if (Math.abs(xV) > 50) { // 2
            if (xV > 0) {
              currentIndex--;
            } else {
              currentIndex++;
            }
          }
        }
        currentIndex = currentIndex < 0 ? 0 : Math.min(currentIndex, getChildCount() - 1);
        smoothScrollTo(currentIndex * childWidth, 0); // 2
        tracker.clear();
        break;
      default:
        break;
    }
    lastX = x;
    lastY = y;
    return true;
  }

  @Override
  public void computeScroll() {
    super.computeScroll();
    if (scroller.computeScrollOffset()) {
      scrollTo(scroller.getCurrX(), scroller.getCurrY());
      postInvalidate();
    }
  }

  // 弹性滑动到指定位置
  public void smoothScrollTo(int destX, int destY) {
    scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(), destY - getScrollY(), 1000);
    invalidate();
  }

}

自定义图片圆角:

attrs.xml

<declare-styleable name="RoundImageView">
  <attr name="image_radius" format="dimension" />//圆角大小
  <attr name="image_shadow_radius" format="dimension" />//阴影大小
  <attr name="image_circle" format="boolean" />//是否圆形
  <attr name="image_shadow" format="boolean" />//是否阴影
  <attr name="shadow_color" format="integer"/>//阴影颜色
</declare-styleable>

代码:

// 
public class RoundImageView extends AppCompatImageView {

  public RoundImageView(final Context context) {
    this(context, null);
  }

  public RoundImageView(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
  }

  public RoundImageView(Context context, AttributeSet attrs, int defStyle) {
    super(context, attrs, defStyle);
    this.setScaleType(ScaleType.FIT_XY);
    TypedArray ta = context.obtainStyledAttributes(attrs,
                                                   R.styleable.RoundImageView, defStyle, 0);
    if (ta!=null){
      mRadius = ta.getDimension(R.styleable.RoundImageView_image_radius, 0);
      mShadowRadius = ta.getDimension(R.styleable.RoundImageView_image_shadow_radius, 0);
      mIsCircle = ta.getBoolean(R.styleable.RoundImageView_image_circle, false);
      mIsShadow = ta.getBoolean(R.styleable.RoundImageView_image_shadow, false);
      mShadowColor = ta.getInteger(R.styleable.RoundImageView_shadow_color,0xffe4e4e4);
      ta.recycle();
    }else {
      mRadius = 0;
      mShadowRadius = 0;
      mIsCircle = false;
      mIsShadow = false;
      mShadowColor = 0xffe4e4e4;
    }

  }


  private float mRadius;
  private float mShadowRadius;
  private int mShadowColor;
  private boolean mIsCircle;
  private boolean mIsShadow;
  private int width;
  private int height;
  private int imageWidth;
  private int imageHeight;
  private Paint mPaint;

  @Override
  public void onDraw(Canvas canvas) {
    width = canvas.getWidth() - getPaddingLeft() - getPaddingRight();//控件实际大小
    height = canvas.getHeight() - getPaddingTop() - getPaddingBottom();

    if (!mIsShadow)
      mShadowRadius = 0;

    imageWidth = width - (int) mShadowRadius * 2;
    imageHeight = height - (int) mShadowRadius * 2;

    Bitmap image = drawableToBitmap(getDrawable());
    Bitmap reSizeImage = reSizeImage(image, imageWidth, imageHeight);
    initPaint();


    if (mIsCircle) {
      canvas.drawBitmap(createCircleImage(reSizeImage),
                        getPaddingLeft(), getPaddingTop(), null);

    } else {
      canvas.drawBitmap(createRoundImage(reSizeImage),
                        getPaddingLeft(), getPaddingTop(), null);
    }
  }

  private void initPaint() {
    mPaint = new Paint();
    mPaint.setAntiAlias(true);
  }


  private Bitmap createRoundImage(Bitmap bitmap) {
    if (bitmap == null) {
      throw new NullPointerException("Bitmap can't be null");
    }
    BitmapShader bitmapShader = new BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
    Bitmap targetBitmap = Bitmap.createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888);
    Canvas targetCanvas = new Canvas(targetBitmap);

    mPaint.setShader(bitmapShader);

    RectF rect = new RectF(0, 0, imageWidth, imageHeight);
    targetCanvas.drawRoundRect(rect, mRadius, mRadius, mPaint);

    if (mIsShadow){
      mPaint.setShader(null);
      mPaint.setColor(mShadowColor);
      mPaint.setShadowLayer(mShadowRadius, 1, 1, mShadowColor);
      Bitmap target = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
      Canvas canvas = new Canvas(target);

      RectF rectF = new RectF(mShadowRadius, mShadowRadius, width - mShadowRadius, height - mShadowRadius);
      canvas.drawRoundRect(rectF, mRadius, mRadius, mPaint);
      mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_OVER));
      mPaint.setShadowLayer(0, 0, 0, 0xffffff);
      canvas.drawBitmap(targetBitmap, mShadowRadius, mShadowRadius, mPaint);
      return target;
    }else {
      return targetBitmap;
    }

  }


  private Bitmap createCircleImage(Bitmap bitmap) {
    if (bitmap == null) {
      throw new NullPointerException("Bitmap can't be null");
    }
    BitmapShader bitmapShader = new BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP);
    Bitmap targetBitmap = Bitmap.createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888);
    Canvas targetCanvas = new Canvas(targetBitmap);

    mPaint.setShader(bitmapShader);

    targetCanvas.drawCircle(imageWidth / 2, imageWidth / 2, Math.min(imageWidth, imageHeight) / 2,
                            mPaint);

    if (mIsShadow){
      mPaint.setShader(null);
      mPaint.setColor(mShadowColor);
      mPaint.setShadowLayer(mShadowRadius, 1, 1, mShadowColor);
      Bitmap target = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
      Canvas canvas = new Canvas(target);

      canvas.drawCircle(width / 2, height / 2, Math.min(imageWidth, imageHeight) / 2,
                        mPaint);
      mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_OVER));
      mPaint.setShadowLayer(0, 0, 0, 0xffffff);
      canvas.drawBitmap(targetBitmap, mShadowRadius, mShadowRadius, mPaint);
      return target;
    }else {
      return targetBitmap;
    }

  }


  @Override
  protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int width = MeasureSpec.getSize(widthMeasureSpec);
    int height = MeasureSpec.getSize(heightMeasureSpec);
    setMeasuredDimension(width, height);
  }

  /**
     * drawable转bitmap
     *
     * @param drawable
     * @return
     */
  private Bitmap drawableToBitmap(Drawable drawable) {
    if (drawable == null) {
      return null;
    } else if (drawable instanceof BitmapDrawable) {
      return ((BitmapDrawable) drawable).getBitmap();
    }
    Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicHeight(),
                                        drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);
    Canvas canvas = new Canvas(bitmap);
    drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
    drawable.draw(canvas);
    return bitmap;
  }

  /**
     * 重设Bitmap的宽高
     *
     * @param bitmap
     * @param newWidth
     * @param newHeight
     * @return
     */
  private Bitmap reSizeImage(Bitmap bitmap, int newWidth, int newHeight) {
    int width = bitmap.getWidth();
    int height = bitmap.getHeight();
    // 计算出缩放比
    float scaleWidth = ((float) newWidth) / width;
    float scaleHeight = ((float) newHeight) / height;
    // 矩阵缩放bitmap
    Matrix matrix = new Matrix();
    matrix.postScale(scaleWidth, scaleHeight);
    return Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, true);
  }
}

5 总结

大多数自定义View要么是在onDraw方法中绘制一些内容,要么在onTouchEvent方法中处理触摸事件。 自定义View步骤 :

  • onMeasure方法,可以不重写,不重写的话就要在外面指定宽高,建议重写;
  • onDraw方法,视情况而定,如果需要绘制一些内容,就重写;
  • onTouchEvent,视情况而定,如果要做能和手势交互的View,就重写;

自定义View注意事项:

  • 如果是有自定义布局属性的,在构造方法中取得属性后应及时调用recycle方法回收资源;
  • onDraw方法和onTouchEvent方法中都应尽量避免创建对象,过多操作可能会造成卡顿;

自定义ViewGroup步骤:

  • onMeasure方法,必须重写,在此方法中测量每一个子View,还有处理自身的尺寸;
  • onLayout方法,必须重写,在这里对子View进行布局;
  • 如有自己的触摸事件,需要重写onInterceptTouchEventonTouchEvent;

自定义ViewGroup注意事项:

  • 如果想在ViewGroup中绘制一些内容,又没有在布局中设置background的话,是绘制不出来的,这时候需要调用setWillNotDraw方法,并设置为false
  • 如果有自定义布局属性的,在构造方法中取得属性后应及时调用recycle方法回收资源;
  • onDraw方法和onTouchEvent方法中都应尽量避免创建对象,过多操作可能会造成卡顿;

参考

https://www.jianshu.com/p/32e335d5b842