美文网首页
10分钟可以用 Compose 写个 SlidingUpPane

10分钟可以用 Compose 写个 SlidingUpPane

作者: minminaya | 来源:发表于2022-06-09 22:31 被阅读0次

    背景

    前景背景面板的布局在2022年的今天应用市场上应该绝大部分 APP 都采用了,特别是比如地图,打车,购物,直播 APP(带货)下面的面板交互实现

    在 Android View 体系中,需要实现前景背景面板还挺麻烦的,通常的方案如下:

    • 1、xml 中实现 FrameLayout,分别放置前景板布局和背景布局
    • 2、定义前景面板的拖动状态
    • 3、拦截事件分发控制前景面板的拖动,控制滑动范围(或者用 ViewDragHelper 实现自定义拖动和动画控制,也要相当大的代码量才能精确控制)
    • 4、实现某个状态点附近的回弹动画

    GitHub 中有很多类似的开源方案,其中 Star 最多的是 AndroidSlidingUpPanel,其核心实现类 AndroidSlidingUpPanel 也有将近1500行。

    今天我们就来挑战下 10分钟能不能用 Compose 版本的 SlidingUpPanel

    确定方案

    Compose 版本实现理论上和 View 体系实现差不多,无非也就是布局,拖动控制,范围控制

    • 布局:能画出来就行,通常都是用 Box

    • 拖动控制:Modifier的扩展千奇百样,特别是手势相关的,最基础的无非是使用 Modifier.pointerInput() 纯控制事件分发来控制布局(类似 View 体系)。而且还有逻辑高度封装的 draggable 修饰符或者 swipeable 修饰符可以使用,这里我们要拖动并且也要可以动画控制滑动,采用 swipeable 修饰符即可,配合 SwipeableState 就可以控制滑动或者动画

      手势 | Jetpack Compose | Android Developers

    • 范围控制:swipeable 修饰符直接自带!!!

    实现

    布局

    上来肯定是先画出布局,这里直接无脑 Box Box Box,Box 三连

    @Composable
    fun SlidingUpPanel(
        backgroundContent: @Composable BoxScope.() -> Unit,
        foregroundContent: @Composable BoxScope.() -> Unit
    ) {
    
        Box(
            modifier = Modifier.fillMaxSize()
        ) {
            Box(
                modifier = Modifier.fillMaxSize()
            ) {
                backgroundContent()
            }
            Box(
                modifier = Modifier
                    .fillMaxSize()
            ) {
                foregroundContent()
            }
        }
    }
    
    

    拖动控制

    根据官方文档 手势 | Jetpack Compose | Android Developers 的描述和示范例子,还有 swipeable 修饰符的文档描述,惊了大离谱吧,swipeable 不仅可以让布局滑动,还可以通过 anchors 来控制滑动状态点的距离,甚至还可以动画回弹

    @OptIn(ExperimentalMaterialApi::class)
    @Composable
    fun SlidingUpPanel(
        swipeableState: SwipeableState<PanelStateEnum> = rememberSwipeableState(PanelStateEnum.COLLAPSED),
        enabled: Boolean = true,
        backgroundContent: @Composable BoxScope.() -> Unit,
        foregroundContent: @Composable BoxScope.() -> Unit
    ) {
    
        val anchors = remember {
            mapOf(
                0F to PanelStateEnum.EXPANDED,
                200F to PanelStateEnum.ANCHORED,
                500F to PanelStateEnum.COLLAPSED,
                900F to PanelStateEnum.HIDDEN,
            )
        }
    
        Box(
            modifier = Modifier.fillMaxSize()
        ) {
            Box(
                modifier = Modifier.fillMaxSize()
            ) {
                backgroundContent()
            }
            Box(modifier = Modifier
                .offset {
                    IntOffset(x = 0, y = swipeableState.offset.value.roundToInt())
                }
                .fillMaxSize()
                .swipeable(
                    state = swipeableState,
                    anchors = anchors,
                    orientation = Orientation.Vertical,
                    enabled = enabled
                )) {
                foregroundContent()
            }
        }
    }
    
    

    就这样就已经支持4个状态的滑动控制+边界控制+边界回弹啦!!!

    简单封装下

    上述 anchors 其实只需要 ANCHORED点的偏移和 COLLAPSED 偏移值就好了,定义一个类输入和保存传递相关的值

    /**
     *
     * 保存了面板偏移高度相关的参数,偏移量指从上往下
     *
     * @property context Context
     * @property anchoredOffsetRatio Double anchored 状态偏移占屏幕高度的比例
     * @property collapsedOffsetRatio Double collapsed 状态偏移占屏幕高度的比例
     * @property screenHeight Int 屏幕高度
     * @property anchoredOffset Float anchored 状态偏移
     * @property collapsedOffset Float collapsed 状态偏移
     * @property hiddenOffset Float hidden 状态偏移
     * @constructor
     */
    data class PanelStateOffset(
        val context: Context,
        var anchoredOffsetRatio: Double = 0.25,
        var collapsedOffsetRatio: Double = 0.75,
        var screenHeight: Int = screenHeight(context),
        private var anchoredOffset: Float = 0f,
        private var collapsedOffset: Float = 0f,
        val hiddenOffset: Float = screenHeight.toFloat(),
    ) {
        fun anchoredOffset() = (screenHeight * anchoredOffsetRatio).toFloat()
        fun collapsedOffset() = (screenHeight * collapsedOffsetRatio).toFloat()
    
        fun setOffsetRatio(
            anchoredOffsetRatio: Double, collapsedOffsetRatio: Double, screenHeight: Int = -1
        ) {
            this.anchoredOffsetRatio = anchoredOffsetRatio
            this.collapsedOffsetRatio = collapsedOffsetRatio
            this.screenHeight = if (screenHeight != -1) screenHeight else this.screenHeight
        }
    
        companion object {
            /**
             * 物理尺寸高度
             *
             * @param context Context
             * @return Int
             */
            @Stable
            fun screenHeight(context: Context): Int {
                val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager? ?: return -1
                val point = Point()
                wm.defaultDisplay.getRealSize(point)
                return point.y
            }
        }
    }
    
    

    SlidingUpPanel 最终代码变为

    /**
     * 前景背景面板布局
     *
     * @param initialPanelState [PanelStateEnum] 面板初始化状态
     * @param swipeableState SwipeableState<PanelStateEnum> swipeable修饰符的状态
     * @param panelStateOffset [@kotlin.ExtensionFunctionType] Function2<PanelStateOffset, Int, Unit> 可以预设面板的各个高度参数
     * @param enabled Boolean 是否开启滑动
     * @param backgroundContent [@androidx.compose.runtime.Composable] [@kotlin.ExtensionFunctionType] Function1<BoxScope, Unit> 背景布局
     * @param foregroundContent [@androidx.compose.runtime.Composable] [@kotlin.ExtensionFunctionType] Function1<BoxScope, Unit> 前景布局
     */
    @OptIn(ExperimentalMaterialApi::class)
    @Composable
    fun SlidingUpPanel(
        initialPanelState: PanelStateEnum = PanelStateEnum.COLLAPSED,
        swipeableState: SwipeableState<PanelStateEnum> = rememberSwipeableState(initialPanelState),
        panelStateOffset: PanelStateOffset.(Int) -> Unit = {},
        enabled: Boolean = true,
        backgroundContent: @Composable BoxScope.() -> Unit,
        foregroundContent: @Composable BoxScope.() -> Unit
    ) {
        val context = LocalContext.current
        val panelOffset = remember {
            PanelStateOffset(context)
        }
    
        panelStateOffset.invoke(panelOffset, panelOffset.screenHeight)
    
        val anchors = remember {
            mapOf(
                0F to PanelStateEnum.EXPANDED,
                panelOffset.anchoredOffset() to PanelStateEnum.ANCHORED,
                panelOffset.collapsedOffset() to PanelStateEnum.COLLAPSED,
                panelOffset.hiddenOffset to PanelStateEnum.HIDDEN,
            )
        }
    
        Box(
            modifier = Modifier.fillMaxSize()
        ) {
            Box(
                modifier = Modifier.fillMaxSize()
            ) {
                backgroundContent()
            }
            Box(modifier = Modifier
                .offset {
                    IntOffset(x = 0, y = swipeableState.offset.value.roundToInt())
                }
                .fillMaxSize()
                .swipeable(
                    state = swipeableState,
                    anchors = anchors,
                    orientation = Orientation.Vertical,
                    enabled = enabled
                )) {
                foregroundContent()
            }
        }
    }
    
    

    使用

    只需要和传统方式类似 Button,Box 一样使用即可

    @OptIn(ExperimentalMaterialApi::class)
    @Composable
    fun Home() {
        val scope = rememberCoroutineScope()
        val swipeableState = rememberSwipeableState(PanelStateEnum.COLLAPSED)
    
        SlidingUpPanel(swipeableState = swipeableState, panelStateOffset = {
            this.collapsedOffsetRatio = 0.9
            this.anchoredOffsetRatio = 0.1
        }, backgroundContent = {
            Box(modifier = Modifier
                .border(width = 1.dp, color = Color.Black)
                .fillMaxSize()
                .background(Color.Green), contentAlignment = Alignment.TopCenter) {
                BasicText(
                    "背景面板", style = TextStyle.Default.copy(color = Color.Red, fontSize = 25.sp)
                )
                ButtonColumn(scope, swipeableState)
            }
        }, foregroundContent = {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .background(Color.Yellow)
                    .border(width = 1.dp, color = Color.Black),
                contentAlignment = Alignment.TopCenter,
            ) {
                BasicText(
                    "前景面板", style = TextStyle.Default.copy(color = Color.Red, fontSize = 25.sp)
                )
                ButtonColumn(scope, swipeableState)
            }
        })
    }
    
    

    手动控制滑动

    有时候我们要根据业务情况来控制面板的移动,只需要通过 SwipeableStateanimateTo()【有动画】 或者 snapTo()【无动画】控制即可

    例如,让面板动画移动到 EXPANDED 状态只需要调用

    swipeableState.animateTo(PanelStateEnum.EXPANDED)
    
    

    有图有真相

    Gif图巨大,耐心等待


    开源地址

    minminaya/SlidingUpPanel-compose: SlidingUpPanel layout for Android Compose (github.com)

    一键依赖

    • 1、添加maven 地址
    allprojects {
        repositories {
          ...
          maven { url 'https://jitpack.io' }
        }
      }
    
    • 2、声明依赖
    dependencies {
           implementation 'com.github.minminaya:SlidingUpPanel-compose:Tag'
      }
    

    相关文章

      网友评论

          本文标题:10分钟可以用 Compose 写个 SlidingUpPane

          本文链接:https://www.haomeiwen.com/subject/woxvmrtx.html