自定义ViewPager和滑动冲突解决

文章目录

  • 1. 自定义ViewPager
  • 2. 滑动冲突
    • 2.1 环境构建
    • 2.2 环境构建中问题排查
    • 2.3 滑动冲突解决

1. 自定义ViewPager

比如在自定义ViewPager中,中的某个子页面使用了一个scrollView。对于自定义ViewPager这里再次复习一下:

  • 定义对应的类,继承自ViewGroup,并复写onLayout方法,使得所有的页面在逻辑上是连着的。
  • 通过addView来添加子视图,这里直接使用ImageView,然后为其指定Background;
  • 通过上述步骤后,就可以显示出来一个页面;然后我们需要为这个自定义ViewPager指定手指触摸的滑动事件;
  • 使用手势识别GestureDetector的onTouchEvent事件来进行事件的拦截,在对应的onScroll方法中进行滑动,这里使用scrollBy进行,当然需要进行边界的判断;
  • 然后我们需要为他添加一个回弹的动画,这里可以采用自定义,也可以使用系统中提供的android.widget.Scroller来实现,使用scroller.startScroll来开始滑动,使用scroller.computeScrollerOffset判断是否滑动结束。同时,这个类也提供了插值器,所以在最后会有个很好看的平滑效果。

对应代码:

/**
 * 使用系统自带Scroller
 */
class MyViewPager(context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) {

    // 手势识别器
    var gestureDetector: GestureDetector? = null
    var mOnPagerChangerListener: OnPagerChangerListener? = null

    init {
        gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
            override fun onScroll(
                e1: MotionEvent?,
                e2: MotionEvent?,
                distanceX: Float,
                distanceY: Float
            ): Boolean {
                // X轴移动
                if(scrollX + distanceX >= 0 && scrollX + distanceX <= width * (childCount - 1)){
                    scrollBy(distanceX.toInt(), 0)
                }
                return true
            }
        })
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        for (i in 0..childCount) {
            val childView = getChildAt(i)
            childView?.layout(i * width, 0, (i + 1) * width, height)
        }
    }

    var startX = 0f
    var index = 0
    // 使用系统android.widget.Scroller
    var scroller: Scroller = Scroller(context)

    override fun onTouchEvent(event: MotionEvent?): Boolean {
        //3.把事件传递给手势识别器
        gestureDetector?.onTouchEvent(event)
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {
                // 起始位置
                startX = event.x
            }
            MotionEvent.ACTION_MOVE -> {

            }
            MotionEvent.ACTION_UP -> {
                var tempIndex = index
                if((startX - event.x) > width / 2){
                    tempIndex++
                }else if((event.x - startX) > width / 2 ){
                    tempIndex--
                }
                // 非法处理
                if(tempIndex < 0) tempIndex = 0
                if(tempIndex > childCount - 1) tempIndex = childCount - 1
                index = tempIndex
                // 监听接口调用
                mOnPagerChangerListener?.onPageChange(index)
                // 回弹
                scrollToPage(index)
            }
        }
        return true
    }

    /**
     * 按照页面下标进行滚动
     */
    fun scrollToPage(tempIndex: Int){
        scroller.startScroll(scrollX, scrollY, tempIndex*width - scrollX, 0)
        invalidate()
    }

    override fun computeScroll() {
        if (scroller.computeScrollOffset()) {
            scrollTo(scroller.currX, 0)
            invalidate()
        }
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        // 获取子元素个数
        if (childCount == 0) return
        // 如果是wrap_content,也就是AT_MOST模式
        // 如果是match_parents,也就是精确模式
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
        // 自适应,也就是取决于最大的子元素的宽和高,这里直接设置为屏幕的宽和高
        var height = MeasureSpec.getSize(heightMeasureSpec)
        var width = MeasureSpec.getSize(widthMeasureSpec)
        if (widthMode == MeasureSpec.AT_MOST ){
            width = resources.displayMetrics.widthPixels
        }
        if(heightMode == MeasureSpec.AT_MOST) {
            height = resources.displayMetrics.heightPixels
        }
        setMeasuredDimension(width, height)

        // 需要测量孩子
        for (i in 0 until childCount){
            getChildAt(i).measure(width, height)
        }
    }

    // 定义一个页面下标改变的监听接口
    interface OnPagerChangerListener{
        fun onPageChange(position: Int)
    }
}

这里设置监听器是为了关联指示器,指示器使用RadioButton来实现。比如:

class MainActivity : AppCompatActivity() {

    val custom_viewpager by lazy { findViewById<MyViewPager>(R.id.custom_viewpager) }
    val radioGroup by lazy { findViewById<RadioGroup>(R.id.radioGroup) }
    var imageRes = listOf<Int>(R.drawable.a, R.drawable.b, R.drawable.c)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        for (i in 0 until imageRes.size){
            val imageView = ImageView(this)
            imageView.setBackgroundResource(imageRes.get(i))
            custom_viewpager.addView(imageView)
        }

        val view = layoutInflater.inflate(R.layout.activity_other, null)
        custom_viewpager.addView(view, 2)

        for(i in 0 until custom_viewpager.childCount){
            val btn = RadioButton(this)
            if(i == 0) btn.isChecked = true
            btn.id = i
            radioGroup.addView(btn)
        }

        radioGroup.setOnCheckedChangeListener(object : RadioGroup.OnCheckedChangeListener {
            override fun onCheckedChanged(group: RadioGroup?, checkedId: Int) {
                // 切换页面
                custom_viewpager.scrollToPage(checkedId)
            }
        })

        // 页面切换关联radioButton
        custom_viewpager.mOnPagerChangerListener = object : MyViewPager.OnPagerChangerListener{
            override fun onPageChange(position: Int) {
                radioGroup.check(position)
            }
        }
    }
}

2. 滑动冲突

2.1 环境构建

在上面的代码中我们使用了:

val view = layoutInflater.inflate(R.layout.activity_other, null)
custom_viewpager.addView(view, 2)

来添加一个页面,在这个页面中使用了ScrollView:


<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <ScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <LinearLayout
            android:id="@+id/linearlayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical" >

        LinearLayout>
    ScrollView>

LinearLayout>

如果我们在另外一个Activity中测试:

class TestActivity : AppCompatActivity() {


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_other)

        val linearlayout by lazy { findViewById<LinearLayout>(R.id.linearlayout) }
        for( i in 0..50){
            val textView = TextView(this)
            textView.text = "文本:${i}"
            linearlayout.addView(textView)
        }
    }
}

可以发现滑动没有问题。但是如果我们将其应用在前面的自定义ViewPager中:

val view = layoutInflater.inflate(R.layout.activity_other, null)
val linearlayout by lazy { view.findViewById<LinearLayout>(R.id.linearlayout) }
for( i in 0..50){
    val textView = TextView(this)
    textView.width = resources.displayMetrics.widthPixels
    textView.gravity = Gravity.CENTER
    textView.textSize = 22F
    textView.text = "文本:${i}"
    linearlayout.addView(textView)
}
custom_viewpager.addView(view, 0)

​那么,按照逻辑这里就会出现滑动冲突。这里我的现象是滚动不了ViewPager,同时ScrollerView也不能滚动。

2.2 环境构建中问题排查

其实这个现象是不应该的,因为按照道理来说滑动冲突,也能有响应发生,故而这里排查一下:

ScrollView内的最外层控件或布局,如果尺寸大小不明确,会导致无法滑动。

但由于我的布局为:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ScrollView
        android:id="@+id/scrollview"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <LinearLayout
            android:id="@+id/linearlayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical" >
        LinearLayout>
    ScrollView>
LinearLayout>

然后custom_viewpager.addView(view, 0),也就是说可能的原因就在于xml布局中外层LinearLayout的大小没有测量出来。也就是测量孩子的宽高有问题。再次看下前面的测量孩子的代码:

  // 需要测量孩子
for (i in 0 until childCount){
    getChildAt(i).measure(width, height)
}

很明显,这里只是手动的测量了直接孩子的大小。所以这里就由系统自己去测量:

// 测量孩子
measureChildren(widthMeasureSpec, heightMeasureSpec)

即:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    // 获取子元素个数
    if (childCount == 0) return
    // 测量孩子
    measureChildren(widthMeasureSpec, heightMeasureSpec)
    // 如果是wrap_content,也就是AT_MOST模式
    // 如果是match_parents,也就是精确模式
    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val heightMode = MeasureSpec.getMode(heightMeasureSpec)
    // 自适应,也就是取决于最大的子元素的宽和高,这里直接设置为屏幕的宽和高
    var height = MeasureSpec.getSize(heightMeasureSpec)
    var width = MeasureSpec.getSize(widthMeasureSpec)
    if (widthMode == MeasureSpec.AT_MOST ){
        width = resources.displayMetrics.widthPixels
    }
    if(heightMode == MeasureSpec.AT_MOST) {
        height = resources.displayMetrics.heightPixels
    }
    setMeasuredDimension(width, height)
}

然后就解决了前面的现象:

滚动不了ViewPager,同时ScrollerView也不能滚动

到了一个正常的滑动冲突的现象。此时的现象为:

可以滚动ScrollView中的文本;
自定义的ViewPager无法滑动切换;

这里的冲突也就是常见的非同向冲突。

2.3 滑动冲突解决

这里的滑动冲突解决起来比较简单,因为是个非同向的冲突,我们只需要判断一下触摸事件的方向,然后决定由谁来处理即可。具体来说涉及到几个方法:

  • onTouchEvent,判断起始位置,判断用户滑动事件方向;
  • onInterceptTouchEvent,返回true表示拦截,否则为不拦截;
  • parent.requestDisallowInterceptTouchEvent(boolean),传入的参数为true表示要求父控件不处理,由自己处理;

首先看下这个方法:

override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
}

这里再次来回顾一下事件拦截和消费的规则:

  1. Down事件首先会传递到onInterceptTouchEvent()方法
  2. 如果该ViewGroup的onInterceptTouchEvent()在接收到Down事件处理完成之return false,那么在当前ViewGroup后续的Move, Up等事件将交给当前的onTouchEvent()处理,且子View可接收到Down事件;
  3. 如果该ViewGroup的onInterceptTouchEvent()在接收到Down事件处理完成之后return true,那么Down,Move, Up等事件将交给当前ViewGroup的onTouchEvent,而子view将接收不到任何事件。

讲起来比较拗口,但是根据事件传递规则,如果一开始就直接返回true或者false,那么就会导致两种情况。要么事件直接被当前的VeiwGroup拦截,要么就是使得当前的ViewGroup响应不了事件。所以这里提供的思路就可以有两种:

  • 根据条件判断,是否要拦截事件;
  • 传递到子View,由子View决定自己要消费的事件;

当然,这里首先考虑使用第一种,代码如下:

var interceptorX = 0f
var interceptorY = 0f
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
    when (ev?.action) {
        MotionEvent.ACTION_DOWN -> { // 放行Down事件
            // 起始位置
            interceptorX = ev.x
            interceptorY = ev.y
        }
        MotionEvent.ACTION_MOVE -> { // MOVE事件有选择放行
            return abs(ev.x - interceptorX) >= abs(ev.y - interceptorY)
        }
    }
    return false
}

但是这里有个Bug,因为在ViewPager的代码中我在onTouchEvent使用了ACTION_DOWN事件,而实际上该事件在ViewPager中的onTouchEvent方法中已经接收不到了。所以会有Bug,故而后续需要修改其滑动逻辑。最简单的修改就是:

var interceptorX = 0f
var interceptorY = 0f
override fun onInterceptTouchEvent(ev: MotionEvent?): Boolean {
    when (ev?.action) {
        MotionEvent.ACTION_DOWN -> { // 放行Down事件
            startX = ev.x  // 因为后续接受不了Down事件,所以在这里赋值
            // 起始位置
            interceptorX = ev.x
            interceptorY = ev.y
        }
        MotionEvent.ACTION_MOVE -> { // MOVE事件选择放行
            return abs(ev.x - interceptorX) >= abs(ev.y - interceptorY)
        }
    }
    return false
}

当然还是来尝试一下使用第二种方式:
由于ViewGroup默认onInterceptTouchEvent返回false,所以是交给子View处理,也就是这里的ScrollView来处理。我们只需要复写一下其方法,然后对需要的事件进行消费:
注释:在ViewPager中要先放行Down事件,然后才可以在scrollview中处理事件。

var interceptorX = 0f
var interceptorY = 0f
// 添加到ViewGroup之后执行
scrollview.setOnTouchListener(object : View.OnTouchListener {
    override fun onTouch(v: View?, event: MotionEvent?): Boolean {
        when (event?.action) {
            MotionEvent.ACTION_DOWN -> {
                Log.e("TAG", "ACTION_DOWN: ${scrollview.parent == null}")
                // 告诉父控件,自己要处理,不允许拦截
                scrollview.parent?.apply {
                    (scrollview.parent as ViewGroup).requestDisallowInterceptTouchEvent(true)
                }
                // 起始位置
                interceptorX = event.x
                interceptorY = event.y
                return true
            }
            MotionEvent.ACTION_MOVE -> {
                Log.e("TAG", "ACTION_MOVE: ")
                scrollview.parent?.apply {
                    if (abs(event.x - interceptorX) >= abs(event.y - interceptorY)) {
                        // 让父控件拦截
                        (scrollview.parent as ViewGroup).requestDisallowInterceptTouchEvent(
                            false
                        )
                        return false // ScrollView不消费
                    } else {
                        (scrollview.parent as ViewGroup).requestDisallowInterceptTouchEvent(
                            true
                        )
                        // 交给scrollview处理
                        scrollview.onTouchEvent(event)
                        return true
                    }
                }
            }
        }
        return true
    }
})

从代码量来说,在这个场景中第一种方式解决冲突的更好,因为代码量更少。

你可能感兴趣的:(Android学习笔记,android,kotlin,动画)