diff --git a/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/refreshLayout/PullToRefresh.kt b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/refreshLayout/PullToRefresh.kt new file mode 100644 index 00000000..c174d01d --- /dev/null +++ b/coreapp/src/androidMain/kotlin/com/metacto/core/presentation/components/refreshLayout/PullToRefresh.kt @@ -0,0 +1,56 @@ +package com.metacto.core.presentation.components.refreshLayout + +import androidx.compose.foundation.layout.Box +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.metacto.core.presentation.theme.CoreTheme +import com.metacto.core.utils.extensions.pullRefreshIf +import com.metacto.core.utils.extensions.rememberPullRefreshStateIf + +@OptIn(ExperimentalMaterialApi::class) +@Composable +actual fun PullToRefresh( + modifier: Modifier, + isRefreshable: Boolean, + isRefreshing: Boolean, + onRefresh: (() -> Unit)?, + content: @Composable () -> Unit +) { + + val refreshIndicatorColor = CoreTheme.colors.pullRefreshIndicator + val refreshIndicatorBgColor = CoreTheme.colors.pullRefreshIndicatorBackground + + // Prepare refresh state + val pullRefreshState = rememberPullRefreshStateIf( + condition = isRefreshable, + refreshing = isRefreshing, + onRefresh = { + onRefresh?.invoke() + } + ) + + // Container box + Box( + modifier = modifier.pullRefreshIf( + condition = isRefreshable, + state = pullRefreshState + ) + ) { + content + + // Render pull refresh indicator + if (pullRefreshState != null) { + PullRefreshIndicator( + modifier = Modifier.align(Alignment.TopCenter), + refreshing = isRefreshing, + state = pullRefreshState, + contentColor = refreshIndicatorColor, + backgroundColor = refreshIndicatorBgColor + ) + } + } + +} \ No newline at end of file diff --git a/coreapp/src/commonMain/kotlin/com/metacto/core/presentation/components/containers/CoreColumn.kt b/coreapp/src/commonMain/kotlin/com/metacto/core/presentation/components/containers/CoreColumn.kt index 3ec97a26..1e0c75f7 100644 --- a/coreapp/src/commonMain/kotlin/com/metacto/core/presentation/components/containers/CoreColumn.kt +++ b/coreapp/src/commonMain/kotlin/com/metacto/core/presentation/components/containers/CoreColumn.kt @@ -2,24 +2,17 @@ package com.metacto.core.presentation.components.containers import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import com.metacto.core.presentation.theme.CoreTheme +import com.metacto.core.presentation.components.refreshLayout.PullToRefresh import com.metacto.core.utils.extensions.onScrolling -import com.metacto.core.utils.extensions.pullRefreshIf -import com.metacto.core.utils.extensions.rememberPullRefreshStateIf import com.metacto.core.utils.extensions.rememberScrollStateIf import com.metacto.core.utils.extensions.verticalScrollIf -@OptIn(ExperimentalMaterialApi::class) @Composable fun CoreColumn( modifier: Modifier = Modifier, @@ -31,8 +24,6 @@ fun CoreColumn( isRefreshable: Boolean = false, isRefreshing: Boolean = false, onRefresh: (() -> Unit)? = null, - refreshIndicatorColor: Color = CoreTheme.colors.pullRefreshIndicator, - refreshIndicatorBgColor: Color = CoreTheme.colors.pullRefreshIndicatorBackground, content: @Composable ColumnScope.() -> Unit ) { // Config scroll state @@ -41,25 +32,8 @@ fun CoreColumn( onScrolled?.invoke() } } - - // Prepare refresh state - val pullRefreshState = rememberPullRefreshStateIf( - condition = isRefreshable, - refreshing = isRefreshing, - onRefresh = { - onRefresh?.invoke() - } - ) - - // Container box - Box( - modifier = modifier.pullRefreshIf( - condition = isRefreshable, - state = pullRefreshState - ) - ) { - // Render content column - Column( + // Render content column + val contentColumn = Column( verticalArrangement = verticalArrangement, horizontalAlignment = horizontalAlignment, content = content, @@ -68,15 +42,11 @@ fun CoreColumn( .verticalScrollIf(isScrollable, scrollState) ) - // Render pull refresh indicator - if (pullRefreshState != null) { - PullRefreshIndicator( - modifier = Modifier.align(Alignment.TopCenter), - refreshing = isRefreshing, - state = pullRefreshState, - contentColor = refreshIndicatorColor, - backgroundColor = refreshIndicatorBgColor - ) - } - } + PullToRefresh( + isRefreshable = isRefreshable, + isRefreshing = isRefreshing, + onRefresh = onRefresh, + content = { contentColumn }, + modifier = modifier + ) } \ No newline at end of file diff --git a/coreapp/src/commonMain/kotlin/com/metacto/core/presentation/components/containers/ScreenColumn.kt b/coreapp/src/commonMain/kotlin/com/metacto/core/presentation/components/containers/ScreenColumn.kt index 79a36e6c..9f8648a5 100644 --- a/coreapp/src/commonMain/kotlin/com/metacto/core/presentation/components/containers/ScreenColumn.kt +++ b/coreapp/src/commonMain/kotlin/com/metacto/core/presentation/components/containers/ScreenColumn.kt @@ -70,8 +70,8 @@ fun ScreenColumn( isRefreshable = isRefreshable, isRefreshing = isRefreshing, onRefresh = onRefresh, - refreshIndicatorColor = refreshIndicatorColor, - refreshIndicatorBgColor = refreshIndicatorBgColor, +// refreshIndicatorColor = refreshIndicatorColor, +// refreshIndicatorBgColor = refreshIndicatorBgColor, modifier = Modifier .weight(1f) .fillMaxWidth() diff --git a/coreapp/src/commonMain/kotlin/com/metacto/core/presentation/components/refreshLayout/PullToRefresh.kt b/coreapp/src/commonMain/kotlin/com/metacto/core/presentation/components/refreshLayout/PullToRefresh.kt new file mode 100644 index 00000000..8056a070 --- /dev/null +++ b/coreapp/src/commonMain/kotlin/com/metacto/core/presentation/components/refreshLayout/PullToRefresh.kt @@ -0,0 +1,13 @@ +package com.metacto.core.presentation.components.refreshLayout + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +expect fun PullToRefresh( + modifier: Modifier, + isRefreshable: Boolean, + isRefreshing: Boolean, + onRefresh: (() -> Unit)? = null, + content: @Composable () -> Unit +) \ No newline at end of file diff --git a/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/PullToRefresh.kt b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/PullToRefresh.kt new file mode 100644 index 00000000..4e39609a --- /dev/null +++ b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/PullToRefresh.kt @@ -0,0 +1,35 @@ +package com.metacto.core.presentation.components.refreshLayout + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +actual fun PullToRefresh( + modifier: Modifier, + isRefreshable: Boolean, + isRefreshing: Boolean, + onRefresh: (() -> Unit)?, + content: @Composable () -> Unit +) { + val refreshContent: @Composable RefreshLayoutState.() -> Unit = remember { + { PullToRefreshContent() } + } + + RefreshLayout( + refreshContent = refreshContent, + refreshLayoutState = createState(), + modifier = modifier, + content = content + ) +} + +@Composable +private fun createState() = rememberRefreshLayoutState { + coroutineScope.launch { + delay(2000) + setRefreshState(RefreshContentStateEnum.Stop) + } +} \ No newline at end of file diff --git a/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/PullToRefreshContent.kt b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/PullToRefreshContent.kt new file mode 100644 index 00000000..d207c54f --- /dev/null +++ b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/PullToRefreshContent.kt @@ -0,0 +1,109 @@ +package com.metacto.core.presentation.components.refreshLayout + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.metacto.core.presentation.theme.CoreTheme +import com.metacto.coreApp.resources.Res +import com.metacto.coreApp.resources.ic_play +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.painterResource +import kotlin.math.abs + +/** + * creator: lt 2022/9/18 lt.dygzs@qq.com + * effect : 下拉刷新的刷新组件 + * Refresh component for pull down refresh + * warning: + */ +@OptIn(ExperimentalResourceApi::class) +@Composable +fun RefreshLayoutState.PullToRefreshContent() { + val refreshContentState by remember { + getRefreshContentState() + } + Row( + Modifier + .fillMaxWidth() + .height(35.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center + ) { + when (refreshContentState) { + RefreshContentStateEnum.Stop -> { + //no image + } + + RefreshContentStateEnum.Refreshing -> { + //循环旋转动画 + val infiniteTransition = rememberInfiniteTransition() + val rotate by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ) + ) + Image( + painter = painterResource(Res.drawable.ic_play), + contentDescription = "", + modifier = Modifier + .size(20.dp) + .rotate(rotate) + ) + Spacer(Modifier.width(10.dp)) + } + + RefreshContentStateEnum.Dragging -> { + //旋转动画 + val isCannotRefresh = + abs(getRefreshContentOffset()) < getRefreshContentThreshold() + val rotate by animateFloatAsState(targetValue = if (isCannotRefresh) 0f else 180f) + Image( + painter = painterResource(Res.drawable.ic_play), + contentDescription = "", + modifier = Modifier + .size(20.dp) + .rotate(rotate) + ) + Spacer(Modifier.width(10.dp)) + } + } + Text( + text = when (refreshContentState) { + RefreshContentStateEnum.Stop -> "Refresh Complete" + RefreshContentStateEnum.Refreshing -> "Refreshing" + RefreshContentStateEnum.Dragging -> { + if (abs(getRefreshContentOffset()) < getRefreshContentThreshold()) { + "Pull to refresh" + } else { + "Release refresh" + } + } + }, + fontSize = 14.sp, + color = CoreTheme.colors.red, + ) + } +} \ No newline at end of file diff --git a/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/RefreshContentStateEnum.kt b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/RefreshContentStateEnum.kt new file mode 100644 index 00000000..9c7d2e32 --- /dev/null +++ b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/RefreshContentStateEnum.kt @@ -0,0 +1,23 @@ +/* + * Copyright lt 2023 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.metacto.core.presentation.components.refreshLayout + +enum class RefreshContentStateEnum { + Stop, + Refreshing, + Dragging +} \ No newline at end of file diff --git a/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/RefreshLayout.kt b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/RefreshLayout.kt new file mode 100644 index 00000000..adceec8c --- /dev/null +++ b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/RefreshLayout.kt @@ -0,0 +1,141 @@ +package com.metacto.core.presentation.components.refreshLayout + +import androidx.compose.foundation.clipScrollableContainer +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import com.metacto.core.presentation.components.refreshLayout.util.ComposePosition +import com.metacto.core.presentation.components.refreshLayout.util.ComposePosition.Bottom +import com.metacto.core.presentation.components.refreshLayout.util.ComposePosition.End +import com.metacto.core.presentation.components.refreshLayout.util.ComposePosition.Start +import com.metacto.core.presentation.components.refreshLayout.util.ComposePosition.Top +import com.metacto.core.presentation.components.refreshLayout.util.runIf +import kotlin.math.roundToInt + +@Composable +fun RefreshLayout( + refreshContent: @Composable RefreshLayoutState.() -> Unit, + refreshLayoutState: RefreshLayoutState, + modifier: Modifier = Modifier, + refreshContentThreshold: Dp? = null, + composePosition: ComposePosition = Top, + contentIsMove: Boolean = true, + dragEfficiency: Float = 0.5f, + isSupportCanNotScrollCompose: Boolean = false, + userEnable: Boolean = true, + refreshingCanScroll: Boolean = false, + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + val coroutineScope = rememberCoroutineScope() + //更新状态 + val orientationIsHorizontal = remember( + refreshLayoutState, + composePosition, + refreshContentThreshold, + coroutineScope, + density, + ) { + refreshLayoutState.composePositionState.value = composePosition + refreshLayoutState.coroutineScope = coroutineScope + if (refreshContentThreshold != null) + refreshLayoutState.refreshContentThresholdState.value = + with(density) { refreshContentThreshold.toPx() } + composePosition.isHorizontal() + } + val nestedScrollState = rememberRefreshLayoutNestedScrollConnection( + composePosition, + refreshLayoutState, + dragEfficiency, + orientationIsHorizontal, + refreshingCanScroll + ) + + Layout( + content = { + if (isSupportCanNotScrollCompose) { + Box( + if (orientationIsHorizontal) + Modifier.horizontalScroll(rememberScrollState()) + else + Modifier.verticalScroll(rememberScrollState()) + ) { + content() + } + } else { + content() + } + refreshLayoutState.refreshContent() + }, + modifier = modifier + .runIf(userEnable) { + nestedScroll(nestedScrollState) + } + .clipScrollableContainer(composePosition.orientation) + ) { measurableList, constraints -> + val contentPlaceable = + measurableList[0].measure(constraints.copy(minWidth = 0, minHeight = 0)) + //宽或高不能超过content(根据方向来定) + val refreshContentPlaceable = measurableList[1].measure( + Constraints( + maxWidth = if (orientationIsHorizontal) Constraints.Infinity else contentPlaceable.width, + maxHeight = if (orientationIsHorizontal) contentPlaceable.height else Constraints.Infinity, + ) + ) + if (refreshContentThreshold == null && refreshLayoutState.refreshContentThresholdState.value == 0f) { + refreshLayoutState.refreshContentThresholdState.value = + if (orientationIsHorizontal) { + refreshContentPlaceable.width.toFloat() + } else { + refreshContentPlaceable.height.toFloat() + } + } + + layout(contentPlaceable.width, contentPlaceable.height) { + val offset = refreshLayoutState.refreshContentOffsetState.value.roundToInt() + when (composePosition) { + Start -> { + contentPlaceable.placeRelative(if (contentIsMove) offset else 0, 0) + refreshContentPlaceable.placeRelative( + (-refreshContentPlaceable.width) + offset, + 0 + ) + } + + End -> { + contentPlaceable.placeRelative(if (contentIsMove) offset else 0, 0) + refreshContentPlaceable.placeRelative( + contentPlaceable.width + offset, + 0 + ) + } + + Top -> { + contentPlaceable.placeRelative(0, if (contentIsMove) offset else 0) + refreshContentPlaceable.placeRelative( + 0, + (-refreshContentPlaceable.height) + offset + ) + } + + Bottom -> { + contentPlaceable.placeRelative(0, if (contentIsMove) offset else 0) + refreshContentPlaceable.placeRelative( + 0, + contentPlaceable.height + offset + ) + } + } + } + } +} diff --git a/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/RefreshLayoutNestedScrollConnection.kt b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/RefreshLayoutNestedScrollConnection.kt new file mode 100644 index 00000000..c2bdf9aa --- /dev/null +++ b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/RefreshLayoutNestedScrollConnection.kt @@ -0,0 +1,140 @@ +package com.metacto.core.presentation.components.refreshLayout + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.unit.Velocity +import com.metacto.core.presentation.components.refreshLayout.util.ComposePosition + +internal class RefreshLayoutNestedScrollConnection( + private val composePosition: ComposePosition, + private val refreshLayoutState: RefreshLayoutState, + private val dragEfficiency: Float, + private val orientationIsHorizontal: Boolean, + private val refreshingCanScroll: Boolean = false, +) : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if (source == NestedScrollSource.Drag) { + when (composePosition) { + ComposePosition.Start -> { + val value = available.x + if (value > 0) { + if (value > 0.01f) + refreshLayoutState.offset(value * dragEfficiency) + return Offset(value, 0f) + } + } + ComposePosition.End -> { + val value = available.x + if (value < 0) { + if (value < -0.01f) + refreshLayoutState.offset(value * dragEfficiency) + return Offset(value, 0f) + } + } + ComposePosition.Top -> { + val value = available.y + if (value > 0) { + if (value > 0.01f) + refreshLayoutState.offset(value * dragEfficiency) + return Offset(0f, value) + } + } + ComposePosition.Bottom -> { + val value = available.y + if (value < 0) { + if (value < -0.01f) + refreshLayoutState.offset(value * dragEfficiency) + return Offset(0f, value) + } + } + } + } + return Offset.Zero + } + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (!refreshingCanScroll && refreshLayoutState.refreshContentState.value == RefreshContentStateEnum.Refreshing) { + return if (orientationIsHorizontal) + Offset(available.x, 0f) + else + Offset(0f, available.y) + } + val refreshOffset = refreshLayoutState.refreshContentOffsetState.value + if (source == NestedScrollSource.Drag) { + when (composePosition) { + ComposePosition.Start -> { + if (available.x < 0 && refreshOffset > 0) { + var consumptive = available.x + if (-available.x > refreshOffset) { + consumptive = available.x - refreshOffset + } + refreshLayoutState.offset(consumptive * dragEfficiency) + return Offset(consumptive, 0f) + } + } + ComposePosition.End -> { + if (available.x > 0 && refreshOffset < 0) { + var consumptive = available.x + if (-available.x > refreshOffset) { + consumptive = available.x - refreshOffset + } + refreshLayoutState.offset(consumptive * dragEfficiency) + return Offset(consumptive, 0f) + } + } + ComposePosition.Top -> { + if (available.y < 0 && refreshOffset > 0) { + var consumptive = available.y + if (-available.y > refreshOffset) { + consumptive = available.y - refreshOffset + } + refreshLayoutState.offset(consumptive * dragEfficiency) + return Offset(0f, consumptive) + } + } + ComposePosition.Bottom -> { + if (available.y > 0 && refreshOffset < 0) { + var consumptive = available.y + if (-available.y < refreshOffset) { + consumptive = available.y - refreshOffset + } + refreshLayoutState.offset(consumptive * dragEfficiency) + return Offset(0f, consumptive) + } + } + } + } + return Offset.Zero + } + + override suspend fun onPreFling(available: Velocity): Velocity { + if (!refreshingCanScroll && refreshLayoutState.refreshContentState.value == RefreshContentStateEnum.Refreshing) { + return available + } + if (refreshLayoutState.refreshContentOffsetState.value != 0f) { + refreshLayoutState.offsetHoming() + return available + } + return Velocity.Zero + } +} + +@Composable +internal fun rememberRefreshLayoutNestedScrollConnection( + composePosition: ComposePosition, + refreshLayoutState: RefreshLayoutState, + dragEfficiency: Float, + orientationIsHorizontal: Boolean, + refreshingCanScroll: Boolean = false, +) = remember(composePosition) { + RefreshLayoutNestedScrollConnection( + composePosition, refreshLayoutState, dragEfficiency, orientationIsHorizontal, refreshingCanScroll + ) +} diff --git a/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/RefreshLayoutState.kt b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/RefreshLayoutState.kt new file mode 100644 index 00000000..d84cca22 --- /dev/null +++ b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/RefreshLayoutState.kt @@ -0,0 +1,118 @@ +package com.metacto.core.presentation.components.refreshLayout + +import androidx.compose.animation.core.Animatable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import com.metacto.core.presentation.components.refreshLayout.util.ComposePosition +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlin.math.abs + +@Stable +class RefreshLayoutState( + internal val onRefreshListener: RefreshLayoutState.() -> Unit +) { + internal val refreshContentState = mutableStateOf(RefreshContentStateEnum.Stop) + + internal val refreshContentOffsetState = Animatable(0f) + + internal val composePositionState = mutableStateOf(ComposePosition.Top) + + internal val refreshContentThresholdState = mutableStateOf(0f) + + internal lateinit var coroutineScope: CoroutineScope + + var canCallRefreshListener = true + + fun getRefreshContentState(): State = refreshContentState + + fun createRefreshContentOffsetFlow(): Flow = + snapshotFlow { refreshContentOffsetState.value } + + fun getComposePositionState(): State = composePositionState + + fun getRefreshContentThreshold(): Float = refreshContentThresholdState.value + + fun getRefreshContentOffset(): Float = refreshContentOffsetState.value + + fun setRefreshState(state: RefreshContentStateEnum) { + when (state) { + RefreshContentStateEnum.Stop -> { + if (refreshContentState.value == RefreshContentStateEnum.Stop) + return + if (!this::coroutineScope.isInitialized) + throw IllegalStateException("[RefreshLayoutState]还未初始化完成,请在[LaunchedEffect]中或composable至少组合一次后使用此方法") + coroutineScope.launch { + refreshContentState.value = RefreshContentStateEnum.Stop + delay(300) + refreshContentOffsetState.animateTo(0f) + } + } + + RefreshContentStateEnum.Refreshing -> { + if (refreshContentState.value == RefreshContentStateEnum.Refreshing) + return + if (!this::coroutineScope.isInitialized) + throw IllegalStateException("[RefreshLayoutState]还未初始化完成,请在[LaunchedEffect]中或composable至少组合一次后使用此方法") + coroutineScope.launch { + refreshContentState.value = RefreshContentStateEnum.Refreshing + if (canCallRefreshListener) + onRefreshListener() + else + setRefreshState(RefreshContentStateEnum.Stop) + animateToThreshold() + } + } + + RefreshContentStateEnum.Dragging -> throw IllegalStateException("设置为[RefreshContentStateEnum.Dragging]无意义") + } + } + + //偏移量归位,并检查是否超过了刷新阈值,如果超过了执行刷新逻辑 + internal fun offsetHoming() { + coroutineScope.launch { + //检查是否进入了刷新状态 + if (abs(refreshContentOffsetState.value) >= refreshContentThresholdState.value) { + refreshContentState.value = RefreshContentStateEnum.Refreshing + if (canCallRefreshListener) + onRefreshListener() + else + setRefreshState(RefreshContentStateEnum.Stop) + animateToThreshold() + } else { + refreshContentOffsetState.animateTo(0f) + refreshContentState.value = RefreshContentStateEnum.Stop + } + } + } + + //动画滑动至阈值处 + private suspend fun animateToThreshold() { + val composePosition = composePositionState.value + if (composePosition == ComposePosition.Start || composePosition == ComposePosition.Top) + refreshContentOffsetState.animateTo(refreshContentThresholdState.value) + else + refreshContentOffsetState.animateTo(-refreshContentThresholdState.value) + } + + //增加偏移量 + internal fun offset(refreshContentOffset: Float) { + coroutineScope.launch { + val targetValue = refreshContentOffsetState.value + refreshContentOffset + if (refreshContentState.value != RefreshContentStateEnum.Dragging && targetValue != 0f) { + refreshContentState.value = RefreshContentStateEnum.Dragging + } + refreshContentOffsetState.snapTo(targetValue) + } + } +} + +@Composable +fun rememberRefreshLayoutState(onRefreshListener: RefreshLayoutState.() -> Unit) = + remember { RefreshLayoutState(onRefreshListener) } \ No newline at end of file diff --git a/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/util/ComposePosition.kt b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/util/ComposePosition.kt new file mode 100644 index 00000000..42cf7069 --- /dev/null +++ b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/util/ComposePosition.kt @@ -0,0 +1,12 @@ +package com.metacto.core.presentation.components.refreshLayout.util + +import androidx.compose.foundation.gestures.Orientation + +enum class ComposePosition(val orientation: Orientation) { + Start(Orientation.Horizontal), + End(Orientation.Horizontal), + Top(Orientation.Vertical), + Bottom(Orientation.Vertical); + + fun isHorizontal(): Boolean = orientation == Orientation.Horizontal +} \ No newline at end of file diff --git a/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/util/Utils.kt b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/util/Utils.kt new file mode 100644 index 00000000..548e67e0 --- /dev/null +++ b/coreapp/src/iosMain/kotlin/com/metacto/core/presentation/components/refreshLayout/util/Utils.kt @@ -0,0 +1,13 @@ +package com.metacto.core.presentation.components.refreshLayout.util + +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +@OptIn(ExperimentalContracts::class) +internal inline fun T.runIf(useBlock: Boolean, block: T.() -> T): T { + contract { + callsInPlace(block, InvocationKind.AT_MOST_ONCE) + } + return if (useBlock) block(this) else this +} \ No newline at end of file diff --git a/sampleAppShared/src/commonMain/kotlin/com/sampleApp/app/presentation/home/components/HomeContent.kt b/sampleAppShared/src/commonMain/kotlin/com/sampleApp/app/presentation/home/components/HomeContent.kt index 4a470b29..2d1dc455 100644 --- a/sampleAppShared/src/commonMain/kotlin/com/sampleApp/app/presentation/home/components/HomeContent.kt +++ b/sampleAppShared/src/commonMain/kotlin/com/sampleApp/app/presentation/home/components/HomeContent.kt @@ -37,7 +37,9 @@ internal fun HomeContent( ScreenColumn( isScrollable = true, verticalArrangement = Arrangement.spacedBy(8.dp), - enableSafeInsets = true + enableSafeInsets = true, + isRefreshable = true, + isRefreshing = true ) { VideoPlayer( uniqueId = "home_video_player",