Skip to content

Commit f0b2082

Browse files
committed
library: PullToRefresh: Fix state loss issue
* THX @sd086
1 parent 84cd6a2 commit f0b2082

File tree

2 files changed

+158
-57
lines changed

2 files changed

+158
-57
lines changed

example/src/commonMain/kotlin/SecondPage.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import top.yukonga.miuix.kmp.basic.ScrollBehavior
2121
import top.yukonga.miuix.kmp.basic.rememberPullToRefreshState
2222
import top.yukonga.miuix.kmp.extra.SuperDropdown
2323
import top.yukonga.miuix.kmp.utils.getWindowSize
24+
import top.yukonga.miuix.kmp.utils.overScrollVertical
2425
import top.yukonga.miuix.kmp.utils.scrollEndHaptic
2526

2627
@Composable
@@ -50,9 +51,8 @@ fun SecondPage(
5051
LazyColumn(
5152
modifier = Modifier
5253
.height(getWindowSize().height.dp)
53-
.then(
54-
if (scrollEndHaptic) Modifier.scrollEndHaptic() else Modifier
55-
),
54+
.overScrollVertical()
55+
.then(if (scrollEndHaptic) Modifier.scrollEndHaptic() else Modifier),
5656
contentPadding = PaddingValues(top = padding.calculateTopPadding() + 12.dp),
5757
overscrollEffect = null
5858
) {

miuix/src/commonMain/kotlin/top/yukonga/miuix/kmp/basic/PullToRefresh.kt

Lines changed: 155 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ import androidx.compose.runtime.mutableStateOf
3535
import androidx.compose.runtime.remember
3636
import androidx.compose.runtime.rememberCoroutineScope
3737
import androidx.compose.runtime.rememberUpdatedState
38+
import androidx.compose.runtime.saveable.Saver
39+
import androidx.compose.runtime.saveable.listSaver
40+
import androidx.compose.runtime.saveable.rememberSaveable
3841
import androidx.compose.runtime.setValue
3942
import androidx.compose.runtime.snapshotFlow
4043
import androidx.compose.ui.Alignment
@@ -60,10 +63,12 @@ import androidx.compose.ui.unit.Velocity
6063
import androidx.compose.ui.unit.dp
6164
import androidx.compose.ui.unit.sp
6265
import kotlinx.coroutines.CoroutineScope
66+
import kotlinx.coroutines.Dispatchers
67+
import kotlinx.coroutines.Job
6368
import kotlinx.coroutines.flow.collectLatest
6469
import kotlinx.coroutines.launch
70+
import top.yukonga.miuix.kmp.basic.PullToRefreshState.Companion.Saver
6571
import top.yukonga.miuix.kmp.utils.getWindowSize
66-
import top.yukonga.miuix.kmp.utils.overScrollVertical
6772
import kotlin.math.PI
6873
import kotlin.math.cos
6974
import kotlin.math.max
@@ -105,37 +110,46 @@ fun PullToRefresh(
105110
pullToRefreshState.syncDragOffsetWithRawOffset()
106111
}
107112

108-
val nestedScrollConnection = remember(pullToRefreshState, topAppBarScrollBehavior) {
109-
object : NestedScrollConnection {
110-
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
111-
val consumedByAppBar = topAppBarScrollBehavior?.nestedScrollConnection?.onPreScroll(available, source) ?: Offset.Zero
113+
val nestedScrollConnection =
114+
remember(pullToRefreshState, topAppBarScrollBehavior) {
115+
object : NestedScrollConnection {
116+
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
117+
val consumedByAppBar = topAppBarScrollBehavior?.nestedScrollConnection?.onPreScroll(available, source) ?: Offset.Zero
112118

113-
val remaining = available - consumedByAppBar
114-
val consumedByRefresh = pullToRefreshState.createNestedScrollConnection().onPreScroll(remaining, source)
119+
val remaining = available - consumedByAppBar
120+
val consumedByRefresh = pullToRefreshState.createNestedScrollConnection().onPreScroll(remaining, source)
115121

116-
return consumedByAppBar + consumedByRefresh
117-
}
122+
return consumedByAppBar + consumedByRefresh
123+
}
118124

119-
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
120-
val consumedByAppBar = topAppBarScrollBehavior?.nestedScrollConnection?.onPostScroll(consumed, available, source) ?: Offset.Zero
125+
override fun onPostScroll(
126+
consumed: Offset,
127+
available: Offset,
128+
source: NestedScrollSource
129+
): Offset {
130+
val consumedByAppBar = topAppBarScrollBehavior?.nestedScrollConnection?.onPostScroll(consumed, available, source) ?: Offset.Zero
121131

122-
val remaining = available - consumedByAppBar
123-
val consumedByRefresh = pullToRefreshState.createNestedScrollConnection().onPostScroll(consumed, remaining, source)
132+
val remaining = available - consumedByAppBar
133+
val consumedByRefresh =
134+
pullToRefreshState.createNestedScrollConnection().onPostScroll(consumed, remaining, source)
124135

125-
return consumedByAppBar + consumedByRefresh
126-
}
136+
return consumedByAppBar + consumedByRefresh
137+
}
127138

128-
override suspend fun onPreFling(available: Velocity): Velocity {
129-
return topAppBarScrollBehavior?.nestedScrollConnection?.onPreFling(available) ?: Velocity.Zero
130-
}
139+
override suspend fun onPreFling(available: Velocity): Velocity {
140+
return topAppBarScrollBehavior?.nestedScrollConnection?.onPreFling(available) ?: Velocity.Zero
141+
}
131142

132-
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
133-
val consumedByAppBar = topAppBarScrollBehavior?.nestedScrollConnection?.onPostFling(consumed, available) ?: Velocity.Zero
143+
override suspend fun onPostFling(
144+
consumed: Velocity,
145+
available: Velocity
146+
): Velocity {
147+
val consumedByAppBar = topAppBarScrollBehavior?.nestedScrollConnection?.onPostFling(consumed, available) ?: Velocity.Zero
134148

135-
return consumedByAppBar
149+
return consumedByAppBar
150+
}
136151
}
137152
}
138-
}
139153

140154
val pointerModifier = Modifier.pointerInput(Unit) {
141155
awaitPointerEventScope {
@@ -149,7 +163,11 @@ fun PullToRefresh(
149163
}
150164
}
151165
}
152-
LaunchedEffect(pullToRefreshState.pointerReleasedValue, pullToRefreshState.isRefreshing, currentOnRefresh) {
166+
LaunchedEffect(
167+
pullToRefreshState.pointerReleasedValue,
168+
pullToRefreshState.isRefreshing,
169+
currentOnRefresh
170+
) {
153171
pullToRefreshState.handlePointerReleased(currentOnRefresh)
154172
}
155173

@@ -159,7 +177,6 @@ fun PullToRefresh(
159177
val boxModifier = modifier
160178
.nestedScroll(nestedScrollConnection)
161179
.then(pointerModifier)
162-
.overScrollVertical()
163180

164181
Box(modifier = boxModifier) {
165182
Column {
@@ -209,7 +226,11 @@ fun RefreshHeader(
209226
}
210227
}
211228

212-
val refreshDisplayInfo by remember(pullToRefreshState.refreshState, pullToRefreshState.pullProgress, refreshCompleteAnimProgress) {
229+
val refreshDisplayInfo by remember(
230+
pullToRefreshState.refreshState,
231+
pullToRefreshState.pullProgress,
232+
refreshCompleteAnimProgress
233+
) {
213234
derivedStateOf {
214235
val (text, alpha) = when (pullToRefreshState.refreshState) {
215236
RefreshState.Idle -> "" to 0f
@@ -227,7 +248,14 @@ fun RefreshHeader(
227248
}
228249
}
229250

230-
val heightInfo by remember(density, pullToRefreshState.refreshState, circleSize, dragOffset, thresholdOffset, refreshCompleteAnimProgress) {
251+
val heightInfo by remember(
252+
density,
253+
pullToRefreshState.refreshState,
254+
circleSize,
255+
dragOffset,
256+
thresholdOffset,
257+
refreshCompleteAnimProgress
258+
) {
231259
derivedStateOf {
232260
with(density) {
233261
when (pullToRefreshState.refreshState) {
@@ -484,23 +512,38 @@ private fun DrawScope.drawRefreshCompleteState(
484512
}
485513

486514
/**
487-
* Refresh status
515+
* Represents the various states of the pull-to-refresh component.
488516
*/
489-
sealed class RefreshState {
490-
/** Idle state */
491-
data object Idle : RefreshState()
492-
493-
/** Pulling state */
494-
data object Pulling : RefreshState()
495-
496-
/** Threshold reached state */
497-
data object ThresholdReached : RefreshState()
498-
499-
/** Refreshing state */
500-
data object Refreshing : RefreshState()
501-
502-
/** Refresh complete state */
503-
data object RefreshComplete : RefreshState()
517+
sealed interface RefreshState {
518+
/** The default, resting state. */
519+
data object Idle : RefreshState
520+
521+
/** The state when the user is actively pulling down, but has not yet passed the threshold. */
522+
data object Pulling : RefreshState
523+
524+
/** The state when the user has pulled down past the refresh threshold. */
525+
data object ThresholdReached : RefreshState
526+
527+
/** The state when the refresh operation is in progress. */
528+
data object Refreshing : RefreshState
529+
530+
/** The state after the refresh operation has completed, before returning to Idle. */
531+
data object RefreshComplete : RefreshState
532+
533+
companion object {
534+
/**
535+
* Restores a [RefreshState] from a saved integer value.
536+
* @param value The integer representation of the state.
537+
* @return The corresponding [RefreshState] instance.
538+
*/
539+
internal fun fromInt(value: Int): RefreshState = when (value) {
540+
1 -> Pulling
541+
2 -> ThresholdReached
542+
3 -> Refreshing
543+
4 -> RefreshComplete
544+
else -> Idle // Default to Idle for safety.
545+
}
546+
}
504547
}
505548

506549
/**
@@ -512,16 +555,26 @@ sealed class RefreshState {
512555
fun rememberPullToRefreshState(): PullToRefreshState {
513556
val coroutineScope = rememberCoroutineScope()
514557
val currentWindowSize = getWindowSize()
515-
return remember(coroutineScope, currentWindowSize.height) {
516-
val screenHeight = currentWindowSize.height.toFloat()
517-
val maxDragDistancePx = screenHeight * maxDragRatio
518-
val refreshThresholdOffset = maxDragDistancePx * thresholdRatio
558+
559+
// Use rememberSaveable with the custom Saver to preserve state across configuration changes.
560+
val state = rememberSaveable(saver = Saver) {
561+
// This block provides the initial state when created for the first time.
519562
PullToRefreshState(
520-
coroutineScope,
521-
screenHeight,
522-
refreshThresholdOffset
563+
coroutineScope = coroutineScope,
564+
maxDragDistancePx = currentWindowSize.height.toFloat(),
565+
refreshThresholdOffset = currentWindowSize.height.toFloat() * maxDragRatio * thresholdRatio
523566
)
524567
}
568+
569+
// Update the transient, context-dependent properties after creation or restoration.
570+
LaunchedEffect(state, coroutineScope, currentWindowSize) {
571+
state.coroutineScope = coroutineScope // The coroutine scope needs to be updated.
572+
// Recalculate dimensions in case they changed during rotation.
573+
state.maxDragDistancePx = currentWindowSize.height.toFloat()
574+
state.refreshThresholdOffset = currentWindowSize.height.toFloat() * maxDragRatio * thresholdRatio
575+
}
576+
577+
return state
525578
}
526579

527580
/**
@@ -532,9 +585,9 @@ fun rememberPullToRefreshState(): PullToRefreshState {
532585
* @param refreshThresholdOffset Refresh threshold offset
533586
*/
534587
class PullToRefreshState(
535-
private val coroutineScope: CoroutineScope,
536-
val maxDragDistancePx: Float,
537-
val refreshThresholdOffset: Float
588+
internal var coroutineScope: CoroutineScope,
589+
internal var maxDragDistancePx: Float,
590+
internal var refreshThresholdOffset: Float
538591
) {
539592
/** Original drag offset */
540593
var rawDragOffset by mutableFloatStateOf(0f)
@@ -567,6 +620,37 @@ class PullToRefreshState(
567620
/** Refresh in progress */
568621
private var isRefreshingInProgress by mutableStateOf(false)
569622

623+
companion object {
624+
/**
625+
* A [Saver] object that defines how to save and restore a [PullToRefreshState].
626+
* It saves the essential state properties needed to survive configuration changes.
627+
*/
628+
val Saver: Saver<PullToRefreshState, *> = listSaver(
629+
save = {
630+
listOf(
631+
it.rawDragOffset,
632+
it.isRefreshingInProgress,
633+
it.refreshState.toInt() // Convert sealed interface state to a savable Int
634+
)
635+
},
636+
restore = { list ->
637+
// Create a new state instance with placeholder values, as context is needed.
638+
// The actual state will be restored immediately after.
639+
val state = PullToRefreshState(
640+
coroutineScope = CoroutineScope(Job() + Dispatchers.Main.immediate), // Placeholder
641+
maxDragDistancePx = 0f, // Will be recalculated
642+
refreshThresholdOffset = 0f // Will be recalculated
643+
)
644+
// Restore the saved properties.
645+
state.rawDragOffset = list[0] as Float
646+
state.isRefreshingInProgress = list[1] as Boolean
647+
// Use the correct `fromInt` call.
648+
state.internalRefreshState = RefreshState.fromInt(list[2] as Int)
649+
state
650+
}
651+
)
652+
}
653+
570654
init {
571655
coroutineScope.launch {
572656
snapshotFlow { dragOffsetAnimatable.value }.collectLatest { offset ->
@@ -668,7 +752,11 @@ class PullToRefreshState(
668752
} else Offset.Zero
669753
}
670754

671-
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = when {
755+
override fun onPostScroll(
756+
consumed: Offset,
757+
available: Offset,
758+
source: NestedScrollSource
759+
): Offset = when {
672760
isRefreshingInProgress || refreshState == RefreshState.Refreshing || refreshState == RefreshState.RefreshComplete -> Offset.Zero
673761
source == NestedScrollSource.UserInput -> {
674762
if (available.y > 0f && consumed.y == 0f) {
@@ -745,6 +833,19 @@ class PullToRefreshState(
745833
val overThreshold = offset - refreshThresholdOffset
746834
return 1.0f / (1.0f + overThreshold / refreshThresholdOffset * 0.8f)
747835
}
836+
837+
}
838+
839+
/**
840+
* Converts a [RefreshState] instance to a savable integer representation.
841+
* @return The integer corresponding to the state.
842+
*/
843+
private fun RefreshState.toInt(): Int = when (this) {
844+
is RefreshState.Idle -> 0
845+
is RefreshState.Pulling -> 1
846+
is RefreshState.ThresholdReached -> 2
847+
is RefreshState.Refreshing -> 3
848+
is RefreshState.RefreshComplete -> 4
748849
}
749850

750851
/** Maximum drag ratio */
@@ -785,4 +886,4 @@ object PullToRefreshDefaults {
785886
fontWeight = FontWeight.Bold,
786887
color = color
787888
)
788-
}
889+
}

0 commit comments

Comments
 (0)