自定义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" />
运行程序:
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" />
运行程序:
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
属性生效了:
2.3 对wrap_content
属性进行处理示
修改布局文件,让RectView
的宽度分别为wrap_content
和match_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" />
对于这种情况需要在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
,如图所示:
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
的值,然后通过TypedArray
的getColor
方法来获取自定义的属性值。最后修改布局文件,如下所示:
<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
的颜色变成了蓝色:
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());
}
}
运行程序:
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
的类并继承ViewGroup
,onLayout
这个抽象方法是必须要实现的, 暂且什么都不做。
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
中的宽和高来做相应的处理。接着根据widthMode
和heightMode
来分别设置HorizontalView
的宽和高。另外,在测量时没有考虑HorizontalView
的padding
和子元素的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
保持为0
,bottom
保持为第一个元素的高度,right
就是left + width
元素的宽度。同样,这里没有处理HorizontalView
的padding
以及子元素的margin
。
4.4 处理滑动冲突
这个自定义ViewGroup
为水平滑动,如果里面是ListView
,ListView
则为垂直滑动,这样会导致滑动的冲突。解决的方法就是,如果我们检测到的滑动方向是水平的话,就让父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
来进行拦截,这样事件则由HorizontalView
的onTouchEvent
方法来处理。
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
秒才完成滑动,在这个时间内,再次触摸屏幕,希望能拦截这次滑动,然后再次去操作页面。要实现在弹性滑动过程中再次触摸拦截,肯定要在onInterceptTouchEvent
的ACTION_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
没有执行完成,则调用Scroller
的abortAnimation
方法来打断Scroller
。因为 onInterceptTouchEvent
方法的ACTION_DOWN
返回false
,所以在onTouchEvent
方法中无法获取DOWN
事件,故而需要在注释2
处设置lastX
和lastY
这两个参数。
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
,如图所示:
最后贴上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
进行布局;- 如有自己的触摸事件,需要重写
onInterceptTouchEvent
或onTouchEvent
;
自定义ViewGroup
注意事项:
- 如果想在
ViewGroup
中绘制一些内容,又没有在布局中设置background
的话,是绘制不出来的,这时候需要调用setWillNotDraw
方法,并设置为false
; - 如果有自定义布局属性的,在构造方法中取得属性后应及时调用
recycle
方法回收资源; onDraw
方法和onTouchEvent
方法中都应尽量避免创建对象,过多操作可能会造成卡顿;
参考
https://www.jianshu.com/p/32e335d5b842