android群英传笔记——自定义ViewGroup(类似原生控件ScrollView)

本文介绍如何自定义一个带有粘性效果的ScrollView控件。该控件能够在子View滑动到一定距离后自动滚动到下一个子View,并在用户手指离开时保持粘性效果。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

自定义ViewGroup

如下图所示就是这次的自定义ViewGroup:

自定义ViewGroup

这次准备实现一个类似Android原生控件ScrollView的自定义ViewGroup,自定义ViewGroup可以实现ScrollView所具有的上下滑动功能,但是在滑动的过程中,增加一个粘性的效果,即当一个子View向上滑动大于一定的距离后,松开手指,它能自动的向上滑动,显示下一个子View。反之同理。

首先实现类似ScrollView的功能。

使用遍历的方式通知子View对自身进行测量,代码如下:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            // 遍历查找所有的子view
            View childView = getChildAt(i);
            // 测量所有子view的宽、高
            measureChild(childView, widthMeasureSpec, heightMeasureSpec);
        }
    }

接下来,对子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;
                // 获取初始的y轴坐标
                mStart = getScrollY();
                break;
            case MotionEvent.ACTION_MOVE: // 滑动
                // 判断是否完成动作
                if (!mScroller.isFinished()) {
                    // 如果没有动作的话就立刻停止动画
                    mScroller.abortAnimation();
                }
                // 当前手指位置坐标减去没有动作时的坐标
                int dy = mLastY - y;  // 滑动距离dy
                // 如果检测到坐标小于0,说明当前坐标是处于第一页,用户试图向下滑动
                if (getScrollY() < 0) {
                    // 直接将滑动距离置为0
                    dy = 0;
                }
                // 如果超过了父view的高度减去子view的高度的话(处于最后一页),并且用户试图向上滑动
                if (getScrollY() > getHeight() - mScreenHeight) {
                    // 直接将滑动距离置为0
                    dy = 0;
                }
                // 辅助滑动,执行滑动
                scrollBy(0, dy);
                // 记录左上角坐标,为下次动作做准备
                mLastY = y;
                break;

按如上方法操作就可以实现类似ScrollView的滚动效果了。当然,系统的原生ScrollView有更强大的功能,比较滑动的惯性效果等,这些功能可以在后面慢慢添加,这也是一个空间的迭代的过程。

最后实现控件滑动的黏性效果。要实现手指离开后ViewGroup的黏性效果,我们很自然的想到onTouchEvent()的ACTION_UP事件和Scroller类。通过ACTION_UP事件中判断手指滑动的距离,来确定是否执行滑动到下一个子View,代码如下所示:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 获得最左上角的坐标相对于 view 刚显示出来原点的位置
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: // 按下
                // 记录本次左上角的坐标,为下次动作做准备
                mLastY = y;
                // 获取初始的y轴坐标
                mStart = getScrollY();
                break;
            case MotionEvent.ACTION_MOVE: // 滑动
                // 判断是否完成动作
                if (!mScroller.isFinished()) {
                    // 如果没有动作的话就立刻停止动画
                    mScroller.abortAnimation();
                }
                // 当前手指位置坐标减去没有动作时的坐标
                int dy = mLastY - y;  // 滑动距离dy
                // 如果检测到坐标小于0,说明当前坐标是处于第一页,用户试图向下滑动
                if (getScrollY() < 0) {
                    // 直接将滑动距离置为0
                    dy = 0;
                }
                // 如果超过了父view的高度减去子view的高度的话(处于最后一页),并且用户试图向上滑动
                if (getScrollY() > getHeight() - mScreenHeight) {
                    // 直接将滑动距离置为0
                    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(),
                                // 向下辅助滑动的距离需要将子view的高度减去用户已经滑动过的距离
                                0, mScreenHeight - dScrollY
                        );
                    }
                } else {
                    // 如果滑动的距离小于0,即手指向上滑动(-dScrollY相当于滑动距离,因为dScrollY小于0)
                    if (-dScrollY < mScreenHeight / 3) {
                        // 滑动距离,计算要注意滑动距离为负数
                        mScroller.startScroll(
                                0, getScrollY(),
                                0, -dScrollY
                        );
                    } else {
                        mScroller.startScroll(
                                0, getScrollY(),
                                0, -mScreenHeight - dScrollY
                        );
                    }
                }
                break;
        }
        // 刷新界面,区别于invalidate()
        postInvalidate();
        return true;
    }

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

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

代码

以下是完整代码:

package com.example.customvg;

import android.content.Context;
import android.util.AttributeSet;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.Scroller;

/**
 * 自定义的ViewGroup(类似原生控件ScrollView)
 * bug比较多,过度调戏会卡死
 * 建议还是尽量使用原生控件
 * Created by shize on 2016/9/7.
 */
public class MyViewGroup extends ViewGroup {
    // 屏幕高度
    private int mScreenHeight;
    private int mLastY;
    private Scroller mScroller;
    // 手指触碰终点
    int mEnd;
    // 手指触碰起始点
    int mStart = 0;

    public MyViewGroup(Context context) {
        super(context);
        initView(context);
    }

    public MyViewGroup(Context context, AttributeSet attrs) {
        super(context, attrs);
        initView(context);
    }

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

    /**
     * 初始化View
     * 图片太大可能会卡顿,不建议使用过大的图片
     * @param context
     */
    private void initView(Context context) {
        // 获取窗口管理器
        WindowManager mWindowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
        // 新建一个DisplayMetrics
        DisplayMetrics mDisplayMetrics = new DisplayMetrics();
        // 将度量标准设置为窗口管理器的默认显示
        mWindowManager.getDefaultDisplay().getMetrics(mDisplayMetrics);
        // 获取屏幕的宽度并赋值给mScreenHeight
        mScreenHeight = mDisplayMetrics.heightPixels;
        // 实例化一个滚动类Scroller
        mScroller = new Scroller(context);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            // 遍历查找所有的子view
            View childView = getChildAt(i);
            // 测量所有子view的宽、高
            measureChild(childView, widthMeasureSpec, heightMeasureSpec);
        }
    }

    @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);
            }
        }
    }


    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 获得最左上角的坐标相对于 view 刚显示出来原点的位置
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: // 按下
                // 记录本次左上角的坐标,为下次动作做准备
                mLastY = y;
                // 获取初始的y轴坐标
                mStart = getScrollY();
                break;
            case MotionEvent.ACTION_MOVE: // 滑动
                // 判断是否完成动作
                if (!mScroller.isFinished()) {
                    // 如果没有动作的话就立刻停止动画
                    mScroller.abortAnimation();
                }
                // 当前手指位置坐标减去没有动作时的坐标
                int dy = mLastY - y;  // 滑动距离dy
                // 如果检测到坐标小于0,说明当前坐标是处于第一页,用户试图向下滑动
                if (getScrollY() < 0) {
                    // 直接将滑动距离置为0
                    dy = 0;
                }
                // 如果超过了父view的高度减去子view的高度的话(处于最后一页),并且用户试图向上滑动
                if (getScrollY() > getHeight() - mScreenHeight) {
                    // 直接将滑动距离置为0
                    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(),
                                // 向下辅助滑动的距离需要将子view的高度减去用户已经滑动过的距离
                                0, mScreenHeight - dScrollY
                        );
                    }
                } else {
                    // 如果滑动的距离小于0,即手指向上滑动(-dScrollY相当于滑动距离,因为dScrollY小于0)
                    if (-dScrollY < mScreenHeight / 3) {
                        // 滑动距离,计算要注意滑动距离为负数
                        mScroller.startScroll(
                                0, getScrollY(),
                                0, -dScrollY
                        );
                    } else {
                        mScroller.startScroll(
                                0, getScrollY(),
                                0, -mScreenHeight - dScrollY
                        );
                    }
                }
                break;
        }
        // 刷新界面,区别于invalidate()
        postInvalidate();
        return true;
    }

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

}

布局文件的完整代码:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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"
    android:orientation="horizontal"
    tools:context="com.example.customviewgroup.MainActivity">

    <com.example.customvg.MyViewGroup
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ImageView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:scaleType="fitXY"
            android:src="@drawable/test1"/>

        <ImageView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:scaleType="fitXY"
            android:src="@drawable/test2"/>

        <ImageView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:scaleType="fitXY"
            android:src="@drawable/test3"/>

    </com.example.customvg.MyViewGroup>
</LinearLayout>

感谢阅读,下次再见。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值