Compose仿写简易版小红书

2,242 阅读10分钟

介绍

使用compose仿写的简单版小红书APP,使用的技术包括VersionCatalog、Navigation、Paging3、自定义Layout、NestedScrollConnection、compose共享元素、EdgeToEdge、自定义主题

效果图

Screen_recording_20240823_144803.gifScreen_recording_20240823_144905.gifScreen_recording_20240823_145010.gif

版本管理

版本管理使用了VersionCatalog,可以参考官方项目nowinandroid文章一文章二

image.png

路由导航

使用Navigation进行导航,图例如下

image.png

这里在AppNavHost中有两个导航页,一个是main,一个是detailmain里面嵌套了一个MainNavHost导航。

之所以这样涉及是由于MainNavHost导航的大小不包括底部BottomBar,如果detail页面写在这个导航,就会导致打开详情页页面大小无法撑满屏幕,所以使用嵌套导航的结构

class MainActivity {
  setContent {
    AppNavHost()
  }
}

fun AppNavHost() {
  composable("main") {
    Scaffold(
        bottomBar = {
          BottomBar()
        }
    ) { paddingValues -> // 这里的原因导致MainNavHost的高度是去除了bottomBar
      Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
        MainNavHost()
      }
    }
  }
  composable("home_detail")
}

fun MainNavHost() {
  composable("home")
  composable("shopping")
  composable("message")
  composable("me")
}

首页结构

image.png

一、自定义ScrollableTabRow

TabRow1+TabRow2我们仿写官方的ScrollableTabRow自定义一个RedBookTabRow,之所以自定义是因为官方的ScrollableTabRow有个默认的最小宽度90.dp,并且无法自定义,而横向滚动tabRow我们希望tab的宽度是根据每个tab内容宽度自适应的。 image.png image.png 修改如下 image.png

二、HorizontalPage左右滚动优先级

TabRow1TabRow2都显示的时候,左右切换优先处理内部的HorizontalPager2,当TabRow2隐藏的时候,希望优先处理外部的HorizontalPager1

// 使用userScrollEnabled简单处理即可,隐藏直接userScrollEnabled=false
HorizontalPager(
    state = pagerState,
    modifier = Modifier.fillMaxSize(),
    userScrollEnabled = animateHeaderState.flag,
)

三、下拉刷新&上拉加载&Paging3数据渲染

下拉刷新我们自定义了一个控件CommonRefresh,配合Paging3,完成了列表内容的滚动加载。

下拉刷新控件

这里通过自定义Layout+NestedScrollConnection实现

具体思路可以参考这篇文章中的下拉刷新,我们基于这个修改一下

点击查看完整代码

@Composable
fun CommonRefresh(
    modifier: Modifier = Modifier,
    state: CommonRefreshState,
    onRefresh: (suspend () -> Unit)? = null,
    headerIndicator: (@Composable () -> Unit)? = {
        CommonRefreshHeader(
            modifier = Modifier
                .padding(16.dp)
                .fillMaxWidth()
                .height(28.dp),
            state = state
        )
    },
    content: @Composable () -> Unit
) {
    LaunchedEffect(state.isRefreshing) {
        if (state.isRefreshing) {
            state.animateIsOver = false
            onRefresh?.invoke()
        } else {
            state.animateOffsetTo(0f)
        }
    }

    Layout(
        modifier = modifier.nestedScroll(state.connection),
        content = {
            content()
            headerIndicator?.invoke()
        }
    ) { measurables, constraints ->
        val contentPlaceable = measurables[0].measure(constraints)
        val headerPlaceable = measurables.getOrNull(1)?.measure(constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity))
        state.headerHeight = headerPlaceable?.height?.toFloat() ?: 0f
        layout(constraints.maxWidth, constraints.maxHeight) {
            contentPlaceable.placeRelative(0, state.indicatorOffset.roundToInt())
            headerPlaceable?.placeRelative(0, -headerPlaceable.height + state.indicatorOffset.roundToInt())
        }
    }
}

@Composable
fun rememberCommonRefreshState(
    coroutineScope: CoroutineScope = rememberCoroutineScope()
): CommonRefreshState {
    return remember {
        CommonRefreshState(coroutineScope)
    }
}

@Stable
class CommonRefreshState(private val coroutineScope: CoroutineScope) {
    var headerHeight = 0f
    var enableRefresh = true
    var isRefreshing by mutableStateOf(false)
    var animateIsOver by mutableStateOf(true)
    private val _indicatorOffset = Animatable(0f)
    private val mutatorMutex = MutatorMutex()

    val indicatorOffset: Float
        get() = _indicatorOffset.value

    fun isLoading() = !animateIsOver || isRefreshing

    suspend fun animateOffsetTo(offset: Float, durationMillis: Int = AnimationConstants.DefaultDurationMillis) {
        mutatorMutex.mutate {
            _indicatorOffset.animateTo(offset, animationSpec = tween(durationMillis)) {
                if (this.value == 0f) {
                    animateIsOver = true
                }
            }
        }
    }

    suspend fun snapOffsetTo(offset: Float) {
        mutatorMutex.mutate(MutatePriority.UserInput) {
            _indicatorOffset.snapTo(offset)
        }
    }

    private fun consumed(needConsumedY: Float) {
        if (needConsumedY == 0f) return
        coroutineScope.launch {
            snapOffsetTo(indicatorOffset + needConsumedY)
        }
    }


    internal val connection = object : NestedScrollConnection {

        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            return when {
                enableRefresh && available.y < 0 -> {
                    val canConsumed = (available.y * 0.5f).coerceAtLeast(0 - indicatorOffset)
                    consumed(canConsumed)
                    available.copy(x = 0f, y = canConsumed / 0.5f)
                }

                else -> Offset.Zero
            }
        }

        override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
            return when {
                enableRefresh && available.y > 0 -> {
                    val canConsumed = available.y * 0.5f
                    consumed(canConsumed)

                    if (source == NestedScrollSource.Fling && indicatorOffset > headerHeight) {
                        throw CancellationException()
                    }

                    available.copy(x = 0f, y = canConsumed / 0.5f)
                }

                else -> Offset.Zero
            }
        }

        override suspend fun onPreFling(available: Velocity): Velocity {
            if (indicatorOffset >= headerHeight) {
                if (!isLoading()) {
                    isRefreshing = true
                    animateOffsetTo(headerHeight)
                    return available
                }
            }

            return super.onPreFling(available)
        }

        override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
            if (indicatorOffset > 0) {
                if (isRefreshing && indicatorOffset > headerHeight) {
                    animateOffsetTo(headerHeight)
                } else if (!isRefreshing) {
                    animateOffsetTo(0f)
                }
                return available
            }

            return super.onPostFling(consumed, available)
        }

    }

}

@Composable
fun CommonRefreshHeader(modifier: Modifier = Modifier, state: CommonRefreshState) {
    if (state.isRefreshing) {
        CircleLoading(modifier = modifier)
    } else {
        val progress = (state.indicatorOffset.coerceAtLeast(0f) / state.headerHeight.coerceAtLeast(1f)).coerceAtMost(1f)
        val rotate = 0f
        Box(
            modifier = modifier,
            contentAlignment = Alignment.Center
        ) {
            CircleProgress(
                modifier = Modifier
                    .fillMaxSize()
                    .rotate(rotate),
                progress = progress
            )
        }
    }
}

注意点:我们希望快速下拉的时候如果header不要一直往下滚动,,也不是僵硬的到某个高度直接折叠,而是header已经完全展示后就马上折叠起来

onPostScroll() {
    if (source == NestedScrollSource.Fling && indicatorOffset > headerHeight) {
        throw CancellationException()
    }
}

Screen_recording_20240823_184043 2.gif

Paging3使用步骤

  1. 定义PagingSource
class CommonPagingSource(
    private val pictures: List<Int>,
    private val mockUsers: List<UserBean>,
    private val isVideo: Boolean = false,
    private val mockPages: Int = 3,
) : PagingSource<Int, ContentBean>() {
    override fun getRefreshKey(state: PagingState<Int, ContentBean>): Int? {
        return state.anchorPosition?.let { state.closestPageToPosition(it)?.prevKey }
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ContentBean> {
        return try {
            val page = params.key ?: 1
            val data = requestData(page, params.loadSize) ?: emptyList()
            LoadResult.Page(
                data = data,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (data.isEmpty()) null else page + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    private suspend fun requestData(page: Int, pageSize: Int): List<ContentBean>? {
        if (page > mockPages) {
            return null
        } else {
            val list = mutableListOf<ContentBean>()
            pictures.forEachIndexed { index, pic ->
                val user = mockUsers[index]
                list.add(
                    ContentBean(
                        id = Random.nextLong(),
                        pic = pic,
                        title = if (isVideo) "视频:${user.userName}" else "我是${user.userName}",
                        user = user,
                        likeNum = Random.nextInt(0, 20000),
                        isVideo = if (isVideo) true else Random.nextBoolean()
                    )
                )
            }
            delay(2000)
            Log.v("Loren", "加载数据page=$page size=$pageSize")
            return list.shuffled().takeLast(pageSize)
        }
    }
}
  1. 创建PagingData流
class CommonContentRepository @Inject constructor(
    @Dispatcher(MyDispatchers.IO) private val ioDispatcher: CoroutineDispatcher,
    @MockDataModule.MockContentPic private val pictures: List<Int>,
    private val mockUsers: List<UserBean>
) {

    val commonPagingFlow = Pager(
        config = PagingConfig(
            pageSize = 20, // 每次加载20条
            initialLoadSize = 20, // 首次默认加载20条
            prefetchDistance = 4 // 滚动到离当前显示项还有4条时开始预取数据
        ),
        pagingSourceFactory = { CommonPagingSource(pictures, mockUsers, isVideo = false, mockPages = 3) }
    ).flow.flowOn(ioDispatcher)

}

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val commonContentRepository: CommonContentRepository
) : ViewModel() {
    val commonPagingFlow = commonContentRepository.commonPagingFlow.cachedIn(viewModelScope)
}
  1. 根据数据流展示UI
// 首次加载展示loading
if (lazyPagingItems.loadState.refresh is LoadState.Loading && lazyPagingItems.itemCount == 0) {
    CircleLoading(
        modifier = Modifier.fillMaxSize()
    )
}
// 加载更多时展示loading
pagingItems.loadState.append is LoadState.Loading -> {
    item(span = StaggeredGridItemSpan.FullLine) {
        CircleLoading()
    }
}
// 没有更多数据了
pagingItems.loadState.append == LoadState.NotLoading(true) -> {
    // 没有数据了
    item(span = StaggeredGridItemSpan.FullLine) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .wrapContentHeight()
                .padding(8.dp),
            contentAlignment = Alignment.Center
        ) {
            Text(text = "没有数据了", style = RedBookTheme.textStyle.bodySmall, color = RedBookTheme.colors.body)
        }
    }
}
  1. 下拉刷新触发paging的refresh
val homePagingItems = homeViewModel.commonPagingFlow.collectAsLazyPagingItems()

LaunchedEffect(homePagingItems.loadState.refresh) {
    when {
        homePagingItems.loadState.refresh is LoadState.NotLoading -> {
            refreshState.isRefreshing = false
        }

        homePagingItems.loadState.refresh is LoadState.Error -> {
            refreshState.isRefreshing = false
        }
    }
}

CommonRefresh(
    modifier = Modifier.fillMaxSize(),
    state = refreshState,
    onRefresh = {
        homePagingItems.refresh()
    }
) {}

四、自定义CircleProgress

image.png Screen_recording_20240823_201620.gif

@Composable
fun CircleProgress(
    modifier: Modifier = Modifier,
    color: Color = Color.LightGray,
    progress: Float = 1f
) {
    Canvas(modifier = modifier.drawWithCache {
        val ringStrokeWidth = 3.dp.toPx()
        val radius = min(size.width, size.height) / 2 - ringStrokeWidth
        val topTopLeft = Offset(size.width / 2 - radius, size.height / 2 - radius)
        // 两个半圆弧形中间有一点间隔 取一个圆弧140度 左右各减少20度
        onDrawWithContent {
            val sweepAngle = 140f * progress
            // 绘制上半圆
            drawArc(
                color = color,
                startAngle = -160f,
                sweepAngle = sweepAngle,
                useCenter = false,
                topLeft = topTopLeft,
                size = Size(radius * 2, radius * 2),
                style = Stroke(width = ringStrokeWidth, cap = StrokeCap.Round)
            )
            // 绘制下半圆
            drawArc(
                color = color,
                startAngle = 20f,
                sweepAngle = sweepAngle,
                useCenter = false,
                topLeft = topTopLeft,
                size = Size(radius * 2, radius * 2),
                style = Stroke(width = ringStrokeWidth, cap = StrokeCap.Round)
            )
        }
    }) {}
}

我的页面结构

// 大致布局 实际通过自定义Layout布局
Drawer {
    Box {
        RefreshContent()
        TopBar()
    }
}

RefreshContent() {
    Column {
        Box {
            BackgroundImg()
            Column {
                MeTabContent()
                MeFunctionBar()
            }
        }
        MeViewPager()
    }
}

页面的协调滚动分析

向上滚动向下滚动非列表区域的滚动
up.gifdown.gifddd.gif
TopBar透明度改变 0->1
MeTabContent折叠,折叠高度=整个顶部内容高度-TopBar高度-FunctionBar高度
MeFunctionBar吸顶
TopBar透明度改变 1->0
MeViewPager内部的滚动布局滚动到顶部时MeTabContent展开
可展开高度=整个顶部内容高度-TopBar高度-FunctionBar高度,到顶时触发刷新
MeFunctionBar吸顶
手指在顶部区域也能拖拽,下拉刷新,上拉最多只能折叠MeTabContent

通过上面的三种情况分析,首先我们需要分别测量出TopBarMeTabContentMeFunctionBar对应的高度用于滚动计算。

创建一个类管理协调滚动、记录控件高度以及记录偏移量

// 部分代码片段
class NestedScrollMeState {
    var topBarHeight = 0
    var contentBarHeight = 0
    var functionBarHeight = 0
    private val _offset = Animatable(0f)
    val offset: Float // 布局滚动的偏移量
        get() = _offset.value
}

@Composable
fun rememberNestedScrollMeState() = remember {
    NestedScrollMeState()
}

稍微布局一下

// 部分代码片段
val state = rememberNestedScrollMeState()
SubcomposeLayout(
    modifier = Modifier.fillMaxSize()
) { constraints ->
    val topBar = subcompose("topBar") {
        MeTopBar()
    }.first().measure(constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity))
    state.topBarHeight = topBar.height // 记录高度
    val refreshContent = subcompose("refreshContent") {
        RefreshContent()
    }.first().measure(constraints.copy(minHeight = 0, maxHeight = constraints.maxHeight))
    layout(constraints.maxWidth, constraints.maxHeight) {
        refreshContent.placeRelative(0, 0)
        topBar.placeRelative(0, 0)
    }
}
SubcomposeLayout(
    modifier = Modifier.fillMaxSize()
) { constraints ->
    val functionBar = subcompose("functionBar") {
        MeFunctionBar()
    }.first().measure(constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity))
    state.functionBarHeight = functionBar.height // 记录高度

    val contentBar = subcompose("contentBar") {
        MeTabContent()
    }.first().measure(constraints.copy(minHeight = 0, maxHeight = Constraints.Infinity))
    state.contentBarHeight = contentBar.height // 记录高度

    val backgroundImage = subcompose("backgroundImage") {
        AsyncImage(
            modifier = Modifier
                .fillMaxSize(),
            model = R.drawable.pic_vitality,
            contentDescription = "background",
            contentScale = ContentScale.Crop,
        )
    }.first().measure(
        constraints.copy(
            minHeight = state.contentBarHeight, // 图片撑满顶部内容布局
            maxHeight = Constraints.Infinity
        )
    )

    val viewPager = subcompose("viewPager") {
        MeViewPager()
    }.first().measure(
        constraints.copy(
            minHeight = 0, 
            maxHeight = constraints.maxHeight - topBarHeight - functionBar.height // 底部内容区域=总高度-topBarHeight-functionBarHeight
        )
    ) 

    layout(constraints.maxWidth, constraints.maxHeight) {
        backgroundImage.placeRelative(0, 0)
        contentBar.placeRelative(0, 0)
        functionBar.placeRelative(0, contentBar.height - functionBar.height)
        viewPager.placeRelative(0, contentBar.height)
    }
}

这个时候我们的布局是不会根据偏移量而滚动的,然后我们需要让上面的布局根据滚动偏移量做文章

layout(constraints.maxWidth, constraints.maxHeight) {
    backgroundImage.placeRelative(0, state.offset.toInt().coerceAtMost(0))
    contentBar.placeRelative(0, state.offset.toInt())
    functionBar.placeRelative(0, contentBar.height - functionBar.height + state.offset.toInt())
    viewPager.placeRelative(0, contentBar.height + state.offset.toInt())
}

背景图随着拖动放大缩小,图片高度变化根据下拉刷新的偏移量+原本高度

val backgroundImage = subcompose("backgroundImage") {
    AsyncImage(
        modifier = Modifier
            .fillMaxSize()
            .graphicsLayer {
                translationY = -refreshState.indicatorOffset
            },
        model = R.drawable.pic_vitality,
        contentDescription = "background",
        contentScale = ContentScale.Crop,
    )
}.first().measure(
    // 图片高度根据下拉刷新的偏移量+原本高度
    constraints.copy(
        minHeight = (state.contentBarHeight + refreshState.indicatorOffset.coerceAtLeast(0f)).toInt(),
        maxHeight = Constraints.Infinity
    )
)

一、MeTabContent前景渐变色

image.png

二、TopBar透明度变化及动画

topbar.gif

滚动到头像底部的时候,TopBar的透明度变为1,使用上面生成的那个随机颜色,并且做头像小动画。透明度变化fraction=state.offset/滚动到头像底部的距离backgroundColor=randomColor.copy(alpha = fraction)

三、嵌套滑动处理

  • 手指向上滑动的情况,此时available.y<0,如果functionBar没有滚动到顶部,则预先劫持滑动事件,消费后再交由子布局,所以在onPreScroll中进行消费,functionBar吸顶后则不需要再消费
  • 手指向下滑动的情况,此时available.y>0,在onPostScroll中进行消费,这里有可消费值代表底部数据已经滚到顶了,剩余的y事件交给这里处理,最多只能消费到offset为0的情况,再剩下没消费的y值传递给父onPostScrol,即下拉刷新控件
internal val connection = object : NestedScrollConnection {

    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        val needConsumedY = when {
            available.y < 0 && offset > -(contentBarHeight - topBarHeight - functionBarHeight) -> {
                // drag up
                available.y.coerceAtLeast(-(contentBarHeight - topBarHeight - functionBarHeight) - offset)
            }

            else -> 0f
        }
        consumed(needConsumedY)
        return available.copy(x = 0f, y = needConsumedY)
    }

    override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
        val needConsumedY = when {
            available.y > 0 && offset < 0 -> {
                // drag down
                available.y.coerceAtMost(-offset)
            }

            else -> 0f
        }
        consumed(needConsumedY)
        return available.copy(x = 0f, y = needConsumedY)
    }
}

private fun consumed(needConsumedY: Float) {
    if (needConsumedY == 0f) return
    coroutineScope.launch {
        // 调整offset
        snapOffsetTo(offset + needConsumedY)
    }
}

踩坑点

一开始我的HorizontalPager版本是1.6.3,然后我把这个connection设置到最外层,发现当我快速下滑时,无法展开上面的内容 Screen_recording_20240824_222427.gif

查看源码可知,HorizontalPager有一个DefaultPagerNestedScrollConnection
消费顺序:父onPreScroll->子onPreScroll->子onPostScroll->父onPostScroll
所以当我把我的connection设置到最外层,即HorizontalPager是子控件时,会优先响应HorizontalPageronPostScroll,当我手指快速滑动时throw CancellationException()导致无法展开上面被折叠的内容,所以最后我把我的connection设置到我笔记页面的LazyVerticalStaggeredGrid上。 image.png

The bug on JetBrains/compose-multiplatform#4395

Fix on Fix nested scroll when Pager involved in scrolling process

PS:后续我升级物料清单Compose 1.6.8解决了这个问题

image.png

四、TabContent区域的手势滚动

实现方式查看官方文档 可滚动的修饰符

通过 scrollable 修饰符与滚动修饰符不同,scrollable 会检测滚动手势并捕获增量,但不会偏移其内容。系统会通过 ScrollableState,此修饰符才能正常运行。

构建ScrollableState时,您必须提供consumeScrollDelta函数,该函数将在每个滚动步骤中调用(通过手势输入,平滑)滚动或快速滑动)。该函数必须返回滚动距离所消耗的量,以确保事件在存在具有scrollable的嵌套元素时传播修饰符。

我们构建一个ScrollState,返回的needConsumedY就是滚动所需要消耗的量,向下滚动时,可以消耗任意Y值,向上滚动时,我们希望吸顶后就不继续消耗Y值了。

val scrollState = ScrollableState {
    val needConsumedY = when {
        it > 0 && offset < 0 -> {
            // drag down
            it
        }

        it < 0 && offset > -(contentBarHeight - topBarHeight - functionBarHeight) -> {
            // drag up
            it.coerceAtLeast(-(contentBarHeight - topBarHeight - functionBarHeight) - offset)
        }

        else -> 0f
    }
    consumed(needConsumedY)
    needConsumedY
}

// 滚动值记录到offset
private fun consumed(needConsumedY: Float) {
    if (needConsumedY == 0f) return
    coroutineScope.launch {
        snapOffsetTo(offset + needConsumedY)
    }
}

fun MeTabContent() {
    Box(
        modifier = Modifier
            .scrollable(
                state = state.scrollState,
                orientation = Orientation.Vertical
            )
    )
}

五、记录我的页面的滚动位置以及背景渐变色

切换bottomBar发现我的页面的滚动位置以及颜色状态都丢失了

没有Saver有Saver
no_saver.gifsaver.gif

使用rememberSaverable,由于Color不是可序列化的数据结构,通过自定义Saver实现

val randomColor = rememberSaveable(state, saver = ColorSaver) {
    Color(Random.nextInt(0, 100), Random.nextInt(0, 100), Random.nextInt(0, 100), 255)
}

val ColorSaver = Saver<Color, Long>(
    save = { it.value.toLong() },
    restore = { Color(it.toULong()) }
)

@Composable
fun rememberNestedScrollMeState(coroutineScope: CoroutineScope) = rememberSaveable(saver = NestedScrollMeStateSaver(coroutineScope)) {
    NestedScrollMeState(coroutineScope)
}
class NestedScrollMeState(private val coroutineScope: CoroutineScope, offsetSave: Float = 0f) {
    private val _offset = Animatable(offsetSave)
}
/**
 * 保存滚动进度
 */
class NestedScrollMeStateSaver(private val coroutineScope: CoroutineScope) : Saver<NestedScrollMeState, Float> {
    override fun restore(value: Float): NestedScrollMeState {
        return NestedScrollMeState(coroutineScope, value)
    }

    override fun SaverScope.save(value: NestedScrollMeState): Float {
        return value.offset
    }
}

共享元素

官方文档 导航间的共享元素动画

SharedTransitionLayout包裹NavHost,用CompostitionLocalProviderSharedTransitionScope以及AnimatedVisibilityScope向下传递

SharedTransitionLayout {
    CompositionLocalProvider(
        LocalNavHostSharedTransitionScope provides this
    ) {
        AppNavHost()
    }
}

fun NavGraphBuilder.composableWithCompositionLocal(
    route: String,
    arguments: List<NamedNavArgument> = emptyList(),
    content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit
) {
    composable(route = route, arguments = arguments) {
        CompositionLocalProvider(
            LocalAnimatedVisibilityScope provides this@composable
        ) {
            content(it)
        }
    }
}

主题&状态栏颜色

@Composable
fun RedBookTheme(themeType: AppThemeType = AppThemeType.Light, content: @Composable () -> Unit) {
    val colors =
        if (AppThemeType.isDark(themeType = themeType)) darkLorenColors else lightLorenColors

    CompositionLocalProvider(
        LocalCustomColors provides colors,
        LocalTextStyles provides RedBookTheme.textStyle
    ) {
        MaterialTheme(content = content)
    }
}

object RedBookTheme {
    val colors: LorenColors
        @Composable
        get() = LocalCustomColors.current
    val textStyle: LorenTextStyle
        @Composable
        get() = LocalTextStyles.current
}

我的页面状态栏图标颜色亮色,其他页面状态栏图标是暗色,使用EdgeToEdge控制

// statusBar图标颜色模式
// isDark=true,状态栏为浅色图标
val isDark = if (appState.iconIsLight && appState.currentDestination?.route == MAIN_ROUTE) true else AppThemeType.isDark(
    themeType = lorenTheme
)

DisposableEffect(lorenTheme, isDark) {
    enableEdgeToEdge(
        SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT) { isDark },
        SystemBarStyle.auto(Color.WHITE, Color.BLACK) { isDark },
    )
    onDispose { }
}

项目完整代码地址

🎉 完整项目代码地址