@@ -35,6 +35,9 @@ import androidx.compose.runtime.mutableStateOf
3535import androidx.compose.runtime.remember
3636import androidx.compose.runtime.rememberCoroutineScope
3737import 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
3841import androidx.compose.runtime.setValue
3942import androidx.compose.runtime.snapshotFlow
4043import androidx.compose.ui.Alignment
@@ -60,10 +63,12 @@ import androidx.compose.ui.unit.Velocity
6063import androidx.compose.ui.unit.dp
6164import androidx.compose.ui.unit.sp
6265import kotlinx.coroutines.CoroutineScope
66+ import kotlinx.coroutines.Dispatchers
67+ import kotlinx.coroutines.Job
6368import kotlinx.coroutines.flow.collectLatest
6469import kotlinx.coroutines.launch
70+ import top.yukonga.miuix.kmp.basic.PullToRefreshState.Companion.Saver
6571import top.yukonga.miuix.kmp.utils.getWindowSize
66- import top.yukonga.miuix.kmp.utils.overScrollVertical
6772import kotlin.math.PI
6873import kotlin.math.cos
6974import 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 {
512555fun 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 */
534587class 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