【Android View】自定义ViewGroup——继承自ViewGroup的水平翻页视图HorizontalView的实现

自定义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~

再进一步

现在你已经基本完成了它的主要功能,如果你还想更进一步,可以从以下方面入手:

  1. 适配自己的padding与子View的Margin
  2. 在控件所在页面销毁时,需要哪些操作?
  3. 如果内容中有点击事件,需要如何处理?

参考文章:
《Android 进阶之光》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值