Skip to content

Commit

Permalink
Bump landscapist and improve the palette tracking logic
Browse files Browse the repository at this point in the history
  • Loading branch information
skydoves committed Jan 3, 2025
1 parent 516c51e commit dff20d4
Show file tree
Hide file tree
Showing 3 changed files with 171 additions and 13 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* Designed and developed by 2024 skydoves (Jaewoong Eum)
*
* 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)

package com.skydoves.pokedex.compose.feature.details

import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingCommand
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest

/**
* Create an upstream cold flow as a StateFlow that triggers the upstream operation only once,
* preventing re-execution no matter how many times it's subscribed.
* After the initial emission, it will simply replay the latest cached value.
*
* @param scope the coroutine scope in which sharing is started.
* @param stopTimeout configures a delay (in milliseconds) between the disappearance of the last
* subscriber and the stopping of the sharing coroutine. It defaults to zero (stop immediately).
* @param replayExpiration configures a delay (in milliseconds) between the stopping of
* the sharing coroutine and the resetting of the replay cache (which makes the cache empty for the
* [shareIn] operator and resets the cached value to the original `initialValue`
* for the [stateIn] operator). It defaults to `Long.MAX_VALUE` (keep replay cache forever,
* never reset buffer). Use zero value to expire the cache immediately.
* @param initialValue the initial value of the state flow. This value is also used when the
* state flow is reset using the [SharingStarted]. [WhileSubscribed] strategy with the
* [replayExpiration] parameter.
*/
public fun <T> Flow<T>.onetimeStateIn(
scope: CoroutineScope,
stopTimeout: Long = 0,
replayExpiration: Long = Long.MAX_VALUE,
initialValue: T,
): StateFlow<T> {
return stateIn(
scope = scope,
started = OnetimeWhileSubscribed(
stopTimeout = stopTimeout,
replayExpiration = replayExpiration,
),
initialValue,
)
}

/**
* This is a companion extension of [SharingStarted], which is a [SharingStarted] strategy
* designed to limit upstream emissions to only once. After the initial emission,
* it remains inactive until an active subscriber reappears.
*
* @param stopTimeout configures a delay (in milliseconds) between the disappearance of the last
* subscriber and the stopping of the sharing coroutine. It defaults to zero (stop immediately).
* @param replayExpiration configures a delay (in milliseconds) between the stopping of
* the sharing coroutine and the resetting of the replay cache (which makes the cache empty for the
* [shareIn] operator and resets the cached value to the original `initialValue`
* for the [stateIn] operator). It defaults to `Long.MAX_VALUE` (keep replay cache forever,
* never reset buffer). Use zero value to expire the cache immediately.
*/
public fun SharingStarted.Companion.OnetimeWhileSubscribed(
stopTimeout: Long,
replayExpiration: Long = Long.MAX_VALUE,
): OnetimeWhileSubscribed {
return OnetimeWhileSubscribed(
stopTimeout = stopTimeout,
replayExpiration = replayExpiration,
)
}

/**
* `OnetimeWhileSubscribed` is a [SharingStarted] strategy designed to limit upstream emissions to
* only once. After the initial emission, it remains inactive until an active subscriber reappears.
*
* @param stopTimeout configures a delay (in milliseconds) between the disappearance of the last
* subscriber and the stopping of the sharing coroutine. It defaults to zero (stop immediately).
* @param replayExpiration configures a delay (in milliseconds) between the stopping of
* the sharing coroutine and the resetting of the replay cache (which makes the cache empty for the
* [shareIn] operator and resets the cached value to the original `initialValue`
* for the [stateIn] operator). It defaults to `Long.MAX_VALUE` (keep replay cache forever,
* never reset buffer). Use zero value to expire the cache immediately.
*/
public class OnetimeWhileSubscribed(
private val stopTimeout: Long,
private val replayExpiration: Long,
) : SharingStarted {

private val hasCollected: MutableStateFlow<Boolean> = MutableStateFlow(false)

init {
require(stopTimeout >= 0) { "stopTimeout($stopTimeout ms) cannot be negative" }
require(replayExpiration >= 0) { "replayExpiration($replayExpiration ms) cannot be negative" }
}

override fun command(subscriptionCount: StateFlow<Int>): Flow<SharingCommand> =
combine(hasCollected, subscriptionCount) { collected, counts ->
collected to counts
}
.transformLatest { pair ->
val (collected, count) = pair
if (count > 0 && !collected) {
emit(SharingCommand.START)
hasCollected.value = true
} else {
delay(stopTimeout)
if (replayExpiration > 0) {
emit(SharingCommand.STOP)
delay(replayExpiration)
}
}
}
.dropWhile {
it != SharingCommand.START
} // don't emit any STOP/RESET_BUFFER to start with, only START
.distinctUntilChanged() // just in case somebody forgets it, don't leak our multiple sending of START

override fun toString(): String {
val params = buildList(2) {
if (stopTimeout > 0) add("stopTimeout=${stopTimeout}ms")
if (replayExpiration < Long.MAX_VALUE) add("replayExpiration=${replayExpiration}ms")
}
return "SharingStarted.WhileSubscribed(${params.joinToString()})"
}

// equals & hashcode to facilitate testing, not documented in public contract
override fun equals(other: Any?): Boolean =
other is OnetimeWhileSubscribed &&
stopTimeout == other.stopTimeout &&
replayExpiration == other.replayExpiration

override fun hashCode(): Int = stopTimeout.hashCode() * 31 + replayExpiration.hashCode()
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import com.skydoves.landscapist.animation.crossfade.CrossfadePlugin
import com.skydoves.landscapist.components.rememberImageComponent
import com.skydoves.landscapist.glide.GlideImage
import com.skydoves.landscapist.palette.PalettePlugin
import com.skydoves.landscapist.palette.rememberPaletteState
import com.skydoves.landscapist.placeholder.shimmer.Shimmer
import com.skydoves.landscapist.placeholder.shimmer.ShimmerPlugin
import com.skydoves.pokedex.compose.core.data.repository.home.FakeHomeRepository
Expand Down Expand Up @@ -117,9 +118,14 @@ private fun SharedTransitionScope.HomeContent(
fetchNextPokemonList()
}

var palette by rememberPaletteState()
val backgroundColor by palette.paletteBackgroundColor()

PokemonCard(
animatedVisibilityScope = animatedVisibilityScope,
pokemon = pokemon,
onPaletteLoaded = { palette = it },
backgroundColor = backgroundColor,
)
}
}
Expand All @@ -133,11 +139,11 @@ private fun SharedTransitionScope.HomeContent(
@Composable
private fun SharedTransitionScope.PokemonCard(
animatedVisibilityScope: AnimatedVisibilityScope,
onPaletteLoaded: (Palette) -> Unit,
backgroundColor: Color,
pokemon: Pokemon,
) {
val composeNavigator = currentComposeNavigator
var palette by remember { mutableStateOf<Palette?>(null) }
val backgroundColor by palette.paletteBackgroundColor()

Card(
modifier = Modifier
Expand Down Expand Up @@ -182,7 +188,7 @@ private fun SharedTransitionScope.PokemonCard(
+PalettePlugin(
imageModel = pokemon.imageUrl,
useCache = true,
paletteLoadedListener = { palette = it },
paletteLoadedListener = { onPaletteLoaded.invoke(it) },
)
}
},
Expand Down
20 changes: 10 additions & 10 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
[versions]
agp = "8.7.1"
kotlin = "2.0.21"
ksp = "2.0.21-1.0.25"
agp = "8.7.3"
kotlin = "2.1.0"
ksp = "2.0.21-1.0.28"
kotlinxImmutable = "0.3.7"
androidxActivity = "1.9.3"
androidxCore = "1.13.1"
androidxLifecycle = "2.8.6"
androidxCore = "1.15.0"
androidxLifecycle = "2.8.7"
androidxRoom = "2.6.1"
androidxArchCore = "2.2.0"
androidXStartup = "1.2.0"
androidxCompose = "1.7.4"
androidxComposeMaterial3 = "1.3.0"
androidxNavigation = "2.8.3"
androidxCompose = "1.7.6"
androidxComposeMaterial3 = "1.3.1"
androidxNavigation = "2.8.5"
androidxHiltNavigationCompose = "1.2.0"
composeStableMarker = "1.0.5"
kotlinxSerializationJson = "1.7.3"
hilt = "2.52"
hilt = "2.54"
retrofit = "2.11.0"
okHttp = "4.12.0"
sandwich = "2.0.10"
landscapist = "2.4.1"
landscapist = "2.4.6"
coroutines = "1.9.0"
profileInstaller = "1.4.1"
macroBenchmark = "1.3.3"
Expand Down

0 comments on commit dff20d4

Please sign in to comment.