自定义ViewGroup可按父类分为三类,分别为继承自ViewGroup、继承自系统特定的ViewGroup(如LinearLayout)和继承自View。
其中第二种最为简单,第三种最为复杂,让我们先把目光放在第一种难度适中的情况。
目标
仿照 ViewPager 完成一个水平翻页视图,支持左右滑动切换不同的页面。
开始
继承ViewGroup
首先,我们先创建一个HorizontalView类,并实现其抽象方法。
public class HorinzontalView extends ViewGroup {
public HorinzontalView(Context context) {
super(context);
}
public HorinzontalView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public HorinzontalView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public HorinzontalView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
}
}
处理wrap_content
阅读过我上篇博客的一定知道,自定义控件首先就要对wrap_content
进行适配。这里我们需要重写onMeasure
方法。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//对 wrap_content 进行处理
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
measureChildren(widthMeasureSpec, heightMeasureSpec);
//如果没有子View,则设宽高为0
if (getChildCount() == 0){
setMeasuredDimension(0,0);
}else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST){
//宽和高都是AT_MOST,则宽度设置为所有子元素宽度之和,高度设为第一个子元素的高度
View childOne = getChildAt(0);
int childWidth = childOne.getMeasuredWidth();
int childHeight = childOne.getMeasuredHeight();
setMeasuredDimension(childWidth * getChildCount(), childHeight);
}else if (widthMode == MeasureSpec.AT_MOST){
//宽为AT_MOST,则宽度设置为所有子元素宽度之和
int childWidth = getChildAt(0).getMeasuredWidth();
setMeasuredDimension(childWidth * getChildCount(), heightSize);
}else if (heightMode == MeasureSpec.AT_MOST){
//高是AT_MOST,高度设为第一个子元素的高度
int childHeight = getChildAt(0).getMeasuredHeight();
setMeasuredDimension(widthSize, childHeight);
}
}
实现onLayout
当然,在onMeasure方法测量之后,还需要onLayout对控件进行布局。
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
int left = 0;
View child;
//遍历子View
for (int i = 0; i < childCount; i++) {
child = getChildAt(i);
if (child.getVisibility() != View.GONE){
//如果View不为GONE,则将其放置到合适的位置
int width = child.getMeasuredWidth();
//这四个参数分别为:
//l – Left position, relative to parent
//t – Top position, relative to parent
//r – Right position, relative to parent
//b – Bottom position, relative to parent
child.layout(left, 0, left+width, 0 + child.getMeasuredHeight());
left += width;
}
}
}
处理滑动冲突
我们的控件是水平滑动的,如果其内容为竖向滑动的ListView,如果我们不加处理,就会导致滑动冲突(因点击事件被外层HorizontalView捕获而无法传达到内容ListView)。
解决滑动冲突的思想是:如果我们检测到滑动方向是水平的话,就让父View拦截,反之则不拦截。
class HorinzontalView extends ViewGroup {
//用来处理滑动冲突
private int lastInterceptX;
private int lastInterceptY;
private int lastX;
private int lastY;
...
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.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){
//滑动为横向,拦截
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);
}
}
弹性滑动效果
滑动页面我们需要用到Scroller
...
int currentIndex = 0;
int childWidth = 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;
//如果滑动距离超过childWidth的一半
//则滑动到上/下一个子View
if (Math.abs(distance) > childWidth/2){
if (distance > 0){
currentIndex++;
}else{
currentIndex--;
}
}
smoothScrollTo(currentIndex * childWidth, 0);
break;
}
lastX = x;
lastY = y;
return super.onTouchEvent(event);
}
@Override
public void computeScroll() {
super.computeScroll();
if(scroller.computeScrollOffset()){
scrollTo(scroller.getCurrX(), scroller.getCurrY());
postInvalidate();
}
}
private void smoothScrollTo(int destX, int destY) {
scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(), destY - getScrollY(), 1000);
invalidate();
}
快速滑动进入其他页面
在相当多的情况下,用户并不会滑动很长的距离,而是会进行相对短和相对快的滑动操作,我们也要对快速滑动进行适配。而为了捕捉滑动速度,我们需要借用速度追踪器 VelocityTracker
。
首先我们要在构造器中添加初始化速度追踪器的代码。
private void init(){
scroller = new Scroller(getContext());
//想一想,这里为什么要调用obtain方法而不是new一个对象?
tracker = VelocityTracker.obtain();
}
然后我们在处理点击事件的逻辑中加入滑动速度相关的代码:
@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){
if (distance > 0){
currentIndex++;
}else{
currentIndex--;
}
}else{
//计算当前滑动速度
tracker.computeCurrentVelocity(10);
float xV = tracker.getXVelocity();
//如果滑动速度大于50则认为发生了“快速滑动”
if (Math.abs(xV) > 50){
if (xV > 0){
currentIndex--;
}else{
currentIndex++;
}
}
}
currentIndex = currentIndex < 0 ? 0 : Math.min(currentIndex, getChildCount() - 1);
smoothScrollTo(currentIndex * childWidth, 0);
//重置速度计算器
tracker.clear();
break;
default:
break;
}
lastX = x;
lastY = y;
return super.onTouchEvent(event);
}
滑动时点击屏幕阻止滑动
当我们滑动至下一页面时,由于弹性滑动需要时间,在该时间内,我们再次点击屏幕,希望能拦截本次滑动,然后再去操作页面。
要实现上述逻辑,我们需要在onInterceptEvent方法中进行判断,如果在ACTION_DOWN时,Scroller还没有执行完毕,则说明上次滑动仍在进行中,在此时我们中断滑动即可。
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
boolean intercept = false;
int x = (int) ev.getX();
int y = (int) ev.getY();
switch (ev.getAction()){
case MotionEvent.ACTION_DOWN:
intercept = false;
if (!scroller.isFinished()){
//如果Scroller没有执行完成,则对其进行打断
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:
break;
default:
break;
}
lastX = x;
lastY = y;
lastInterceptX = x;
lastInterceptY = y;
return intercept;
}
应用HorizontalView
现在,我们的控件已初具雏形了,让我们来简单使用一下吧~
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);
init();
}
private void init() {
lv_one = findViewById(R.id.lv_one);
lv_two = findViewById(R.id.lv_two);
List<String> strs1 = new ArrayList<>();
List<Character> strs2 = new ArrayList<>();
for (int i = 0; i < 15; i++) {
strs1.add(String.valueOf(i+1));
strs2.add((char) ('A' + i));
}
ArrayAdapter<String> arrayAdapter1 =
new ArrayAdapter<>(this, android.R.layout.simple_expandable_list_item_1, strs1);
ArrayAdapter<Character> arrayAdapter2 =
new ArrayAdapter<>(this, android.R.layout.simple_expandable_list_item_1, strs2);
lv_one.setAdapter(arrayAdapter1);
lv_two.setAdapter(arrayAdapter2);
}
}
<?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:content=".MainActivity">
<com.example.myview.HorinzontalView
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.example.myview.HorinzontalView>
</RelativeLayout>
完成使用逻辑后运行程序,你会得到一个简易的ViewPager~
再进一步
现在你已经基本完成了它的主要功能,如果你还想更进一步,可以从以下方面入手:
- 适配自己的padding与子View的Margin
- 在控件所在页面销毁时,需要哪些操作?
- 如果内容中有点击事件,需要如何处理?
参考文章:
《Android 进阶之光》