This commit is contained in:
2024-02-27 22:09:30 +03:00
parent bfa3231823
commit 38a3141d43
479 changed files with 36348 additions and 10142 deletions

1
compose/persist/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,44 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "it.hamy.compose.persist"
compileSdk = 34
defaultConfig {
minSdk = 21
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
}
}
sourceSets.all {
kotlin.srcDir("src/$name/kotlin")
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
}
kotlin {
jvmToolchain(libs.versions.jvm.get().toInt())
}
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.compose.foundation)
detektPlugins(libs.detekt.compose)
detektPlugins(libs.detekt.formatting)
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>

View File

@@ -0,0 +1,31 @@
package it.hamy.compose.persist
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.SnapshotMutationPolicy
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.structuralEqualityPolicy
@Suppress("UNCHECKED_CAST")
@Composable
fun <T> persist(
tag: String,
initialValue: T,
policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> {
val persistMap = LocalPersistMap.current
return remember(persistMap) {
persistMap?.map?.getOrPut(tag) { mutableStateOf(initialValue, policy) } as? MutableState<T>
?: mutableStateOf(initialValue, policy)
}
}
@Composable
fun <T> persistList(tag: String): MutableState<List<T>> =
persist(tag = tag, initialValue = emptyList())
@Composable
fun <T : Any?> persist(tag: String): MutableState<T?> =
persist(tag = tag, initialValue = null)

View File

@@ -0,0 +1,16 @@
package it.hamy.compose.persist
import android.util.Log
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.compositionLocalOf
@JvmInline
value class PersistMap(val map: MutableMap<String, MutableState<*>> = hashMapOf()) {
fun clean(prefix: String) = map.keys.removeAll { it.startsWith(prefix) }
}
val LocalPersistMap = compositionLocalOf<PersistMap?> {
Log.e("PersistMap", "Tried to reference uninitialized PersistMap, stacktrace:")
runCatching { error("Stack:") }.exceptionOrNull()?.printStackTrace()
null
}

View File

@@ -0,0 +1,30 @@
package it.hamy.compose.persist
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalContext
@Composable
fun PersistMapCleanup(prefix: String) {
val context = LocalContext.current
val persistMap = LocalPersistMap.current
DisposableEffect(persistMap) {
onDispose {
if (context.findActivityNullable()?.isChangingConfigurations == false)
persistMap?.clean(prefix)
}
}
}
fun Context.findActivityNullable(): Activity? {
var current = this
while (current is ContextWrapper) {
if (current is Activity) return current
current = current.baseContext
}
return null
}

1
compose/preferences/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,48 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "it.hamy.compose.preferences"
compileSdk = 34
defaultConfig {
minSdk = 21
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
}
}
sourceSets.all {
kotlin.srcDir("src/$name/kotlin")
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
}
kotlin {
jvmToolchain(libs.versions.jvm.get().toInt())
}
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.compose.foundation)
implementation(libs.core.ktx)
implementation(libs.kotlin.coroutines)
detektPlugins(libs.detekt.compose)
detektPlugins(libs.detekt.formatting)
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>

View File

@@ -0,0 +1,124 @@
package it.hamy.compose.preferences
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.Snapshot
import androidx.core.content.edit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
private val coroutineScope = CoroutineScope(Dispatchers.IO)
fun <T : Any> sharedPreferencesProperty(
getValue: SharedPreferences.(key: String) -> T,
setValue: SharedPreferences.Editor.(key: String, value: T) -> Unit,
defaultValue: T
) = SharedPreferencesProperty(
get = getValue,
set = setValue,
default = defaultValue
)
@Stable
data class SharedPreferencesProperty<T : Any> internal constructor(
private val get: SharedPreferences.(key: String) -> T,
private val set: SharedPreferences.Editor.(key: String, value: T) -> Unit,
private val default: T
) : ReadWriteProperty<PreferencesHolder, T> {
private val state = mutableStateOf(default)
val stateFlow = MutableStateFlow(default) // TODO: hotfix
private var listener: OnSharedPreferenceChangeListener? = null
private fun setState(newValue: T) {
state.value = newValue
stateFlow.update { newValue }
}
override fun getValue(thisRef: PreferencesHolder, property: KProperty<*>): T {
if (listener == null && !Snapshot.current.readOnly && !Snapshot.current.root.readOnly) {
setState(thisRef.get(property.name))
listener = OnSharedPreferenceChangeListener { preferences, key ->
if (key == property.name) preferences.get(property.name).let {
if (it != state.value && !Snapshot.current.readOnly) setState(it)
}
}
thisRef.registerOnSharedPreferenceChangeListener(listener)
}
return state.value
}
override fun setValue(thisRef: PreferencesHolder, property: KProperty<*>, value: T) =
coroutineScope.launch {
thisRef.edit(commit = true) {
set(property.name, value)
}
}.let { }
}
/**
* A snapshottable, thread-safe, compose-first, extensible SharedPreferences wrapper that supports
* virtually all types, and if it doesn't, one could simply type
* `fun myNewType(...) = sharedPreferencesProperty(...)` and start implementing. Starts off as given
* defaultValue until we are allowed to subscribe to SharedPreferences. Caution: the type of the
* preference has to be [Stable], otherwise UB will occur.
*/
open class PreferencesHolder(
application: Application,
name: String,
mode: Int = Context.MODE_PRIVATE
) : SharedPreferences by application.getSharedPreferences(name, mode) {
fun boolean(defaultValue: Boolean) = sharedPreferencesProperty(
getValue = { getBoolean(it, defaultValue) },
setValue = { k, v -> putBoolean(k, v) },
defaultValue
)
fun string(defaultValue: String) = sharedPreferencesProperty(
getValue = { getString(it, null) ?: defaultValue },
setValue = { k, v -> putString(k, v) },
defaultValue
)
fun int(defaultValue: Int) = sharedPreferencesProperty(
getValue = { getInt(it, defaultValue) },
setValue = { k, v -> putInt(k, v) },
defaultValue
)
fun float(defaultValue: Float) = sharedPreferencesProperty(
getValue = { getFloat(it, defaultValue) },
setValue = { k, v -> putFloat(k, v) },
defaultValue
)
fun long(defaultValue: Long) = sharedPreferencesProperty(
getValue = { getLong(it, defaultValue) },
setValue = { k, v -> putLong(k, v) },
defaultValue
)
inline fun <reified T : Enum<T>> enum(defaultValue: T) = sharedPreferencesProperty(
getValue = {
getString(it, null)?.let { runCatching { enumValueOf<T>(it) }.getOrNull() }
?: defaultValue
},
setValue = { k, v -> putString(k, v.name) },
defaultValue
)
fun stringSet(defaultValue: Set<String>) = sharedPreferencesProperty(
getValue = { getStringSet(it, null) ?: defaultValue },
setValue = { k, v -> putStringSet(k, v) },
defaultValue
)
}

1
compose/reordering/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,44 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "it.hamy.compose.reordering"
compileSdk = 34
defaultConfig {
minSdk = 21
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
}
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + listOf("-Xcontext-receivers")
}
}
kotlin {
jvmToolchain(libs.versions.jvm.get().toInt())
}
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.compose.foundation)
detektPlugins(libs.detekt.compose)
detektPlugins(libs.detekt.formatting)
}

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@@ -0,0 +1,34 @@
package it.hamy.compose.reordering
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector
import androidx.compose.animation.core.TwoWayConverter
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class AnimatablesPool<T, V : AnimationVector>(
private val size: Int,
private val initialValue: T,
typeConverter: TwoWayConverter<T, V>
) {
private val values = MutableList(size) {
Animatable(initialValue = initialValue, typeConverter = typeConverter)
}
private val mutex = Mutex()
init {
require(size > 0)
}
suspend fun acquire() = mutex.withLock {
if (values.isNotEmpty()) values.removeFirst() else null
}
suspend fun release(animatable: Animatable<T, V>) = mutex.withLock {
if (values.size < size) {
animatable.snapTo(initialValue)
values.add(animatable)
}
}
}

View File

@@ -0,0 +1,10 @@
package it.hamy.compose.reordering
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.ui.Modifier
context(LazyItemScope)
@ExperimentalFoundationApi
fun Modifier.animateItemPlacement(reorderingState: ReorderingState) =
if (reorderingState.draggingIndex == -1) animateItemPlacement() else this

View File

@@ -0,0 +1,58 @@
package it.hamy.compose.reordering
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.offset
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.layout.LocalPinnableContainer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
fun Modifier.draggedItem(
reorderingState: ReorderingState,
index: Int,
draggedElevation: Dp = 4.dp
): Modifier = when (reorderingState.draggingIndex) {
-1 -> this
index -> offset {
when (reorderingState.lazyListState.layoutInfo.orientation) {
Orientation.Vertical -> IntOffset(0, reorderingState.offset.value)
Orientation.Horizontal -> IntOffset(reorderingState.offset.value, 0)
}
}.zIndex(1f)
else -> offset {
val offset = when (index) {
in reorderingState.indexesToAnimate -> reorderingState.indexesToAnimate.getValue(index).value
in (reorderingState.draggingIndex + 1)..reorderingState.reachedIndex -> -reorderingState.draggingItemSize
in reorderingState.reachedIndex..<reorderingState.draggingIndex -> reorderingState.draggingItemSize
else -> 0
}
when (reorderingState.lazyListState.layoutInfo.orientation) {
Orientation.Vertical -> IntOffset(0, offset)
Orientation.Horizontal -> IntOffset(offset, 0)
}
}
}.composed {
val container = LocalPinnableContainer.current
val elevation by animateDpAsState(
targetValue = if (reorderingState.draggingIndex == index) draggedElevation else 0.dp,
label = ""
)
DisposableEffect(reorderingState.draggingIndex) {
val handle = if (reorderingState.draggingIndex == index) container?.pin() else null
onDispose {
handle?.release()
}
}
this.shadow(elevation = elevation)
}

View File

@@ -0,0 +1,41 @@
package it.hamy.compose.reordering
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.pointerInput
private fun Modifier.reorder(
reorderingState: ReorderingState,
index: Int,
detectDragGestures: DetectDragGestures
) = this.pointerInput(reorderingState) {
with(detectDragGestures) {
detectDragGestures(
onDragStart = { reorderingState.onDragStart(index) },
onDrag = reorderingState::onDrag,
onDragEnd = reorderingState::onDragEnd,
onDragCancel = reorderingState::onDragEnd
)
}
}
fun Modifier.reorder(
reorderingState: ReorderingState,
index: Int
) = this.reorder(
reorderingState = reorderingState,
index = index,
detectDragGestures = PointerInputScope::detectDragGestures
)
private fun interface DetectDragGestures {
suspend fun PointerInputScope.detectDragGestures(
onDragStart: (Offset) -> Unit,
onDragEnd: () -> Unit,
onDragCancel: () -> Unit,
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
)
}

View File

@@ -0,0 +1,224 @@
package it.hamy.compose.reordering
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.VectorConverter
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.PointerInputChange
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
@Stable
class ReorderingState(
val lazyListState: LazyListState,
val coroutineScope: CoroutineScope,
private val lastIndex: Int,
internal val onDragStart: () -> Unit,
internal val onDragEnd: (Int, Int) -> Unit,
private val extraItemCount: Int
) {
internal val offset = Animatable(0, Int.VectorConverter)
internal var draggingIndex by mutableIntStateOf(-1)
internal var reachedIndex by mutableIntStateOf(-1)
internal var draggingItemSize by mutableIntStateOf(0)
private lateinit var itemInfo: LazyListItemInfo
private var previousItemSize = 0
private var nextItemSize = 0
private var overscrolled = 0
internal var indexesToAnimate = mutableStateMapOf<Int, Animatable<Int, AnimationVector1D>>()
private var animatablesPool: AnimatablesPool<Int, AnimationVector1D>? = null
val isDragging: Boolean
get() = draggingIndex != -1
fun onDragStart(index: Int) {
overscrolled = 0
itemInfo = lazyListState.layoutInfo.visibleItemsInfo
.find { it.index == index + extraItemCount } ?: return
onDragStart()
draggingIndex = index
reachedIndex = index
draggingItemSize = itemInfo.size
nextItemSize = draggingItemSize
previousItemSize = -draggingItemSize
offset.updateBounds(
lowerBound = -index * draggingItemSize,
upperBound = (lastIndex - index) * draggingItemSize
)
animatablesPool = AnimatablesPool(
size = (lazyListState.layoutInfo.viewportEndOffset - lazyListState.layoutInfo.viewportStartOffset) /
(draggingItemSize + 2),
initialValue = 0,
typeConverter = Int.VectorConverter
)
}
@Suppress("CyclomaticComplexMethod")
fun onDrag(change: PointerInputChange, dragAmount: Offset) {
if (!isDragging) return
change.consume()
val delta = when (lazyListState.layoutInfo.orientation) {
Orientation.Vertical -> dragAmount.y
Orientation.Horizontal -> dragAmount.x
}.roundToInt()
val targetOffset = offset.value + delta
coroutineScope.launch { offset.snapTo(targetOffset) }
when {
targetOffset > nextItemSize -> {
if (reachedIndex < lastIndex) {
reachedIndex += 1
nextItemSize += draggingItemSize
previousItemSize += draggingItemSize
val indexToAnimate = reachedIndex - if (draggingIndex < reachedIndex) 0 else 1
coroutineScope.launch {
val animatable = indexesToAnimate.getOrPut(indexToAnimate) {
animatablesPool?.acquire() ?: return@launch
}
if (draggingIndex < reachedIndex) {
animatable.snapTo(0)
animatable.animateTo(-draggingItemSize)
} else {
animatable.snapTo(draggingItemSize)
animatable.animateTo(0)
}
indexesToAnimate.remove(indexToAnimate)
animatablesPool?.release(animatable)
}
}
}
targetOffset < previousItemSize -> {
if (reachedIndex > 0) {
reachedIndex -= 1
previousItemSize -= draggingItemSize
nextItemSize -= draggingItemSize
val indexToAnimate = reachedIndex + if (draggingIndex > reachedIndex) 0 else 1
coroutineScope.launch {
val animatable = indexesToAnimate.getOrPut(indexToAnimate) {
animatablesPool?.acquire() ?: return@launch
}
if (draggingIndex > reachedIndex) {
animatable.snapTo(0)
animatable.animateTo(draggingItemSize)
} else {
animatable.snapTo(-draggingItemSize)
animatable.animateTo(0)
}
indexesToAnimate.remove(indexToAnimate)
animatablesPool?.release(animatable)
}
}
}
else -> {
val offsetInViewPort = targetOffset + itemInfo.offset - overscrolled
val topOverscroll = lazyListState.layoutInfo.viewportStartOffset +
lazyListState.layoutInfo.beforeContentPadding - offsetInViewPort
val bottomOverscroll = lazyListState.layoutInfo.viewportEndOffset -
lazyListState.layoutInfo.afterContentPadding - offsetInViewPort - itemInfo.size
if (topOverscroll > 0) overscroll(topOverscroll) else if (bottomOverscroll < 0)
overscroll(bottomOverscroll)
}
}
}
fun onDragEnd() {
if (!isDragging) return
coroutineScope.launch {
offset.animateTo((previousItemSize + nextItemSize) / 2)
withContext(Dispatchers.Main) { onDragEnd(draggingIndex, reachedIndex) }
if (areEquals()) {
draggingIndex = -1
reachedIndex = -1
draggingItemSize = 0
offset.snapTo(0)
}
animatablesPool = null
}
}
private fun overscroll(overscroll: Int) {
val newHeight = itemInfo.offset - overscroll
@Suppress("ComplexCondition")
if (
!(overscroll > 0 && newHeight <= lazyListState.layoutInfo.viewportEndOffset) &&
!(overscroll < 0 && newHeight >= lazyListState.layoutInfo.viewportStartOffset)
) return
coroutineScope.launch {
lazyListState.scrollBy(-overscroll.toFloat())
offset.snapTo(offset.value - overscroll)
}
overscrolled -= overscroll
}
private fun areEquals() = lazyListState.layoutInfo.visibleItemsInfo.find {
it.index + extraItemCount == draggingIndex
}?.key == lazyListState.layoutInfo.visibleItemsInfo.find {
it.index + extraItemCount == reachedIndex
}?.key
}
@Composable
fun rememberReorderingState(
lazyListState: LazyListState,
key: Any,
onDragEnd: (Int, Int) -> Unit,
onDragStart: () -> Unit = {},
extraItemCount: Int = 0
): ReorderingState {
val coroutineScope = rememberCoroutineScope()
return remember(key) {
ReorderingState(
lazyListState = lazyListState,
coroutineScope = coroutineScope,
lastIndex = if (key is List<*>) key.lastIndex else lazyListState.layoutInfo.totalItemsCount,
onDragStart = onDragStart,
onDragEnd = onDragEnd,
extraItemCount = extraItemCount
)
}
}

1
compose/routing/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -0,0 +1,46 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "it.hamy.compose.routing"
compileSdk = 34
defaultConfig {
minSdk = 21
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
}
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + listOf("-Xcontext-receivers")
}
}
kotlin {
jvmToolchain(libs.versions.jvm.get().toInt())
}
dependencies {
implementation(platform(libs.compose.bom))
implementation(libs.compose.activity)
implementation(libs.compose.foundation)
implementation(libs.compose.animation)
detektPlugins(libs.detekt.compose)
detektPlugins(libs.detekt.formatting)
}

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@@ -0,0 +1,14 @@
package it.hamy.compose.routing
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.flow.MutableSharedFlow
internal val globalRouteFlow = MutableSharedFlow<Pair<Route, Array<Any?>>>(extraBufferCapacity = 1)
@Composable
fun OnGlobalRoute(block: suspend (Pair<Route, Array<Any?>>) -> Unit) {
LaunchedEffect(Unit) {
globalRouteFlow.collect(block)
}
}

View File

@@ -0,0 +1,97 @@
@file:Suppress("UNCHECKED_CAST")
package it.hamy.compose.routing
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.saveable.SaverScope
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
@Immutable
open class Route internal constructor(val tag: String) {
override fun equals(other: Any?) = when {
this === other -> true
other is Route -> tag == other.tag
else -> false
}
override fun hashCode() = tag.hashCode()
object Saver : androidx.compose.runtime.saveable.Saver<Route?, String> {
override fun restore(value: String): Route? = value.takeIf(String::isNotEmpty)?.let(::Route)
override fun SaverScope.save(value: Route?): String = value?.tag.orEmpty()
}
}
@Immutable
class Route0(tag: String) : Route(tag) {
context(RouteHandlerScope)
@Composable
operator fun invoke(content: @Composable () -> Unit) {
if (this == route) content()
}
fun global() {
globalRouteFlow.tryEmit(this to emptyArray())
}
suspend fun ensureGlobal() {
globalRouteFlow.subscriptionCount.filter { it > 0 }.first()
globalRouteFlow.emit(this to arrayOf())
}
}
@Immutable
class Route1<P0>(tag: String) : Route(tag) {
context(RouteHandlerScope)
@Composable
operator fun invoke(content: @Composable (P0) -> Unit) {
if (this == route) content(parameters[0] as P0)
}
fun global(p0: P0) {
globalRouteFlow.tryEmit(this to arrayOf(p0))
}
suspend fun ensureGlobal(p0: P0) {
globalRouteFlow.subscriptionCount.filter { it > 0 }.first()
globalRouteFlow.emit(this to arrayOf(p0))
}
}
@Immutable
class Route2<P0, P1>(tag: String) : Route(tag) {
context(RouteHandlerScope)
@Composable
operator fun invoke(content: @Composable (P0, P1) -> Unit) {
if (this == route) content(parameters[0] as P0, parameters[1] as P1)
}
fun global(p0: P0, p1: P1) {
globalRouteFlow.tryEmit(this to arrayOf(p0, p1))
}
suspend fun ensureGlobal(p0: P0, p1: P1) {
globalRouteFlow.subscriptionCount.filter { it > 0 }.first()
globalRouteFlow.emit(this to arrayOf(p0, p1))
}
}
@Immutable
class Route3<P0, P1, P2>(tag: String) : Route(tag) {
context(RouteHandlerScope)
@Composable
operator fun invoke(content: @Composable (P0, P1, P2) -> Unit) {
if (this == route) content(parameters[0] as P0, parameters[1] as P1, parameters[2] as P2)
}
fun global(p0: P0, p1: P1, p2: P2) {
globalRouteFlow.tryEmit(this to arrayOf(p0, p1, p2))
}
suspend fun ensureGlobal(p0: P0, p1: P1, p2: P2) {
globalRouteFlow.subscriptionCount.filter { it > 0 }.first()
globalRouteFlow.emit(this to arrayOf(p0, p1, p2))
}
}

View File

@@ -0,0 +1,98 @@
package it.hamy.compose.routing
import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.updateTransition
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun RouteHandler(
modifier: Modifier = Modifier,
listenToGlobalEmitter: Boolean = false,
handleBackPress: Boolean = true,
transitionSpec: AnimatedContentTransitionScope<RouteHandlerScope>.() -> ContentTransform = {
when {
isStacking -> defaultStacking
isStill -> defaultStill
else -> defaultUnstacking
}
},
content: @Composable RouteHandlerScope.() -> Unit
) {
var route by rememberSaveable(stateSaver = Route.Saver) {
mutableStateOf(null)
}
RouteHandler(
route = route,
onRouteChanged = { route = it },
listenToGlobalEmitter = listenToGlobalEmitter,
handleBackPress = handleBackPress,
transitionSpec = transitionSpec,
modifier = modifier,
content = content
)
}
@ExperimentalAnimationApi
@Composable
fun RouteHandler(
route: Route?,
onRouteChanged: (Route?) -> Unit,
modifier: Modifier = Modifier,
listenToGlobalEmitter: Boolean = false,
handleBackPress: Boolean = true,
transitionSpec: AnimatedContentTransitionScope<RouteHandlerScope>.() -> ContentTransform = {
when {
isStacking -> defaultStacking
isStill -> defaultStill
else -> defaultUnstacking
}
},
content: @Composable RouteHandlerScope.() -> Unit
) {
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val parameters = rememberSaveable {
arrayOfNulls<Any?>(3)
}
val scope = remember(route) {
RouteHandlerScope(
route = route,
parameters = parameters,
push = onRouteChanged,
pop = { if (handleBackPress) backDispatcher?.onBackPressed() else onRouteChanged(null) }
)
}
if (listenToGlobalEmitter && route == null) {
OnGlobalRoute { (newRoute, newParameters) ->
newParameters.forEachIndexed(parameters::set)
onRouteChanged(newRoute)
}
}
BackHandler(enabled = handleBackPress && route != null) {
onRouteChanged(null)
}
updateTransition(targetState = scope, label = null).AnimatedContent(
transitionSpec = transitionSpec,
contentKey = RouteHandlerScope::route,
modifier = modifier
) {
it.content()
}
}

View File

@@ -0,0 +1,34 @@
package it.hamy.compose.routing
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
@Stable
class RouteHandlerScope(
val route: Route?,
val parameters: Array<Any?>,
private val push: (Route?) -> Unit,
val pop: () -> Unit
) {
@Composable
inline fun NavHost(content: @Composable () -> Unit) {
if (route == null) content()
}
operator fun Route.invoke() = push(this)
operator fun <P0> Route.invoke(p0: P0) {
parameters[0] = p0
invoke()
}
operator fun <P0, P1> Route.invoke(p0: P0, p1: P1) {
parameters[1] = p1
invoke(p0)
}
operator fun <P0, P1, P2> Route.invoke(p0: P0, p1: P1, p2: P2) {
parameters[2] = p2
invoke(p0, p1)
}
}

View File

@@ -0,0 +1,46 @@
package it.hamy.compose.routing
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ContentTransform
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleOut
@ExperimentalAnimationApi
val defaultStacking = ContentTransform(
initialContentExit = scaleOut(targetScale = 0.9f) + fadeOut(),
targetContentEnter = fadeIn(),
targetContentZIndex = 1f
)
@ExperimentalAnimationApi
val defaultUnstacking = ContentTransform(
initialContentExit = fadeOut(),
targetContentEnter = EnterTransition.None,
targetContentZIndex = 0f
)
@ExperimentalAnimationApi
val defaultStill = ContentTransform(
initialContentExit = scaleOut(targetScale = 0.9f) + fadeOut(),
targetContentEnter = fadeIn(),
targetContentZIndex = 1f
)
@ExperimentalAnimationApi
val AnimatedContentTransitionScope<RouteHandlerScope>.isStacking: Boolean
get() = initialState.route == null && targetState.route != null
@ExperimentalAnimationApi
val AnimatedContentTransitionScope<RouteHandlerScope>.isUnstacking: Boolean
get() = initialState.route != null && targetState.route == null
@ExperimentalAnimationApi
val AnimatedContentTransitionScope<RouteHandlerScope>.isStill: Boolean
get() = initialState.route == null && targetState.route == null
@ExperimentalAnimationApi
val AnimatedContentTransitionScope<RouteHandlerScope>.isUnknown: Boolean
get() = initialState.route != null && targetState.route != null