工作室任务,可以当成触摸事件传递的练手,触摸事件传递可以看我上一篇博客:

效果

仿高德地图的滑动抽屉,具体可以看下图:

要求抽屉内外的滑动事件不冲突。

GIF 2020-7-25 星期六 16-49-42.gif

控件模型

<com.heyanle.similarlayout.SimilarLayout
    app:similar_bottom_y="80dp"
    app:similar_center_y="300dp"
    app:similar_view_id="@id/scroll"
    app:similar_scrollable_id="@id/scroll"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.core.widget.NestedScrollView
        android:id="@+id/scroll_content"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

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

    </androidx.core.widget.NestedScrollView>


    <androidx.core.widget.NestedScrollView
        android:id="@+id/scroll"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

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

    </androidx.core.widget.NestedScrollView>

</com.heyanle.similarlayout.SimilarLayout>

其中,对于 SimilarLayou ,有几个自定义属性:

  • app:similar_scrollable_id 抽屉中可滑动布局的 控件Id ,用于判断抽屉内的控件是否滑动到顶部。
  • app:similar_view_id 抽屉本体的 控件Id,移动抽屉的时候移动的控件。
  • app:similar_bottom_y 抽屉到最底部的时候距离整个控件底部的距离。
  • app:similar_center_y 抽屉到中间有一个吸附点,该吸附点距离整个控件底部的距离。为空则没有中间吸附点。

自定义属性因为是很简单的内容,所以相关实现省略。

抽屉内可滑动控件是否滑动到顶部判断

这里我写了一个 SimilarScrollable 接口,具体如下:

/**
 * Created by HeYanLe on 2020/7/22 0022 11:19.
 * https://github.com/heyanLE
 */

interface SimilarScrollable {

    abstract fun isScrollToTop():Boolean

    companion object{

        fun of(view:View):SimilarScrollable =
            when(view){
                is ScrollView -> object :SimilarScrollable{
                    override fun isScrollToTop(): Boolean =
                        (view.scrollY == 0)
                }
                is NestedScrollView -> object :SimilarScrollable{
                    override fun isScrollToTop(): Boolean =
                        (view.scrollY == 0)
                }
                is RecyclerView -> object :SimilarScrollable{
                    override fun isScrollToTop(): Boolean =
                        view.computeVerticalScrollOffset() == 0
                }
                is SimilarScrollable -> view
            else -> object :SimilarScrollable{
                override fun isScrollToTop(): Boolean = true

            }
        }
    }
}

这里可以根据传入的 View 不同使用不同的判断方式,当然你也可以自定义一个 View 实现这个接口来自定义。

抽屉滑动的实现

首先我们的 SimilarLayout 继承于 FrameLayout,这个布局每个子控件位置都是独立的,互不干扰。

然后我们抽屉移动主要通过修改抽屉控件的 TranslateY 属性来实现,这个属性可以在控件和父布局的顶部添加偏移量。

首先我们抽屉需要有三个吸附点,底部,中间和顶部。顶部的 TranslateY0 没什么问题。主要是底部和中部,我们通过自定义属性传入的值为吸附点到控件底部的距离,所以我们需要通过计算来得到吸附点对应的 TranslateY。具体可以在 onMeasure 中计算:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val height = MeasureSpec.getSize(heightMeasureSpec)
    mSimilarBottomTranslateY = height-mSimilarBottomTranslateYAttr
    mSimilarCenterTranslateY = height-mSimilarCenterTranslateYAttr
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}

滑动的动画实现

也很简单,没啥奇怪的直接上代码:

/*
Similar 位置标识
*/

private var mLocation = Location.BOTTOM

enum class Location {
    TOP,CENTER,BOTTOM
}
private fun moveSimilar(location:Location, isUser:Boolean){
    if (mIsAnimator){
        return
    }
    mSimilarView?.let { view ->
        val targetTranslationY:Float = when(location){
            Location.BOTTOM -> mSimilarBottomTranslateY
            Location.CENTER -> mSimilarCenterTranslateY
            else -> 0f
        }
        val animator = ValueAnimator.ofFloat(view.translationY, targetTranslationY)
        animator.addUpdateListener {
            val value = it.animatedValue as Float
            view.translationY = value
            mSimilarListenerDelegate.mOnScrollListener?.let {listener ->
                listener(value, false)
            }
        }
        animator.addListener (
            onStart = {
                mIsAnimator = true
            },
            onEnd = {
                mIsAnimator = false
                mLocation = location
                mSimilarListenerDelegate.mOnSimilarChangeListener?.let {listener->
                    listener(mLocation,isUser)
                }
            }
        )
        animator.start()
    }
}

滑动事件拦截

重点戏来了,首先重写拦截方法。注意这里是我们需要抽屉移动的时候,才会拦截。而抽屉移动有以下条件:

  • MotionEvent.ACTION_DOWN 的时候按到的是抽屉
  • 如果向上滑动,则需要此时抽屉不是在最顶部的吸附点
  • 如果向下滑动,则需要抽屉的可滑动布局滑动到顶部

所以 onInterceptTouchEvent 方法:
其中 isSimilarFocus 表示按下的时候抽屉是否在最顶部的吸附点

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
    if (mIsAnimator){
        return true
    }
    mSimilarView?.let {
        when(ev.action){
            MotionEvent.ACTION_DOWN -> {
                mLastY = ev.y
                mDownY = ev.y
                isSimilarFocus = (it.translationY == 0f)
            }
            MotionEvent.ACTION_MOVE ->{
                val dy = ev.y - mLastY
                mLastY = ev.y
                if (mDownY < it.translationY){
                    return false
                }else{
                    if (dy<0){
                        return  !isSimilarFocus
                    }else if(dy >0){
                        return !isSimilarFocus || mSimilarScrollable.isScrollToTop()
                    }
                }
            }
        }
    }
    return super.onInterceptTouchEvent(ev)
}

还有一种情况,当我们在抽屉全屏时候,一开始抽屉里的可滑动布局不是滑动到顶部,然后一直滑动到顶部,继续下滑,此时我们的布局已经在 MotionEvent.ACTION_DOWN 的时候返回不拦截,所以此时 onInterceptTouchEvent 方法不会调用,此时我们需要在 dispatchTouchEvent 强制拦截:

override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
    if (mIsAnimator){
        return true
    }
    mSimilarView?.let {
        when(ev.action){
            MotionEvent.ACTION_MOVE -> {
                val dy = ev.y - mLastY
                if (dy > 0 && it.translationY == 0f) {
                    if (mSimilarScrollable.isScrollToTop()) {
                        requestDisallowInterceptTouchEvent(false)
                        return super.dispatchTouchEvent(ev)
                    }
                }
            }
        }
    }
    return super.dispatchTouchEvent(ev)
}

滑动事件消费

消费这里,因为是否滑动的逻辑已经在拦截的时候处理好了,所以我们直接处理抽屉跟随滑动,然后松手的时候自动移动到最近的吸附点。其中还有滑动速度判断,直接上代码把:

@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(ev: MotionEvent): Boolean {
    if (mIsAnimator){
        return true
    }
    mVelocityTracker.addMovement(ev)
    mSimilarView?.let {
        when(ev.action){
            MotionEvent.ACTION_DOWN ->{
                mActivePointId = ev.getPointerId(ev.actionIndex)
                mLastY = ev.y
                mDownY = ev.y
            }
            MotionEvent.ACTION_POINTER_DOWN -> {
                mActivePointId = ev.getPointerId(ev.actionIndex)
                if (mActivePointId == 0){
                    mLastY = ev.getY(mActivePointId)
                }
                mActivePointId
            }
            MotionEvent.ACTION_MOVE ->{
                val dy = ev.y - mLastY
                mLastY = ev.y
                var translation = it.translationY+dy

                translation = max(translation,0f)
                translation = min(translation, mSimilarBottomTranslateY)

                it.translationY = translation
                mSimilarListenerDelegate.mOnScrollListener?.let {listener ->
                    listener(it.translationY, true)
                }

            }
            MotionEvent.ACTION_UP -> {
                //similarHoldPosition()
                mVelocityTracker.computeCurrentVelocity(1000,500f)
                Log.i("SimilarLayout","mVelocityTracker.yVelocity -> ${mVelocityTracker.yVelocity}")
                Log.i("SimilarLayout","mLocation -> ${mLocation.name}")
                when (mVelocityTracker.yVelocity) {
                    500f -> {
                        when(mLocation){
                            Location.TOP -> moveSimilar(Location.CENTER,true)
                            else -> moveSimilar(Location.BOTTOM,true)
                        }
                    }
                    -500f -> {
                        when(mLocation){
                            Location.BOTTOM -> moveSimilar(Location.CENTER,true)
                            else -> moveSimilar(Location.TOP,true)
                        }
                    }
                    else -> {
                        similarHoldPosition()
                    }
                }
            }
            else -> return true
        }
    }
    return true
}

其中的一些方法:

private fun similarHoldPosition(){
    mSimilarView?.let { view ->
        val deltaTop = abs(view.translationY - 0)
        val deltaCenter = abs(view.translationY - mSimilarCenterTranslateY)
        val deltaBottom = abs(view.translationY - mSimilarBottomTranslateY)

        when{
            deltaCenter < deltaTop && deltaCenter < deltaBottom ->
                moveSimilar(Location.CENTER,true)
            deltaBottom < deltaCenter && deltaBottom < deltaTop ->
                moveSimilar(Location.BOTTOM,true)
            else -> moveSimilar(Location.TOP,true)
        }
    }
}

至此,我们就完成了以上的效果。

其他细节

关于其他的内容,包括滑动监听,移动监听,都可以自己实现。就不放代码了。