10分钟可以用 Compose 写个 SlidingUpPanel?

背景

前景背景面板的布局在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'
  }
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容