前言:本文记录了实现全屏可拖动滑动侧边栏时遇到的两个问题及解决方案。首先针对enableEdgeToEdge导致的渲染不全问题,分析了View渲染机制,提出两种解决方法:使用fitsSystemWindows属性或通过添加透明TopView动态调整布局。其次利用反射技术突破DrawerLayout限制,通过运行时修改私有ViewDragHelper参数,实现了自定义拖动范围控制(全屏/半屏/自定义距离响应)。文章结合源码分析,展示了如何通过反射获取并修改私有属性,最终实现灵活可控的侧边栏交互效果。

滑动侧边栏

首先最外层换为DrawerLayout,然后它仅容忍两个View,由上到下是主View和侧边View。

然后侧边View有一个推荐的组件是NavigationView,它可以绑定headerLayoutMenu来快速实现侧边菜单栏,但是本项目打算就放一个RecyclerView就够了。以及,侧边View可以设定出现方向layout_gravity,默认左边。

<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <androidx.coordinatorlayout.widget.CoordinatorLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <!--视图导航记得添加navGraph属性-->
        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/nav_host_fragment"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:navGraph="@navigation/nav_graph"
            tools:layout="@layout/fragment_deductive" />

    </androidx.coordinatorlayout.widget.CoordinatorLayout>

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/navigation_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        />
    
</androidx.drawerlayout.widget.DrawerLayout>

然后正常使用

// 打开抽屉
    fun openDrawer() {
        binding.drawerLayout.openDrawer(GravityCompat.START)
    }
    
    // 关闭抽屉
    fun closeDrawer() {
        binding.drawerLayout.closeDrawer(GravityCompat.START)
    }
    
    // 检查抽屉是否打开
    fun isDrawerOpen(): Boolean {
        return binding.drawerLayout.isDrawerOpen(GravityCompat.START)
    }

enableEdgeToEdge导致的问题

首先我们需要介绍enableEdgeToEdge到底是什么,这是一个沉浸式模式,使得View渲染区域达到了全屏。但是这样就会导致有一些控件会被挡住,于是就正常的把可渲染区域向内缩:

//渲染区域为全屏,
enableEdgeToEdge()
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)

ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
    //拿到状态栏参数
    val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
    //设置padding,让内容向内缩进,因为全屏渲染,所以需要缩进渲染区域,免得被挡住
    v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
    
    insets
}

整体的渲染流程如下:setContentView(binding.root)初步创建View进行全屏渲染,ViewCompat...设定View监听器,然后修改View参数,重新测量高度,进行渲染调整。

本来一切都没什么,很对劲的。

但是DrawerLayout所包裹的两个子View不知道为什么,尽然在DrawerLayout的高度减少时,没有自动重新测量高度(或者说高度没有得到及时的调整)。于是就导致子View的渲染高度高于父View的渲染高度,对于子View高出的部分父View根本不买账,直接选择不渲染这部分,于是就导致了渲染缺失。

那么要怎么办呢?知道了原理后就比较简单了,主要有两个方向:

  • 给两个子View都设定属性:android:fitsSystemWindows="true",这个参数会在渲染缺失时自动调整View的参数,重新渲染。比较简单,但是既然都用enableEdgeToEdge()了,怎么能止步于这个解决方法呢?

  • 首先去掉v.setPadding代码,不让父View高度变小。但是这样就又会与导航栏发生重叠,于是在两个子View的内部多包含一个TopView,颜色为透明,然后在ViewCompat...中将他的高度和状态栏等高。于是就可以把View中的其他内容挤下去,同时又实现了状态栏透明的效果。

    ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
        //拿到状态栏参数
        val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
        //直接把高度参数修改为和状态栏等高
        binding.TopView.layoutParams.height = systemBars.top
        binding.TopView2.layoutParams.height = systemBars.top
        binding.TopView.setBackgroundColor(Color.TRANSPARENT)
        binding.TopView2.setBackgroundColor(Color.TRANSPARENT)
        insets
    }
    

灵活拖动抽屉-反射的使用

由于直接使用这个组件是没有一个参数来设置拖动范围的,基本上只能在最左侧完成向右拖动才能打开抽屉,但是最左侧向右拖动又和系统级的返回上一页拖动有部分重叠,所以现在要继承这个组件来完成这个功能,使得可拖动范围能够被调节。

我们发现,DrawerLayout的滑动是由ViewDragHelper负责实现的,但是这个ViewDragHelperDrawerLayout中被私有化了,也就意味着我们不能直接给他一个改好的ViewDragHelper实列,所以需要使用反射完成最小入侵。

//DrawerLayout源码,有左右两个Dragger,对应star和end。
private final ViewDragHelper mLeftDragger;
private final ViewDragHelper mRightDragger;

好的,那么接下来开始介绍什么是反射。

反射是 Java/Kotlin 中一个非常强大的特性,它允许程序在运行时检查、访问和修改类、方法、字段等信息,而不需要在编译时就知道这些信息。

也就是说,对于一个class中的私有属性secretValue,我可以先反射这个class,然后直接访问secretValue。由于编译不检查,所以这里不会报错,然后我对这个secretValue属性进行修改,把它改为公有属性后,再交由运行检查。由于已经被我改为了公有属性,所以运行也不会报错。于是我就可以堂而皇之的使用它的私有属性了。

好了,现在开始具体的使用,首先我们在初始化时放一个addOnLayoutChangeListener来监听每次的View变化,这样就可以在视图变化的时候自动修改ViewDragHelper的值了,确保屏幕变化时也能正确的完成我们的要求:

init {
    attrs?.let { parseAttributes(it) }
    addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
        updateViewDragHelper()
    }
}

然后我们利用反射先拿到装有mLeftDragger属性的Field后,将属性设为可访问后再拿到具体实列。

private fun updateViewDragHelper() {
    try {
        //拿到DrawerLayout的反射,并获取mLeftDragger属性(Field类)
        val leftDraggerField = DrawerLayout::class.java.getDeclaredField("mLeftDragger")
        leftDraggerField.isAccessible = true  //设置为可访问
        
        //获取具体的ViewDragHelper对象(ViewDragHelper类)
        val leftDragger = leftDraggerField.get(this) as ViewDragHelper

        val edgeSize = calculateEdgeSize()
        setViewDragHelperEdgeSize(leftDragger, edgeSize)

    } catch (e: Exception) {
        Log.e("NonIntercept", "更新 ViewDragHelper 失败", e)
    }
}

然后我们发现ViewDragHelper中所覆盖的可滑动距离可以由这个方法进行设置:

//ViewDragHelper的源码:
private int mEdgeSize;
public void setEdgeSize(@Px @IntRange(from = 0) int size) {
    mEdgeSize = size;
}

我们可以看到,这个方法是public的,也就意味着我们可以直接调用。他的mEdageSize是用private修饰的,所以我们如果想直接修改mEgeSize也可以直接把这个属性反射出来修改。

private fun setViewDragHelperEdgeSize(dragger: ViewDragHelper, edgeSize: Int) {
    try {
    	//直接调用public方法
        dragger.edgeSize = edgeSize
    } catch (e: Exception) {
        try {
            //如果失败,则尝试直接拿到mEdgeSize属性
            val edgeSizeField = ViewDragHelper::class.java.getDeclaredField("mEdgeSize")
            edgeSizeField.isAccessible = true
            //然后设置边缘大小,相当于:dragger.mEdgeSize = edgeSize
            edgeSizeField.setInt(dragger, edgeSize)
        } catch (e2: Exception) {
            Log.e("NonIntercept", "设置边缘大小失败", e2)
        }
    }
}

这样我们就完成了,但是整体逻辑还有一点瑕疵。因为我们是直接监听View变化,然后每一次变化都会重新反射leftDragger一次,但是实际上我们需要的是每一次变化就重新dragger.edgeSize = edgeSize一次,所以我们可以把参数进行缓存。

private var leftDragger: ViewDragHelper?  = null
private var isInitDragger = false
init {
        attrs?.let { parseAttributes(it) }
        addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
            if (!isInitDragger) {
                try {
                    val draggerField = DrawerLayout::class.java.getDeclaredField("mLeftDragger")
                    draggerField.isAccessible = true
                    leftDragger = draggerField.get(this) as ViewDragHelper
                    isInitDragger = true
                }catch (e: Exception){
                    Log.e("FlexibleDrag", "反射失败", e)
                }
            }
            setViewDragHelperEdgeSize()
        }
    }

这里借用了一个参数来实现懒加载,至于为什么不直接使用by lazy?是因为他的反射可能会失败,在失败的时候下一次再重新反射就是了。而如果用了by lazy的话,这个逻辑不太好实现。

然后,我们设定的监听是addOnLayoutChangeListener,它会在视图发生任何变化的时候都执行,太频繁了。实际上我们只需要在原本DrawerLayout使得EdgeSize变化的地方之后,再把它变为自己的值就可以了。

于是我们直接在DrawerLayout源码中找到了调用setEdgeSize的位置,万幸只在onLayout这一个地方调用。

protected void onLayout(boolean changed, int l, int t, int r, int b) {
    ....其他代码
	mLeftDragger.setEdgeSize(
        Math.max(mLeftDragger.getDefaultEdgeSize(), gestureInsets.left));
	mRightDragger.setEdgeSize(
        Math.max(mRightDragger.getDefaultEdgeSize(), gestureInsets.right));        
}

不过细想之下也能理解,因为这个边缘位置也只有在重新测量时才需要被重新赋值。

于是我们就可以移除这个监听器,直接重写一下onLayout

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    super.onLayout(changed, l, t, r, b)
    setViewDragHelperEdgeSize()
}
private fun setViewDragHelperEdgeSize() {
        try {
            if (!isInitDragger) {
                try {
                    val draggerField = DrawerLayout::class.java.getDeclaredField("mLeftDragger")
                    draggerField.isAccessible = true
                    leftDragger = draggerField.get(this) as ViewDragHelper
                    isInitDragger = true
                }catch (e: Exception){
                    Log.e("FlexibleDrag", "反射失败", e)
                }
            }
            val edgeSize = calculateEdgeSize()
            leftDragger?.edgeSize = edgeSize
        } catch (e: Exception) {
            try {
                val edgeSize = calculateEdgeSize()
                //如果失败,则尝试直接拿到mEdgeSize属性
                val edgeSizeField = ViewDragHelper::class.java.getDeclaredField("mEdgeSize")
                edgeSizeField.isAccessible = true
                //然后设置边缘大小,相当于:dragger.mEdgeSize = edgeSize
                edgeSizeField.setInt(leftDragger, edgeSize)
            } catch (e2: Exception) {
                Log.e("FlexibleDrag", "设置边缘大小失败", e2)
            }
        }
    }

至此,就完成整个的设计逻辑了啦,其中的calculateEdgeSize()方法需要自己实现一个简单的传值就可以了。

吐槽:
原本是直接使用doOnLayout来监听onLayout的执行,但是这个doOnLayout有点问题,它好像只能绑定一个碎片。因为,当我对内部的碎片进行切换时,他就不执行了,只在第一个碎片执行。所以后面才直接重写onLayout

灵活拖动抽屉-PEEK与长按冲突

后续发现一个问题:子View点击的事件不会被抽屉拦截,但是长按事件会被拦截判定为PEEK。也就是说,如果我设置了全屏滑动那不就全屏无长按事件了?这明显不行啊!

首先先解释为什么会发生点击不被拦截,但是长按会被拦截。

  • 点击流程:ACTION_DOWN → (可能的ACTION_MOVE) → ACTION_UP → 触发onClick
  • 长按流程:ACTION_DOWN → 等待400ms → 触发onLongClick
//长按超时源码:
public static final int DEFAULT_LONG_PRESS_TIMEOUT = 400;
public static int getLongPressTimeout() {
        return AppGlobals.getIntCoreSetting(Settings.Secure.LONG_PRESS_TIMEOUT,
                DEFAULT_LONG_PRESS_TIMEOUT);
    }

但是在DrawerLayout中,他重写了ViewDragHelper.Callback,并把PEEK_DELAY的值定为160ms。也就是说,在触摸屏幕的160ms后,就会被判断为拖拽然后被拦截,在时间上完成覆盖了长按。

/**
 * Length of time to delay before peeking the drawer.
 */
private static final int PEEK_DELAY = 160; // ms
private class ViewDragCallback extends ViewDragHelper.Callback {
    ....
    @Override
    public void onEdgeTouched(int edgeFlags, int pointerId) {
         postDelayed(mPeekRunnable, PEEK_DELAY);
     }
}

这里需要补充一下什么是PEEK,PEEK 效果是指当用户在屏幕边缘触摸并短暂停留时,抽屉会稍微拉出一点然后缩回去,提示用户这里有抽屉。

既然知道了PEEK是什么,那么解决方案就有如下两种:

方案一,拦截器在特定View时放行DOWN事件

我们很清楚的知道,这个DrawerLayout之所以能进行PEEK,就是对DOWN事件进行了拦截。那么我就可以重写它的拦截器,然后当我们监测到当前点击位置的View是我们需要长按的View时,我们就对这个事件放行:

override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
    when (ev.action) {
        MotionEvent.ACTION_DOWN -> {
            val childView = findTopChildUnder(ev.x.toInt(), ev.y.toInt())
            if(shouldProtectLongPress(childView) ){
                return false
            }
        }

    }
    return super.onInterceptTouchEvent(ev)
}
private fun findTopChildUnder(x: Int, y: Int): View? {
        for (i in childCount - 1 downTo 0) {
            val child = getChildAt(i)
            if (x >= child.left && x < child.right &&
                y >= child.top && y < child.bottom) {
                return child
            }
        }
        return null
    }
private fun shouldProtectLongPress(view: View?): Boolean {
        if (view == null) return false
        //这里添加需要放行的View,
    	//一般我们自己多继承一层不影响普通layout的正常使用
        val ret =  view.isLongClickable ||
                view is MyLayout  ||
                view is MyLayout1 || 
                hasLongClickListeners(view)
        Log.d(TAG, "shouldProtectLongPress: $view, $ret")
        return ret
    }

这个方案比较简单和直观,但同时也因为是直接对DOWN放行,所以对MyLayout的范围要求就比较严格。同时对于不在顶层的长按事件,就需要改写findTopChildUnder来多进行几层遍历,比较吃性能。

方案二,换掉整个ViewDragCallback

由源码可知:这个PEEK之所以生效,是因为onEdgeTouched方法postDelayed了他,我们可以直接重新写一个ViewDragCallback来把原来的ViewDragCallback替换掉。

那么这样会不会影响到其他功能的实现呢?当然不会,因为我们可以先利用反射拿到原来的ViewDragCallback,然后对着源码一个一个的重写所涉及到的方法,并在里面直接调用原ViewDragCallback.对应的方法。然后在onEdgeTouched方法中为空,或者postDelayed一个比长按判定时长大的PEEK就可。

init {
    attrs?.let { parseAttributes(it) }
    //布局完成后替换回调
    post {
        replaceDragCallbacks()
    }
}
 private fun replaceDragCallbacks() {
        try {
            val leftGravity = Gravity.START
            val rightGravity = Gravity.END
            
            // 替换回调
            replaceCallbackForGravity(leftGravity)
            replaceCallbackForGravity(rightGravity)
        } catch (e: Exception) {
            e.printStackTrace()
        }
    }
private fun replaceCallbackForGravity(gravity: Int) {
    try {
        //反射获取ViewDragHelper
            val dragger = when (gravity) {
                Gravity.START, Gravity.LEFT -> {
                    val leftDraggerField = DrawerLayout::class.java.getDeclaredField("mLeftDragger")
                    leftDraggerField.isAccessible = true
                    leftDraggerField.get(this) as ViewDragHelper
                }
                Gravity.END, Gravity.RIGHT -> {
                    val rightDraggerField = DrawerLayout::class.java.getDeclaredField("mRightDragger")
                    rightDraggerField.isAccessible = true
                    rightDraggerField.get(this) as ViewDragHelper
                }
                else -> return
            }
        //反射获取原始 callback
            val callbackField = ViewDragHelper::class.java.getDeclaredField("mCallback")
            callbackField.isAccessible = true
            val originalCallback = callbackField.get(dragger) as ViewDragHelper.Callback
        	val newCallback = object : ViewDragHelper.Callback() {
            	//源码涉及的方法
                override fun tryCaptureView(child: View, pointerId: Int): Boolean {
                    //直接返回原Callback的对应方法
                    return originalCallback.tryCaptureView(child, pointerId)
                }
            	//...其他源码涉及的方法
            
            	//onEdgeTouched空实现
            	override fun onEdgeTouched(edgeFlags: Int, pointerId: Int) {
                    // 不调用: originalCallback.onEdgeTouched(edgeFlags, pointerId)
                }
            // 替换 callback
            callbackField.set(dragger, newCallback)
        }
    }catch (e: Exception) {
            e.printStackTrace()
        }
}

这样就完成了,看起来工作量比较大,但是在编写的时候可以直接复制源码,然后改写,也不会有什么特别的问题。同时,目前测试下来使用没有什么问题。完成了对PEEK的禁用。

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐