前言
使用 Jetpack Compose 实现首页的下拉小程序入口是我这一期开发中到目前为止觉得最有挑战性的,主要是下拉的回弹效果不好实现,类似能参考的功能很少,以至于我前期搜索阻尼滑动,惯性滑动等,都找不到我想要的东西,直到我看到了Jetpack Compose 实现iOS的回弹效果到底有多简单?跟着我,不难!(上)这篇文章,才找到了思路。
先看看实现的效果
功能拆分
这一部分主要由3部分组成,分别是会话列表,下拉动画(三个点部分)和小程序页面,这里我采用帧布局的方式,如下图:
其中红的C为会话列表布局(上),蓝色B为下拉动画布局(中),绿色A为小程序布局(下)
流程分析
- 会话列表C下拉出现动画布局B
- B布局的高度为动态C下拉的高度
- C下拉到一定高度CB布局消失出现小程序布局A
- 小程序布局上拉到一定高度或点击底部回到会话列表
会话列表的实现
会话列表这里的主要困难点是下拉回弹效果的实现,在这里我主要参考了Jetpack Compose 实现iOS的回弹效果到底有多简单?跟着我,不难!(上),在这篇文章的基础上进行了调整实现了当前的功能
主要代码:
/**
* A parabolic rolling easing curve.
*
* When rolling in the same direction, the farther away from 0, the greater the "resistance"; the closer to 0, the smaller the "resistance";
*
* No drag effect is applied when the scrolling direction is opposite to the currently existing overscroll offset
*
* Note: when [p] = 50f, its performance should be consistent with iOS
* @param currentOffset Offset value currently out of bounds
* @param newOffset The offset of the new scroll
* @param p Key parameters for parabolic curve calculation
* @param density Without this param, the unit of offset is pixels,
* so we need this variable to have the same expectations on different devices.
*/
@Stable
fun parabolaScrollEasing(currentOffset: Float, newOffset: Float, p: Float = 50f, density: Float = 4f): Float {
val realP = p * density
val ratio = (realP / (sqrt(realP * abs(currentOffset + newOffset / 2).coerceAtLeast(Float.MIN_VALUE)))).coerceIn(Float.MIN_VALUE, 1f)
return if (sign(currentOffset) == sign(newOffset)) {
currentOffset + newOffset * ratio
} else {
currentOffset + newOffset
}
}
/**
* Linear, you probably wouldn't think of using it.
*/
val LinearScrollEasing: (currentOffset: Float, newOffset: Float) -> Float = { currentOffset, newOffset -> currentOffset + newOffset }
internal val DefaultParabolaScrollEasing: (currentOffset: Float, newOffset: Float) -> Float
@Composable
get() {
val density = LocalDensity.current.density
return { currentOffset, newOffset ->
parabolaScrollEasing(currentOffset, newOffset, density = density)
}
}
internal const val OutBoundSpringStiff = 150f
internal const val OutBoundSpringDamp = 0.86f
/**
* @see overScrollOutOfBound
*/
fun Modifier.overScrollVertical(
isStartScroll: Boolean = true,
isEndScroll: Boolean = false,
nestedScrollToParent: Boolean = true,
scrollEasing: ((currentOffset: Float, newOffset: Float) -> Float)? = null,
springStiff: Float = OutBoundSpringStiff,
springDamp: Float = OutBoundSpringDamp,
scrollOffset: (offset: Float) -> Unit,
): Modifier = overScrollOutOfBound(isStartScroll = isStartScroll, isEndScroll = isEndScroll, isVertical = true, nestedScrollToParent, scrollEasing, springStiff, springDamp, scrollOffset = scrollOffset)
/**
* @see overScrollOutOfBound
*/
fun Modifier.overScrollHorizontal(
isStartScroll: Boolean = true,
isEndScroll: Boolean = false,
nestedScrollToParent: Boolean = true,
scrollEasing: ((currentOffset: Float, newOffset: Float) -> Float)? = null,
springStiff: Float = OutBoundSpringStiff,
springDamp: Float = OutBoundSpringDamp,
): Modifier = overScrollOutOfBound(isStartScroll = isStartScroll, isEndScroll = isEndScroll, isVertical = false, nestedScrollToParent, scrollEasing, springStiff, springDamp, scrollOffset = {})
/**
* OverScroll effect for scrollable Composable .
*
* - You should call it before Modifiers with similar semantics such as [Modifier.scrollable], so that nested scrolling can work normally
* - You should use it with [rememberOverscrollFlingBehavior]
* @Author: liuchaoqin
* @param isEndScroll Is it allowed to have elastic effects at the end ?
* @param isVertical is vertical, or horizontal ?
* @param nestedScrollToParent Whether to dispatch nested scroll events to parent
* @param scrollEasing U can refer to [DefaultParabolaScrollEasing], The incoming values are the currently existing overscroll Offset
* and the new offset from the gesture.
* modify it to cooperate with [springStiff] to customize the sliding damping effect.
* The current default easing comes from iOS, you don't need to modify it!
* @param springStiff springStiff for overscroll effect,For better user experience, the new value is not recommended to be higher than[Spring.StiffnessMediumLow]
* @param springDamp springDamp for overscroll effect,generally do not need to set
*/
@Suppress("NAME_SHADOWING")
fun Modifier.overScrollOutOfBound(
isStartScroll: Boolean = true,
isEndScroll: Boolean = false,
isVertical: Boolean = true,
nestedScrollToParent: Boolean = true,
scrollEasing: ((currentOffset: Float, newOffset: Float) -> Float)?,
springStiff: Float = OutBoundSpringStiff,
springDamp: Float = OutBoundSpringDamp,
scrollOffset: (offset: Float) -> Unit,
): Modifier = composed {
val nestedScrollToParent by rememberUpdatedState(nestedScrollToParent)
val scrollEasing by rememberUpdatedState(scrollEasing ?: DefaultParabolaScrollEasing)
val springStiff by rememberUpdatedState(springStiff)
val springDamp by rememberUpdatedState(springDamp)
val isVertical by rememberUpdatedState(isVertical)
val dispatcher = remember { NestedScrollDispatcher() }
var offset by remember { mutableFloatStateOf(0f) }
val nestedConnection = remember {
object : NestedScrollConnection {
/**
* If the offset is less than this value, we consider the animation to end.
*/
val visibilityThreshold = 0.5f
lateinit var lastFlingAnimator: Animatable<Float, AnimationVector1D>
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// Found fling behavior in the wrong direction.
if (source != NestedScrollSource.Drag) {
return dispatcher.dispatchPreScroll(available, source)
}
if (::lastFlingAnimator.isInitialized && lastFlingAnimator.isRunning) {
dispatcher.coroutineScope.launch {
lastFlingAnimator.stop()
}
}
val realAvailable = when {
nestedScrollToParent -> available - dispatcher.dispatchPreScroll(available, source)
else -> available
}
val realOffset = if (isVertical) realAvailable.y else realAvailable.x
val isSameDirection = sign(realOffset) == sign(offset)
if (abs(offset) <= visibilityThreshold || isSameDirection) {
return available - realAvailable
}
val offsetAtLast = scrollEasing(offset, realOffset)
// sign changed, coerce to start scrolling and exit
return if (sign(offset) != sign(offsetAtLast)) {
offset = 0f
if (isVertical) {
Offset(x = available.x - realAvailable.x, y = available.y - realAvailable.y + realOffset)
} else {
Offset(x = available.x - realAvailable.x + realOffset, y = available.y - realAvailable.y)
}
} else {
offset = offsetAtLast
if (isVertical) {
Offset(x = available.x - realAvailable.x, y = available.y)
} else {
Offset(x = available.x, y = available.y - realAvailable.y)
}
}
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
// Found fling behavior in the wrong direction.
if (source != NestedScrollSource.Drag) {
return dispatcher.dispatchPreScroll(available, source)
}
val realAvailable = when {
nestedScrollToParent -> available - dispatcher.dispatchPostScroll(consumed, available, source)
else -> available
}
offset = scrollEasing(offset, if (isVertical) realAvailable.y else realAvailable.x)
return if (isVertical) {
Offset(x = available.x - realAvailable.x, y = available.y)
} else {
Offset(x = available.x, y = available.y - realAvailable.y)
}
}
override suspend fun onPreFling(available: Velocity): Velocity {
if (::lastFlingAnimator.isInitialized && lastFlingAnimator.isRunning) {
lastFlingAnimator.stop()
}
val parentConsumed = when {
nestedScrollToParent -> dispatcher.dispatchPreFling(available)
else -> Velocity.Zero
}
val realAvailable = available - parentConsumed
var leftVelocity = if (isVertical) realAvailable.y else realAvailable.x
if (abs(offset) >= visibilityThreshold && sign(leftVelocity) != sign(offset)) {
lastFlingAnimator = Animatable(offset).apply {
when {
leftVelocity < 0 -> updateBounds(lowerBound = 0f)
leftVelocity > 0 -> updateBounds(upperBound = 0f)
}
}
leftVelocity = lastFlingAnimator.animateTo(0f, spring(springDamp, springStiff, visibilityThreshold), leftVelocity) {
offset = scrollEasing(offset, value - offset)
}.endState.velocity
}
return if (isVertical) {
Velocity(parentConsumed.x, y = available.y - leftVelocity)
} else {
Velocity(available.x - leftVelocity, y = parentConsumed.y)
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val realAvailable = when {
nestedScrollToParent -> available - dispatcher.dispatchPostFling(consumed, available)
else -> available
}
lastFlingAnimator = Animatable(offset)
lastFlingAnimator.animateTo(0f, spring(springDamp, springStiff, visibilityThreshold), if (isVertical) realAvailable.y else realAvailable.x) {
offset = scrollEasing(offset, value - offset)
}
return if (isVertical) {
Velocity(x = available.x - realAvailable.x, y = available.y)
} else {
Velocity(x = available.x, y = available.y - realAvailable.y)
}
}
}
}
scrollOffset(offset)
if (!isEndScroll && offset < 0f) {
offset = 0f
}
if (!isStartScroll && offset > 0f) {
offset = 0f
}
this
.clipToBounds()
.nestedScroll(nestedConnection, dispatcher)
.graphicsLayer {
if (isVertical) translationY = offset else translationX = offset
}
}
/**
* You should use it with [overScrollVertical]
* @param decaySpec You can use instead [rememberSplineBasedDecay]
* @param getScrollState Pass in your [ScrollableState], for [LazyColumn]/[LazyRow] , it's [LazyListState]
*/
@Composable
fun rememberOverscrollFlingBehavior(
decaySpec: DecayAnimationSpec<Float> = exponentialDecay(),
getScrollState: () -> ScrollableState,
): FlingBehavior = remember(decaySpec, getScrollState) {
object : FlingBehavior {
/**
* - We should check it every frame of fling
* - Should stop fling when returning true and return the remaining speed immediately.
* - Without this detection, scrollBy() will continue to consume velocity,
* which will cause a velocity error in nestedScroll.
*/
private val Float.canNotBeConsumed: Boolean // this is Velocity
get() {
val state = getScrollState()
return !(this < 0 && state.canScrollBackward || this > 0 && state.canScrollForward)
}
override suspend fun ScrollScope.performFling(initialVelocity: Float): Float {
if (initialVelocity.canNotBeConsumed) {
return initialVelocity
}
return if (abs(initialVelocity) > 1f) {
var velocityLeft = initialVelocity
var lastValue = 0f
AnimationState(
initialValue = 0f,
initialVelocity = initialVelocity,
).animateDecay(decaySpec) {
val delta = value - lastValue
val consumed = scrollBy(delta)
lastValue = value
velocityLeft = this.velocity
// avoid rounding errors and stop if anything is unconsumed
if (abs(delta - consumed) > 0.5f || velocityLeft.canNotBeConsumed) {
cancelAnimation()
}
}
velocityLeft
} else {
initialVelocity
}
}
}
}
这里主要是重写 NestedScrollConnection 的 onPreScroll,onPostScroll,onPreFling 和 onPostFling 方法来计算 offset 的值,从而通过 graphicsLayer 来实现滚动回弹的效果。
我主要的调整是添加了 isStartScroll 和 isEndScroll 参数控制顶部和底部是否允许回弹,以及添加了 scrollOffset 回调函数回调滚动的距离来控制下拉动画和小程序页面的交互。
列表调用:
var offset by remember { mutableStateOf(0f) }
val fullHeight = with(LocalContext. current) {
resources. displayMetrics. heightPixels
}
val density = LocalDensity.current.density
val springStiff by remember { mutableFloatStateOf(Spring.StiffnessLow) }
val springDamp by remember { mutableFloatStateOf(Spring.DampingRatioLowBouncy) }
val dragP by remember { mutableFloatStateOf(50f) }
/*** 列表的显示和隐藏 */
var visible by remember { mutableStateOf(false) }
/*** 滚动的百分比 */
var scrollPercent by remember { mutableFloatStateOf(0f) }
/** 中间圆的大小*/
var ballSize by remember { mutableFloatStateOf(0f) }
/** 出现小程序页面的滚动高度*/
val target = fullHeight / 4
val backgroundColor = Color(0xffEDEDED)
/** 左右两个小圆点的X轴偏移量*/
var offsetX by remember { mutableStateOf(0f) }
LazyColumn(
contentPadding = innerPadding,
state = scrollState,
modifier = Modifier
.overScrollVertical(
isStartScroll = true,
isEndScroll = false,
nestedScrollToParent = false,
scrollEasing = { x1, x2 -> parabolaScrollEasing(x1, x2, dragP) },
springStiff = springStiff,
springDamp = springDamp,
scrollOffset = { x3 ->
offset = x3
if (offset > target) {
visible = true
scrollPercent = 1.0f
} else if (!visible) {
scrollPercent = offset / target
}
scrollPercent = if (scrollPercent < 0f) 0.0f else scrollPercent
ballSize = scrollPercent * 70
println("===offset:$offset ====visible:$visible ====scrollPercent:$scrollPercent")
onChangeVisible(visible)
offsetX = scrollPercent * 100
})
.alpha(1 - scrollPercent)
.background(Color.White),
flingBehavior = rememberOverscrollFlingBehavior { scrollState }
)
scrollPercent:滚动到目标值的百分比,我们在朋友圈那里也有类似的处理;
offset:滚动的距离;
visible:动画组件AnimatedVisibility 的 visible 属性,这里主要用于控制列表的动画效果,具体的代码为:
AnimatedVisibility(
visible = !visible,
enter = slideInVertically(initialOffsetY = {fullHeight}),
exit = slideOutVertically(targetOffsetY = {fullHeight})
) {
LazyColumn(
contentPadding = innerPadding,
state = scrollState,
modifier = Modifier
.overScrollVertical(
isStartScroll = true,
isEndScroll = false,
nestedScrollToParent = false,
scrollEasing = { x1, x2 -> parabolaScrollEasing(x1, x2, dragP) },
springStiff = springStiff,
springDamp = springDamp,
scrollOffset = { x3 ->
offset = x3
if (offset > target) {
visible = true
scrollPercent = 1.0f
} else if (!visible) {
scrollPercent = offset / target
}
scrollPercent = if (scrollPercent < 0f) 0.0f else scrollPercent
ballSize = scrollPercent * 70
println("===offset:$offset ====visible:$visible ====scrollPercent:$scrollPercent")
onChangeVisible(visible)
offsetX = scrollPercent * 100
})
.alpha(1 - scrollPercent)
.background(Color.White),
flingBehavior = rememberOverscrollFlingBehavior { scrollState }
) {
stickyHeader {
TopAppBar(context)
}
item {
CQDivider()
}
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(45.dp)
.background(Color(0xFFEDEDED))
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(40.dp, 0.dp, 35.dp, 0.dp)
) {
Icon(
imageVector = Icons.Filled.PersonalVideo,
contentDescription = null,
modifier = Modifier.size(23.dp),
tint = Color(ContextCompat.getColor(context, R.color.gray))
)
Text(
text = "mac 微信已登陆",
color = Color(ContextCompat.getColor(context, R.color.gray)),
fontSize = 14.sp,
modifier = Modifier.padding(25.dp, 0.dp, 0.dp, 0.dp)
)
}
}
}
item {
CQDivider()
}
items(messageList) {
it.let {
Column {
MessageItem(it = it, context)
Divider(
color = Color(ContextCompat.getColor(context, R.color.gray_10)),
thickness = 0.2.dp,
modifier = Modifier.padding(70.dp, 0.dp, 0.dp, 0.dp)
)
}
}
}
item {
Spacer(modifier = Modifier.height(60.dp))
}
}
}
}
target:滚动出现小程序页面的目标值,这里设置为屏幕高的1/4;
ballSize:下拉动画布局中间那个点的大小;
offsetX:下拉动画布局左右两个点的X轴偏移距离;
下拉动画的实现
效果
流程分析
列表开始下拉时只出现中间的点,且由小变大,到一定距离后出现左右两个点,随着往下拉左右两个点分别向左右偏移,反之逆向恢复。
这里我通过下拉距离 offset 为下拉动画父布局的动态高度,且用来动态计算 ballSize 和 offsetX的值,然后通过Modifier的 size 属性控制大小,offset 来控制偏移,,具体的实现代码为:
/** 三个点的动画*/
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 5.dp)
.height(px2dp((offset.toInt()), density))
.alpha(1 - scrollPercent)
.background(backgroundColor),
contentAlignment = Alignment.Center
) {
Box(modifier = Modifier
.size(if (scrollPercent > 0.15f) (if (scrollPercent > 0.3f) 6.dp else 10.dp) else ballSize.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color(0xff303030))
)
Box(modifier = Modifier
.offset { IntOffset(if (scrollPercent > 0.15f) -offsetX.roundToInt() else 0, 0) }
.size(6.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xff303030))
)
Box(modifier = Modifier
.offset { IntOffset(if (scrollPercent > 0.15f) offsetX.roundToInt() else 0, 0) }
.size(6.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xff303030))
)
}
小程序页面的实现
这里的实现只是静态布局,所以没有难度,主要的逻辑是上拉到一定距离后返回会话列表,小程序页面的全部代码为:
@Composable
fun MiniProgramScreen(
contentPadding: PaddingValues,
popBackStack: ()-> Unit
) {
val scrollState = rememberLazyListState()
val textState = remember { mutableStateOf(TextFieldValue()) }
val springStiff by remember { mutableFloatStateOf(Spring.StiffnessLow) }
val springDamp by remember { mutableFloatStateOf(Spring.DampingRatioLowBouncy) }
val dragP by remember { mutableFloatStateOf(50f) }
val target = with(LocalContext. current) {
resources. displayMetrics. heightPixels / 4
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomCenter
) {
LazyColumn(
contentPadding = contentPadding,
state = scrollState,
modifier = Modifier.fillMaxSize()
.overScrollVertical(
isStartScroll = false,
isEndScroll = true,
nestedScrollToParent = false,
scrollEasing = { x1, x2 -> parabolaScrollEasing(x1, x2, dragP) },
springStiff = springStiff,
springDamp = springDamp,
scrollOffset = { offset ->
// 上拉超过target时返回首页
if (offset < -target) {
popBackStack()
}
}),
) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp)
.height(50.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"最近",
fontSize = 16.sp,
color = Color.White,
textAlign = TextAlign.Center,
)
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(end = 20.dp),
contentAlignment = Alignment.CenterEnd
) {
BasicTextField(
enabled = false,
value = textState.value,
onValueChange = {
textState.value = it
},
textStyle = TextStyle(
fontSize = 16.sp
),
modifier = Modifier
.width(70.dp)
.height(25.dp),
decorationBox = { innerTextField->
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(20.dp))
.background(Color(0xff434056))
.padding(start = 6.dp),
contentAlignment = Alignment.CenterStart
) {
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Color(0xff8E8E9E)
)
Box(modifier = Modifier
.wrapContentHeight()
.wrapContentWidth()) {
Text(
"搜索",
fontSize = 13.sp,
color = Color(0xff8E8E9E),
textAlign = TextAlign.Center,
)
innerTextField()
}
}
}
},
)
}
}
}
item {
Box(modifier = Modifier.padding(start = 30.dp, bottom = 15.dp, top = 30.dp)){
Text(
"音乐和视频",
fontSize = 14.sp,
color = Color(0xff8E8E9E),
)
}
}
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp)
.height(90.dp)
) {
Box(modifier = Modifier
.fillMaxHeight()
.weight(1f)
) {
Row(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(60.dp)
.clip(RoundedCornerShape(35.dp))
.background(Color(0xff434056)),
contentAlignment = Alignment.Center
) {
Image(
painter = rememberCoilPainter(request = R.mipmap.icon_music),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(30.dp)
)
}
Text(
"音乐",
fontSize = 15.sp,
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
Box(
modifier = Modifier
.fillMaxHeight()
.weight(1f),
contentAlignment = Alignment.Center
) {
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(60.dp)
.clip(RoundedCornerShape(35.dp))
.background(Color(0xff434056)),
contentAlignment = Alignment.Center
) {
Image(
painter = rememberCoilPainter(request = R.mipmap.icon_audio),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(30.dp)
)
}
Text(
"音频",
fontSize = 15.sp,
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
Box(modifier = Modifier
.fillMaxHeight()
.weight(1f)
) {
Column() {
BasicTextField(
enabled = false,
value = textState.value,
onValueChange = {
textState.value = it
},
textStyle = TextStyle(
fontSize = 16.sp
),
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.padding(start = 10.dp)
,
decorationBox = { innerTextField->
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(40.dp))
.background(Color(0xff434056))
.padding(start = 6.dp),
contentAlignment = Alignment.CenterStart
) {
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = null,
modifier = Modifier.size(30.dp),
tint = Color(0xff8E8E9E)
)
Box(modifier = Modifier
.wrapContentHeight()
.wrapContentWidth()) {
Text(
"暂无内容",
fontSize = 16.sp,
color = Color(0xff8E8E9E),
textAlign = TextAlign.Center,
)
innerTextField()
}
}
}
},
)
Text(
"最近播放",
fontSize = 15.sp,
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 10.dp)
)
}
}
}
}
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, bottom = 15.dp, top = 30.dp, end = 30.dp)
.wrapContentHeight()
) {
Row {
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterStart
){
Text(
"最近使用小程序",
fontSize = 14.sp,
color = Color(0xff8E8E9E),
)
}
Box(
modifier = Modifier.weight(1f).wrapContentHeight(align = Alignment.CenterVertically),
contentAlignment = Alignment.CenterEnd
){
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
"更多",
fontSize = 14.sp,
color = Color(0xff8E8E9E),
)
Icon(
Icons.Filled.ArrowForwardIos, null,
modifier = Modifier.size(16.dp).padding(top = 4.dp),
tint = Color(0xff8E8E9E),
)
}
}
}
}
}
item {
MiniProgramUI(miniProgramList)
}
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(start = 30.dp, bottom = 15.dp, top = 30.dp)
.wrapContentHeight(),
contentAlignment = Alignment.CenterStart
) {
Text(
"我的小程序",
fontSize = 14.sp,
color = Color(0xff8E8E9E),
)
}
}
item {
MiniProgramUI(mineMiniProgramList)
}
item {
Spacer(modifier = Modifier.height(80.dp))
}
}
Box(modifier = Modifier
.fillMaxWidth()
.height(70.dp)
.clip(RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp))
.background(Color(0xff787493))
.click {
popBackStack()
}
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"微信",
maxLines = 1,
fontSize = 16.sp,
overflow = TextOverflow.Ellipsis,
color = Color.White
)
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.CenterEnd
) {
Row(
Modifier
.fillMaxSize()
.padding(end = 15.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = {
/* doSomething() */
}) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
modifier = Modifier.size(30.dp),
tint = Color.White
)
}
IconButton(onClick = {
/* doSomething() */
}) {
Icon(
imageVector = Icons.Filled.AddCircleOutline,
contentDescription = null,
modifier = Modifier.size(25.dp),
tint = Color.White
)
}
}
}
}
}
}
@Composable
private fun MiniProgramUI(list: List<MiniProgramItem>) {
LazyVerticalGrid(
modifier = Modifier
.fillMaxWidth()
.padding(start = 15.dp, end = 15.dp)
.height(180.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
columns = GridCells.Fixed(4),
userScrollEnabled = false,
content = {
items(list) {
Box{
Column(
Modifier
.wrapContentHeight()
.wrapContentWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(60.dp)
.clip(RoundedCornerShape(35.dp))
.background(Color(0xff434056)),
contentAlignment = Alignment.Center
) {
Image(
painter = rememberCoilPainter(request = it.icon),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(35.dp))
)
}
Text(
it.title,
fontSize = 15.sp,
color = Color.White,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
)
}
到这里,就实现了会话列表小程序入口的功能了,组合函数ChatSessionScreen全部代码为:
@OptIn(ExperimentalFoundationApi::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun ChatSessionScreen(innerPadding: PaddingValues, viewModel: ChatSessionViewModel = ChatSessionViewModel(), onChangeVisible: (visible: Boolean) -> Unit) {
val context = LocalContext.current
/*** 获取状态栏高度 */
val statusBarHeight = LocalDensity.current.run {
WindowInsets.statusBars.getTop(this).toDp()
}
val scrollState = rememberLazyListState()
var offset by remember { mutableStateOf(0f) }
val fullHeight = with(LocalContext. current) {
resources. displayMetrics. heightPixels
}
val density = LocalDensity.current.density
val springStiff by remember { mutableFloatStateOf(Spring.StiffnessLow) }
val springDamp by remember { mutableFloatStateOf(Spring.DampingRatioLowBouncy) }
val dragP by remember { mutableFloatStateOf(50f) }
var visible by remember { mutableStateOf(false) }
/*** 滚动的百分比 */
var scrollPercent by remember { mutableFloatStateOf(0f) }
/** 中间圆的大小*/
var ballSize by remember { mutableFloatStateOf(0f) }
/** 出现小程序页面的滚动高度*/
val target = fullHeight / 4
val backgroundColor = Color(0xffEDEDED)
/** 左右两个小圆点的X轴偏移量*/
var offsetX by remember { mutableStateOf(0f) }
Box(
modifier = Modifier
.padding(top = statusBarHeight)
.fillMaxSize()
) {
Box(modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.alpha(scrollPercent)
.background(Color(0xff1B1B2B)),
) {
MiniProgramScreen(
contentPadding = innerPadding,
popBackStack = {
visible = false
scrollPercent = 0f
}
)
}
/** 遮罩层*/
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.background(if (scrollPercent < 0.9) backgroundColor else Color.Transparent),
)
/** 三个点的动画*/
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 5.dp)
.height(px2dp((offset.toInt()), density))
.alpha(1 - scrollPercent)
.background(backgroundColor),
contentAlignment = Alignment.Center
) {
Box(modifier = Modifier
.size(if (scrollPercent > 0.15f) (if (scrollPercent > 0.3f) 6.dp else 10.dp) else ballSize.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color(0xff303030))
)
Box(modifier = Modifier
.offset { IntOffset(if (scrollPercent > 0.15f) -offsetX.roundToInt() else 0, 0) }
.size(6.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xff303030))
)
Box(modifier = Modifier
.offset { IntOffset(if (scrollPercent > 0.15f) offsetX.roundToInt() else 0, 0) }
.size(6.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color(0xff303030))
)
}
AnimatedVisibility(
visible = !visible,
enter = slideInVertically(initialOffsetY = {fullHeight}),
exit = slideOutVertically(targetOffsetY = {fullHeight})
) {
LazyColumn(
contentPadding = innerPadding,
state = scrollState,
modifier = Modifier
.overScrollVertical(
isStartScroll = true,
isEndScroll = false,
nestedScrollToParent = false,
scrollEasing = { x1, x2 -> parabolaScrollEasing(x1, x2, dragP) },
springStiff = springStiff,
springDamp = springDamp,
scrollOffset = { x3 ->
offset = x3
if (offset > target) {
visible = true
scrollPercent = 1.0f
} else if (!visible) {
scrollPercent = offset / target
}
scrollPercent = if (scrollPercent < 0f) 0.0f else scrollPercent
ballSize = scrollPercent * 70
println("===offset:$offset ====visible:$visible ====scrollPercent:$scrollPercent")
onChangeVisible(visible)
offsetX = scrollPercent * 100
})
.alpha(1 - scrollPercent)
.background(Color.White),
flingBehavior = rememberOverscrollFlingBehavior { scrollState }
) {
stickyHeader {
TopAppBar(context)
}
item {
CQDivider()
}
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(45.dp)
.background(Color(0xFFEDEDED))
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(40.dp, 0.dp, 35.dp, 0.dp)
) {
Icon(
imageVector = Icons.Filled.PersonalVideo,
contentDescription = null,
modifier = Modifier.size(23.dp),
tint = Color(ContextCompat.getColor(context, R.color.gray))
)
Text(
text = "mac 微信已登陆",
color = Color(ContextCompat.getColor(context, R.color.gray)),
fontSize = 14.sp,
modifier = Modifier.padding(25.dp, 0.dp, 0.dp, 0.dp)
)
}
}
}
item {
CQDivider()
}
items(messageList) {
it.let {
Column {
MessageItem(it = it, context)
Divider(
color = Color(ContextCompat.getColor(context, R.color.gray_10)),
thickness = 0.2.dp,
modifier = Modifier.padding(70.dp, 0.dp, 0.dp, 0.dp)
)
}
}
}
item {
Spacer(modifier = Modifier.height(60.dp))
}
}
}
}
}
/**
* 标题栏
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopAppBar(context: Context) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.background(Color(ContextCompat.getColor(context, R.color.nav_bg)))
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
"微信(8)",
maxLines = 1,
fontSize = 16.sp,
)
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(end = 4.dp),
contentAlignment = Alignment.CenterEnd
) {
Row() {
IconButton(
onClick = {
/* doSomething() */
}) {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
modifier = Modifier.size(30.dp),
tint = Color(
ContextCompat.getColor(
context,
R.color.black_10
)
)
)
}
IconButton(onClick = {
/* doSomething() */
}) {
Icon(
imageVector = Icons.Filled.AddCircleOutline,
contentDescription = null,
modifier = Modifier.size(25.dp),
tint = Color(
ContextCompat.getColor(
context,
R.color.black_10
)
)
)
}
}
}
}
}
@Composable
fun MessageItem(it: MessageItem, context: Context) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(15.dp, 0.dp, 15.dp, 0.dp)
.height(70.dp)
.clickable {
ChatActivity.navigate(context, it)
}
) {
Row(
modifier = Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier
.size(50.dp)) {
Image(
painter = rememberCoilPainter(request = it.avatar),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(6.dp))
)
}
Row {
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier
.padding(15.dp, 0.dp, 0.dp, 0.dp)
.fillMaxHeight()
.weight(3f)
) {
Text(
text = it.name,
fontSize = 17.sp,
color = Color(ContextCompat.getColor(context, R.color.black)),
)
Text(
text = it.message,
fontSize = 12.sp,
color = Color(ContextCompat.getColor(context, R.color.gray_10)),
)
}
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier
.fillMaxHeight()
.weight(1f)) {
Text(
text = it.lastTime,
fontSize = 12.sp,
color = Color(ContextCompat.getColor(context, R.color.gray_10)),
)
}
}
}
}
}
总结
这里的功能虽然实现了,但是离微信原版还有一定的距离,还原度不算很高,比如下拉时小程序页面的出现是由模糊到清晰的,本来我想使用透明度 alpha 属性结合 scrollPercent百分比来实现,但是下拉三个点的布局高度使用下拉值 offset 来动态设置时,下拉过程中与标题栏有间隙,不继续下拉才没有间隙,我不知道是不是渲染延后的问题,这里是需要优化的地方。这一篇文章首次使用到了动画,Jetpack compose的动画种类比较多,功能也很强大,感兴趣的可以看下官方的文档,传送带
项目地址:ComposeWechat,如果对你有用,别忘了给个star