Jetpack Compose : 一文学会嵌套滚动NestedScrollConnection

1,750 阅读3分钟

前言

日常开发中我们难免遇到嵌套滚动的需求,那么在Compose中又是如何实现的呢?本文将用最简单的代码实现一个嵌套滚动页面。😁

NestedScrollConnection

Compose中可以使用 nestedScroll 修饰符定义嵌套滚动层次结构来提高灵活性。

object : NestedScrollConnection {
    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        return super.onPreScroll(available, source)
    }

    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource
    ): Offset {
        return super.onPostScroll(consumed, available, source)
    }

    override suspend fun onPreFling(available: Velocity): Velocity {
        return super.onPreFling(available)
    }

    override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
        return super.onPostFling(consumed, available)
    }
}

onPreScroll:预处理滑动事件,先交给父组件消费后再交由子组件 available:当前可用滑动偏移量 source:滑动类型 返回值:当前消费的滑动偏移量,如果不想消费可返回Offset.Zero

onPostScroll:子组件滑动后的回调 consumed:之前件消费滑动偏移量 available:当前剩余可用滑动偏移量 source:滑动事件的类型 返回值:当前消费的滑动偏移量,如果不想消费可返回Offset.Zero,则剩下偏移量会继续交由父组件进行处理

onPreFling 惯性滚动事件预处理。 available:开始时的速度 返回值:当前组件消费的速度,如果不想消费可返回 Velocity.Zero

onPostFling 惯性滚动事件处理 consumed:之前消费的所有速度 available:当前剩余可用的速度 返回值:当前组件消费的速度,如果不想消费可返回Velocity.Zero,则剩下速度会继续交由父组件进行处理。

接下来我们来实现下图效果:

tutieshi_616x1280_1s.gif

我们先来实现布局,可以分为头部和列表伪代码如下:

Column{
	Box{}
	LazyColumn{}
}

接下来让头部和列表进行联动,我的想法是动态改变头部高度伪代码如下:

val titleBarSize = 45.dp
val targetHeight = 100.dp
val targetPercent by remember { mutableStateOf(1f) }

Column{
	Box(            
		modifier = Modifier
                .fillMaxWidth()
                .height(titleBarSize + targetHeight * targetPercent.value)
		){}
	LazyColumn(
		modifier = Modifier.fillMaxSize()
	){}
}

NestedScrollConnection提供onPreScroll来预处理滑动事件,所以我们在此处理targetPercent的逻辑,伪代码如下:

val nestedScrollConnection = remember {
        object : NestedScrollConnection {

            var dyConsumed = 0f //记录消费距离

            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                val delta = available.y // delta<0向上滚动,反之向下滚动
                dyConsumed += delta
                dyConsumed = dyConsumed.coerceAtMost(0f)
                val percent = dyConsumed / targetHeightPx // 计算滚动距离百分比
                coroutineScope.launch {
                    targetPercent = 1 - abs(percent.coerceIn(-1f, 0f))
                }
                if (percent > -1 && percent < 0) { //向上滚动时先交给父组件消费
                    return Offset(0f, delta)
                }
                return Offset.Zero
            }
        }
    }

以上就是思路和核心代码,最后按照惯例贴上完整代码:

@Composable
fun UserScreen(
    userId: String,
    onNavigateToLogin: () -> Unit = {},
    onNavigateToSystem: (cid: String) -> Unit = {},
    onNavigateToWeb: (url: String) -> Unit = {},
) {
    val context = LocalContext.current
    val coroutineScope = rememberCoroutineScope()
    val sw = context.getScreenWidth()
    val titleBarSize = 45.dp
    val titleBarSizePx = with(LocalDensity.current) { titleBarSize.roundToPx().toFloat() }
    val avatarOffsetXPx = (sw - titleBarSizePx) / 2
    val avatarOffsetX = Dp(context.px2dp(avatarOffsetXPx))
    val targetHeight = 100.dp
    val targetHeightPx = with(LocalDensity.current) { targetHeight.roundToPx().toFloat() }
    val targetPercent by remember { mutableStateOf(Animatable(1f)) }

    val nestedScrollConnection = remember {
        object : NestedScrollConnection {

            var dyConsumed = 0f

            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
                val delta = available.y
                dyConsumed += delta
                dyConsumed = dyConsumed.coerceAtMost(0f)
                val percent = dyConsumed / targetHeightPx
                coroutineScope.launch {
                    targetPercent.animateTo(1 - abs(percent.coerceIn(-1f, 0f)))
                }
                if (percent > -1 && percent < 0) {
                    return Offset(0f, delta)
                }
                return Offset.Zero
            }
        }
    }

    val viewModel: UserViewModel = viewModel(
        factory = UserViewModel.provideFactory(userId)
    )
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    Column(
        modifier = Modifier
                .systemBarsPadding()
                .nestedScroll(nestedScrollConnection)
    ) {
        Box(
            modifier = Modifier
                    .background(colorResource(R.color.theme))
                    .fillMaxWidth()
                    .height(titleBarSize + targetHeight * targetPercent.value)
        ) {
            IconButton(
                modifier = Modifier.height(titleBarSize),
                onClick = {
                    if (context is AppCompatActivity) {
                        context.onBackPressedDispatcher.onBackPressed()
                    }
                }
            ) {
                Icon(
                    Icons.Filled.ArrowBack,
                    contentDescription = null,
                    tint = colorResource(R.color.white)
                )
            }
            Image(
                painter = painterResource(id = uiState.coinResult.getAvatarId()),
                contentDescription = null,
                contentScale = ContentScale.Crop,
                modifier = Modifier
                        .size(titleBarSize * targetPercent.value.coerceAtLeast(0.75f))
                        .align(Alignment.Center)
                        .offset(x = -(avatarOffsetX - titleBarSize) * (1 - targetPercent.value))
                        .clip(CircleShape),
            )
            Text(
                text = uiState.coinResult.nickname,
                modifier = Modifier
                        .align(Alignment.Center)
                        .offset(
                            x = -(avatarOffsetX - (titleBarSize * 2)) * (1 - targetPercent.value),
                            y = 35.dp * targetPercent.value
                        ),
                fontSize = 16.sp,
                color = colorResource(R.color.text_fff),
            )
            Text(
                text = "积分:${uiState.coinResult.coinCount}",
                modifier = Modifier
                        .align(Alignment.Center)
                        .offset(x = 0.dp, y = 55.dp * targetPercent.value)
                        .graphicsLayer {
                            alpha = targetPercent.value
                        },
                fontSize = 12.sp,
                color = colorResource(R.color.text_fff),
            )
        }
        BoxLayout(uiState.refreshing && !uiState.loading) {
            SwipeRefresh(
                modifier = Modifier
                        .fillMaxSize()
                        .background(colorResource(R.color.white)),
                contentPadding = PaddingValues(10.dp),
                verticalArrangement = Arrangement.spacedBy(10.dp),
                refreshing = uiState.refreshing,
                loading = uiState.loading,
                onRefresh = { viewModel.getShareArticlesHome() },
                onLoad = { viewModel.getShareArticlesNext() },
                onRetry = { viewModel.getShareArticlesHome() },
                data = uiState.articleResult,
            ) { _, item ->
                ArticleCard(
                    item = item,
                    onNavigateToLogin = onNavigateToLogin,
                    onNavigateToSystem = onNavigateToSystem,
                    onNavigateToWeb = onNavigateToWeb
                )
            }
        }
    }

}

Thanks

以上就是本篇文章的全部内容,如有问题欢迎指出,我们一起进步。
如果觉得本篇文章对您有帮助的话请点个赞让更多人看到吧,您的鼓励是我前进的动力。
谢谢~~

源代码地址

推荐阅读