0.6.0
This commit is contained in:
1
compose/persist/.gitignore
vendored
Normal file
1
compose/persist/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
44
compose/persist/build.gradle.kts
Normal file
44
compose/persist/build.gradle.kts
Normal 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)
|
||||
}
|
||||
4
compose/persist/src/main/AndroidManifest.xml
Normal file
4
compose/persist/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
1
compose/preferences/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
48
compose/preferences/build.gradle.kts
Normal file
48
compose/preferences/build.gradle.kts
Normal 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)
|
||||
}
|
||||
4
compose/preferences/src/main/AndroidManifest.xml
Normal file
4
compose/preferences/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest>
|
||||
|
||||
</manifest>
|
||||
@@ -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
1
compose/reordering/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
44
compose/reordering/build.gradle.kts
Normal file
44
compose/reordering/build.gradle.kts
Normal 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)
|
||||
}
|
||||
2
compose/reordering/src/main/AndroidManifest.xml
Normal file
2
compose/reordering/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
1
compose/routing/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
46
compose/routing/build.gradle.kts
Normal file
46
compose/routing/build.gradle.kts
Normal 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)
|
||||
}
|
||||
2
compose/routing/src/main/AndroidManifest.xml
Normal file
2
compose/routing/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user