From cbc868448b02f7ec62a9ab0471dc9fb232dff0b0 Mon Sep 17 00:00:00 2001 From: InsanusMokrassar Date: Fri, 11 Aug 2023 00:58:09 +0600 Subject: [PATCH 1/4] start 0.20.1 --- CHANGELOG.md | 2 ++ gradle.properties | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e05cd32f179..23bf402e53f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## 0.20.1 + ## 0.20.0 * `Versions`: diff --git a/gradle.properties b/gradle.properties index d5225567187..8bb22490175 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,5 +15,5 @@ crypto_js_version=4.1.1 # Project data group=dev.inmo -version=0.20.0 -android_code_version=206 +version=0.20.1 +android_code_version=207 From a65bb2f4190c933322f6c1e4000edf2f48adfec6 Mon Sep 17 00:00:00 2001 From: InsanusMokrassar Date: Fri, 11 Aug 2023 01:51:40 +0600 Subject: [PATCH 2/4] add number picker, set picker and small text field --- CHANGELOG.md | 5 + android/pickers/build.gradle | 18 ++ .../src/androidMain/AndroidManifest.xml | 1 + .../pickers/src/androidMain/kotlin/Fling.kt | 27 +++ .../src/androidMain/kotlin/NumberPicker.kt | 222 ++++++++++++++++++ .../src/androidMain/kotlin/SetPicker.kt | 156 ++++++++++++ android/smalltextfield/build.gradle | 18 ++ .../src/androidMain/AndroidManifest.xml | 1 + .../src/androidMain/kotlin/SmallTextField.kt | 66 ++++++ gradle/libs.versions.toml | 2 + settings.gradle | 2 + 11 files changed, 518 insertions(+) create mode 100644 android/pickers/build.gradle create mode 100644 android/pickers/src/androidMain/AndroidManifest.xml create mode 100644 android/pickers/src/androidMain/kotlin/Fling.kt create mode 100644 android/pickers/src/androidMain/kotlin/NumberPicker.kt create mode 100644 android/pickers/src/androidMain/kotlin/SetPicker.kt create mode 100644 android/smalltextfield/build.gradle create mode 100644 android/smalltextfield/src/androidMain/AndroidManifest.xml create mode 100644 android/smalltextfield/src/androidMain/kotlin/SmallTextField.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 23bf402e53f..3c5c560375a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## 0.20.1 +* `SmallTextField`: + * Module is initialized +* `Pickers`: + * Module is initialized + ## 0.20.0 * `Versions`: diff --git a/android/pickers/build.gradle b/android/pickers/build.gradle new file mode 100644 index 00000000000..c96f8bbeb52 --- /dev/null +++ b/android/pickers/build.gradle @@ -0,0 +1,18 @@ +plugins { + id "org.jetbrains.kotlin.multiplatform" + id "org.jetbrains.kotlin.plugin.serialization" + id "com.android.library" + alias(libs.plugins.jb.compose) +} + +apply from: "$mppProjectWithSerializationAndComposePresetPath" + +kotlin { + sourceSets { + androidMain { + dependencies { + api project(":micro_utils.android.smalltextfield") + } + } + } +} diff --git a/android/pickers/src/androidMain/AndroidManifest.xml b/android/pickers/src/androidMain/AndroidManifest.xml new file mode 100644 index 00000000000..f305284765e --- /dev/null +++ b/android/pickers/src/androidMain/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/pickers/src/androidMain/kotlin/Fling.kt b/android/pickers/src/androidMain/kotlin/Fling.kt new file mode 100644 index 00000000000..72eae558909 --- /dev/null +++ b/android/pickers/src/androidMain/kotlin/Fling.kt @@ -0,0 +1,27 @@ +package dev.inmo.micro_utils.android.pickers + +import androidx.compose.animation.core.* + +internal suspend fun Animatable.fling( + initialVelocity: Float, + animationSpec: DecayAnimationSpec, + adjustTarget: ((Float) -> Float)?, + block: (Animatable.() -> Unit)? = null, +): AnimationResult { + val targetValue = animationSpec.calculateTargetValue(value, initialVelocity) + val adjustedTarget = adjustTarget?.invoke(targetValue) + + return if (adjustedTarget != null) { + animateTo( + targetValue = adjustedTarget, + initialVelocity = initialVelocity, + block = block + ) + } else { + animateDecay( + initialVelocity = initialVelocity, + animationSpec = animationSpec, + block = block, + ) + } +} \ No newline at end of file diff --git a/android/pickers/src/androidMain/kotlin/NumberPicker.kt b/android/pickers/src/androidMain/kotlin/NumberPicker.kt new file mode 100644 index 00000000000..4226e1d4168 --- /dev/null +++ b/android/pickers/src/androidMain/kotlin/NumberPicker.kt @@ -0,0 +1,222 @@ +package dev.inmo.micro_utils.android.pickers + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.exponentialDecay +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.ContentAlpha +import androidx.compose.material.IconButton +import androidx.compose.material.ProvideTextStyle +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.center +import androidx.compose.ui.unit.dp +import dev.inmo.micro_utils.android.smalltextfield.SmallTextField +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.absoluteValue +import kotlin.math.roundToInt + +private inline fun PointerInputScope.checkContains(offset: Offset): Boolean { + return ((size.center.x - offset.x).absoluteValue < size.width / 2) && ((size.center.y - offset.y).absoluteValue < size.height / 2) +} + +// src: https://gist.github.com/vganin/a9a84653a9f48a2d669910fbd48e32d5 + +@OptIn(ExperimentalTextApi::class, ExperimentalComposeUiApi::class) +@Composable +fun NumberPicker( + number: Int, + modifier: Modifier = Modifier, + range: IntRange? = null, + textStyle: TextStyle = LocalTextStyle.current, + arrowsColor: Color = MaterialTheme.colorScheme.primary, + allowUseManualInput: Boolean = true, + onStateChanged: (Int) -> Unit = {}, +) { + val coroutineScope = rememberCoroutineScope() + val numbersColumnHeight = 36.dp + val halvedNumbersColumnHeight = numbersColumnHeight / 2 + val halvedNumbersColumnHeightPx = with(LocalDensity.current) { halvedNumbersColumnHeight.toPx() } + + fun animatedStateValue(offset: Float): Int = number - (offset / halvedNumbersColumnHeightPx).toInt() + + val animatedOffset = remember { Animatable(0f) }.apply { + if (range != null) { + val offsetRange = remember(number, range) { + val value = number + val first = -(range.last - value) * halvedNumbersColumnHeightPx + val last = -(range.first - value) * halvedNumbersColumnHeightPx + first..last + } + updateBounds(offsetRange.start, offsetRange.endInclusive) + } + } + val coercedAnimatedOffset = animatedOffset.value % halvedNumbersColumnHeightPx + val animatedStateValue = animatedStateValue(animatedOffset.value) + val disabledArrowsColor = arrowsColor.copy(alpha = ContentAlpha.disabled) + + val inputFieldShown = if (allowUseManualInput) { + remember { mutableStateOf(false) } + } else { + null + } + + Column( + modifier = modifier + .wrapContentSize() + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { deltaY -> + if (inputFieldShown ?.value != true) { + coroutineScope.launch { + animatedOffset.snapTo(animatedOffset.value + deltaY) + } + } + }, + onDragStopped = { velocity -> + if (inputFieldShown ?.value != true) { + coroutineScope.launch { + val endValue = animatedOffset.fling( + initialVelocity = velocity, + animationSpec = exponentialDecay(frictionMultiplier = 20f), + adjustTarget = { target -> + val coercedTarget = target % halvedNumbersColumnHeightPx + val coercedAnchors = + listOf(-halvedNumbersColumnHeightPx, 0f, halvedNumbersColumnHeightPx) + val coercedPoint = coercedAnchors.minByOrNull { abs(it - coercedTarget) }!! + val base = + halvedNumbersColumnHeightPx * (target / halvedNumbersColumnHeightPx).toInt() + coercedPoint + base + } + ).endState.value + + onStateChanged(animatedStateValue(endValue)) + animatedOffset.snapTo(0f) + } + } + } + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val spacing = 4.dp + + val upEnabled = range == null || range.first < number + IconButton( + { + onStateChanged(number - 1) + inputFieldShown ?.value = false + }, + enabled = upEnabled + ) { + Icon(Icons.Default.KeyboardArrowUp, "", tint = if (upEnabled) arrowsColor else disabledArrowsColor) + } + + Spacer(modifier = Modifier.height(spacing)) + Box( + modifier = Modifier + .offset { IntOffset(x = 0, y = coercedAnimatedOffset.roundToInt()) }, + contentAlignment = Alignment.Center + ) { + val baseLabelModifier = Modifier.align(Alignment.Center) + ProvideTextStyle(textStyle) { + Text( + text = (animatedStateValue - 1).toString(), + modifier = baseLabelModifier + .offset(y = -halvedNumbersColumnHeight) + .alpha(coercedAnimatedOffset / halvedNumbersColumnHeightPx) + ) + + if (inputFieldShown ?.value == true) { + val currentValue = remember { mutableStateOf(number.toString()) } + + val focusRequester = remember { FocusRequester() } + SmallTextField( + currentValue.value, + { + val asDigit = it.toIntOrNull() + when { + (asDigit == null && it.isEmpty()) -> currentValue.value = (range ?.first ?: 0).toString() + (asDigit != null && (range == null || asDigit in range)) -> currentValue.value = it + else -> { /* do nothing */ } + } + }, + baseLabelModifier.focusRequester(focusRequester).width(IntrinsicSize.Min).pointerInput(number) { + detectTapGestures { + if (!checkContains(it)) { + currentValue.value.toIntOrNull() ?.let(onStateChanged) + inputFieldShown.value = false + } + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ), + keyboardActions = KeyboardActions { + currentValue.value.toIntOrNull() ?.let(onStateChanged) + inputFieldShown.value = false + }, + singleLine = true, + textStyle = textStyle + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + } else { + Text( + text = animatedStateValue.toString(), + modifier = baseLabelModifier + .alpha(1 - abs(coercedAnimatedOffset) / halvedNumbersColumnHeightPx) + .clickable { + if (inputFieldShown ?.value == false) { + inputFieldShown.value = true + } + } + ) + } + Text( + text = (animatedStateValue + 1).toString(), + modifier = baseLabelModifier + .offset(y = halvedNumbersColumnHeight) + .alpha(-coercedAnimatedOffset / halvedNumbersColumnHeightPx) + ) + } + } + + + Spacer(modifier = Modifier.height(spacing)) + + val downEnabled = range == null || range.last > number + IconButton( + { + onStateChanged(number + 1) + inputFieldShown ?.value = false + }, + enabled = downEnabled + ) { + Icon(Icons.Default.KeyboardArrowDown, "", tint = if (downEnabled) arrowsColor else disabledArrowsColor) + } + } +} + diff --git a/android/pickers/src/androidMain/kotlin/SetPicker.kt b/android/pickers/src/androidMain/kotlin/SetPicker.kt new file mode 100644 index 00000000000..b1cfc9fbebd --- /dev/null +++ b/android/pickers/src/androidMain/kotlin/SetPicker.kt @@ -0,0 +1,156 @@ +package dev.inmo.micro_utils.android.pickers + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.exponentialDecay +import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.ContentAlpha +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlin.math.* + +@OptIn(ExperimentalTextApi::class, ExperimentalComposeUiApi::class) +@Composable +fun SetPicker( + current: T, + dataList: List, + modifier: Modifier = Modifier, + textStyle: TextStyle = LocalTextStyle.current, + arrowsColor: Color = MaterialTheme.colorScheme.primary, + dataToString: @Composable (T) -> String = { it.toString() }, + onStateChanged: (T) -> Unit = {}, +) { + val coroutineScope = rememberCoroutineScope() + val numbersColumnHeight = 8.dp + with(LocalDensity.current) { + textStyle.lineHeight.toDp() + } + val numbersColumnHeightPx = with(LocalDensity.current) { numbersColumnHeight.toPx() } + val halvedNumbersColumnHeight = numbersColumnHeight / 2 + val halvedNumbersColumnHeightPx = with(LocalDensity.current) { halvedNumbersColumnHeight.toPx() } + + val index = dataList.indexOfFirst { it === current }.takeIf { it > -1 } ?: dataList.indexOf(current) + val lastIndex = dataList.size - 1 + + fun animatedStateValue(offset: Float): Int = index - (offset / halvedNumbersColumnHeightPx).toInt() + + val animatedOffset = remember { Animatable(0f) }.apply { + val offsetRange = remember(index, lastIndex) { + val value = index + val first = -(lastIndex - value) * halvedNumbersColumnHeightPx + val last = value * halvedNumbersColumnHeightPx + first..last + } + updateBounds(offsetRange.start, offsetRange.endInclusive) + } + val indexAnimatedOffset = if (animatedOffset.value > 0) { + (index - floor(animatedOffset.value / halvedNumbersColumnHeightPx).toInt()) + } else { + (index - ceil(animatedOffset.value / halvedNumbersColumnHeightPx).toInt()) + } + val coercedAnimatedOffset = animatedOffset.value % halvedNumbersColumnHeightPx + val boxOffset = (indexAnimatedOffset * halvedNumbersColumnHeightPx) - coercedAnimatedOffset + val disabledArrowsColor = arrowsColor.copy(alpha = ContentAlpha.disabled) + val scrollState = rememberScrollState() + + Column( + modifier = modifier + .wrapContentSize() + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { deltaY -> + coroutineScope.launch { + animatedOffset.snapTo(animatedOffset.value + deltaY) + } + }, + onDragStopped = { velocity -> + coroutineScope.launch { + val endValue = animatedOffset.fling( + initialVelocity = velocity, + animationSpec = exponentialDecay(frictionMultiplier = 20f), + adjustTarget = { target -> + val coercedTarget = target % halvedNumbersColumnHeightPx + val coercedAnchors = + listOf(-halvedNumbersColumnHeightPx, 0f, halvedNumbersColumnHeightPx) + val coercedPoint = coercedAnchors.minByOrNull { abs(it - coercedTarget) }!! + val base = + halvedNumbersColumnHeightPx * (target / halvedNumbersColumnHeightPx).toInt() + coercedPoint + base + } + ).endState.value + + onStateChanged(dataList.elementAt(animatedStateValue(endValue))) + animatedOffset.snapTo(0f) + } + } + ), + horizontalAlignment = Alignment.CenterHorizontally + ) { + val spacing = 4.dp + + val upEnabled = index > 0 + IconButton( + { + onStateChanged(dataList.elementAt(index - 1)) + }, + enabled = upEnabled + ) { + Icon(Icons.Default.KeyboardArrowUp, "", tint = if (upEnabled) arrowsColor else disabledArrowsColor) + } + + Spacer(modifier = Modifier.height(spacing)) + Box( + modifier = Modifier, + contentAlignment = Alignment.Center + ) { + ProvideTextStyle(textStyle) { + dataList.forEachIndexed { i, t -> + val alpha = when { + i == indexAnimatedOffset - 1 -> coercedAnimatedOffset / halvedNumbersColumnHeightPx + i == indexAnimatedOffset -> 1 - (abs(coercedAnimatedOffset) / halvedNumbersColumnHeightPx) + i == indexAnimatedOffset + 1 -> -coercedAnimatedOffset / halvedNumbersColumnHeightPx + else -> return@forEachIndexed + } + val offset = when { + i == indexAnimatedOffset - 1 && coercedAnimatedOffset > 0 -> coercedAnimatedOffset - halvedNumbersColumnHeightPx + i == indexAnimatedOffset -> coercedAnimatedOffset + i == indexAnimatedOffset + 1 && coercedAnimatedOffset < 0 -> coercedAnimatedOffset + halvedNumbersColumnHeightPx + else -> return@forEachIndexed + } + Text( + text = dataToString(t), + modifier = Modifier + .alpha(alpha) + .offset(y = with(LocalDensity.current) { offset.toDp() }) + ) + } + } + } + + + Spacer(modifier = Modifier.height(spacing)) + + val downEnabled = index < lastIndex + IconButton( + { + onStateChanged(dataList.elementAt(index + 1)) + }, + enabled = downEnabled + ) { + Icon(Icons.Default.KeyboardArrowDown, "", tint = if (downEnabled) arrowsColor else disabledArrowsColor) + } + } +} diff --git a/android/smalltextfield/build.gradle b/android/smalltextfield/build.gradle new file mode 100644 index 00000000000..43d40ccd762 --- /dev/null +++ b/android/smalltextfield/build.gradle @@ -0,0 +1,18 @@ +plugins { + id "org.jetbrains.kotlin.multiplatform" + id "org.jetbrains.kotlin.plugin.serialization" + id "com.android.library" + alias(libs.plugins.jb.compose) +} + +apply from: "$mppProjectWithSerializationAndComposePresetPath" + +kotlin { + sourceSets { + androidMain { + dependencies { + api libs.android.compose.material3 + } + } + } +} diff --git a/android/smalltextfield/src/androidMain/AndroidManifest.xml b/android/smalltextfield/src/androidMain/AndroidManifest.xml new file mode 100644 index 00000000000..1d8e283be89 --- /dev/null +++ b/android/smalltextfield/src/androidMain/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/smalltextfield/src/androidMain/kotlin/SmallTextField.kt b/android/smalltextfield/src/androidMain/kotlin/SmallTextField.kt new file mode 100644 index 00000000000..3b875a93dae --- /dev/null +++ b/android/smalltextfield/src/androidMain/kotlin/SmallTextField.kt @@ -0,0 +1,66 @@ +package dev.inmo.micro_utils.android.smalltextfield + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.VisualTransformation + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SmallTextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + textColor: Color = textStyle.color.takeOrElse { + LocalContentColor.current + }, + visualTransformation: VisualTransformation = VisualTransformation.None, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + singleLine: Boolean = false, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + minLines: Int = 1, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + BasicTextField( + value = value, + modifier = modifier, + onValueChange = onValueChange, + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle.copy( + color = textColor + ), + visualTransformation = visualTransformation, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + interactionSource = interactionSource, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + cursorBrush = SolidColor( + textStyle.color.takeOrElse { + LocalContentColor.current + } + ) + ) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3960ed514bd..6afe972fe56 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,7 @@ android-appCompat = "1.6.1" android-fragment = "1.6.1" android-espresso = "3.5.1" android-test = "1.1.5" +android-compose-material3 = "1.1.1" android-props-minSdk = "21" android-props-compileSdk = "33" @@ -83,6 +84,7 @@ jb-exposed = { module = "org.jetbrains.exposed:exposed-core", version.ref = "jb- android-coreKtx = { module = "androidx.core:core-ktx", version.ref = "android-coreKtx" } android-recyclerView = { module = "androidx.recyclerview:recyclerview", version.ref = "android-recyclerView" } android-appCompat-resources = { module = "androidx.appcompat:appcompat-resources", version.ref = "android-appCompat" } +android-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "android-compose-material3" } android-fragment = { module = "androidx.fragment:fragment", version.ref = "android-fragment" } android-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "android-espresso" } android-test-junit = { module = "androidx.test.ext:junit", version.ref = "android-test" } diff --git a/settings.gradle b/settings.gradle index ec21091c550..2fe83ceb97b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -32,6 +32,8 @@ String[] includes = [ ":coroutines", ":coroutines:compose", ":android:recyclerview", + ":android:pickers", + ":android:smalltextfield", ":android:alerts:common", ":android:alerts:recyclerview", ":serialization:base64", From ce7d4fe9a20dbf8334dde2ea60cb5676433ee27a Mon Sep 17 00:00:00 2001 From: InsanusMokrassar Date: Sat, 12 Aug 2023 22:08:12 +0600 Subject: [PATCH 3/4] fix of #293 --- CHANGELOG.md | 3 + .../micro_utils/coroutines/SmartRWLocker.kt | 102 +++++++++++++ .../micro_utils/coroutines/SmartSemaphore.kt | 142 ++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartRWLocker.kt create mode 100644 coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartSemaphore.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c5c560375a..4f0288f92a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ * Module is initialized * `Pickers`: * Module is initialized +* `Coroutines`: + * Add `SmartSemaphore` + * Add `SmartRWLocker` ## 0.20.0 diff --git a/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartRWLocker.kt b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartRWLocker.kt new file mode 100644 index 00000000000..0e79aca6f21 --- /dev/null +++ b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartRWLocker.kt @@ -0,0 +1,102 @@ +package dev.inmo.micro_utils.coroutines + +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +/** + * Composite mutex which works with next rules: + * + * * [acquireRead] require to [writeMutex] be free. Then it will take one lock from [readSemaphore] + * * [releaseRead] will just free up one permit in [readSemaphore] + * * [lockWrite] will lock [writeMutex] and then await while all [readSemaphore] will be freed + * * [unlockWrite] will just unlock [writeMutex] + */ +class SmartRWLocker(private val readPermits: Int = Int.MAX_VALUE) { + private val _readSemaphore = SmartSemaphore.Mutable(permits = readPermits, acquiredPermits = 0) + private val _writeMutex = SmartMutex.Mutable(locked = false) + + val readSemaphore: SmartSemaphore.Immutable = _readSemaphore.immutable() + val writeMutex: SmartMutex.Immutable = _writeMutex.immutable() + + /** + * Do lock in [readSemaphore] inside of [writeMutex] locking + */ + suspend fun acquireRead() { + _writeMutex.withLock { + _readSemaphore.acquire() + } + } + + /** + * Release one read permit in [readSemaphore] + */ + suspend fun releaseRead(): Boolean { + return _readSemaphore.release() + } + + /** + * Locking [writeMutex] and wait while all [readSemaphore] permits will be freed + */ + suspend fun lockWrite() { + _writeMutex.lock() + readSemaphore.waitRelease(readPermits) + } + + /** + * Unlock [writeMutex] + */ + suspend fun unlockWrite(): Boolean { + return _writeMutex.unlock() + } +} + +/** + * Will call [SmartSemaphore.Mutable.lock], then execute [action] and return the result after [SmartSemaphore.Mutable.unlock] + */ +@OptIn(ExperimentalContracts::class) +suspend inline fun SmartRWLocker.withReadAcquire(action: () -> T): T { + contract { + callsInPlace(action, InvocationKind.EXACTLY_ONCE) + } + + acquireRead() + try { + return action() + } finally { + releaseRead() + } +} + +/** + * Will wait until the [SmartSemaphore.permitsStateFlow] of [this] instance will have [permits] count free permits. + * + * Anyway, after the end of this block there are no any guaranties that [SmartSemaphore.freePermits] >= [permits] due to + * the fact that some other parties may lock it again + */ +suspend fun SmartRWLocker.waitReadRelease(permits: Int = 1) = readSemaphore.waitRelease(permits) + +/** + * Will call [SmartMutex.Mutable.lock], then execute [action] and return the result after [SmartMutex.Mutable.unlock] + */ +@OptIn(ExperimentalContracts::class) +suspend inline fun SmartRWLocker.withWriteLock(action: () -> T): T { + contract { + callsInPlace(action, InvocationKind.EXACTLY_ONCE) + } + + lockWrite() + try { + return action() + } finally { + unlockWrite() + } +} + +/** + * Will wait until the [SmartMutex.lockStateFlow] of [this] instance will be false. + * + * Anyway, after the end of this block there are no any guaranties that [SmartMutex.isLocked] == false due to the fact + * that some other parties may lock it again + */ +suspend fun SmartRWLocker.waitWriteUnlock() = writeMutex.waitUnlock() diff --git a/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartSemaphore.kt b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartSemaphore.kt new file mode 100644 index 00000000000..bfb29feb599 --- /dev/null +++ b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartSemaphore.kt @@ -0,0 +1,142 @@ +package dev.inmo.micro_utils.coroutines + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +/** + * It is interface which will work like classic [Semaphore], but in difference have [permitsStateFlow] for listening of the + * [SmartSemaphore] state. + * + * There is [Mutable] and [Immutable] realizations. In case you are owner and manager current state of lock, you need + * [Mutable] [SmartSemaphore]. Otherwise, [Immutable]. + * + * Any [Mutable] [SmartSemaphore] may produce its [Immutable] variant which will contains [permitsStateFlow] equal to its + * [Mutable] creator + */ +sealed interface SmartSemaphore { + val permitsStateFlow: StateFlow + + /** + * * True - locked + * * False - unlocked + */ + val freePermits: Int + get() = permitsStateFlow.value + + /** + * Immutable variant of [SmartSemaphore]. In fact will depend on the owner of [permitsStateFlow] + */ + class Immutable(override val permitsStateFlow: StateFlow) : SmartSemaphore + + /** + * Mutable variant of [SmartSemaphore]. With that variant you may [lock] and [unlock]. Besides, you may create + * [Immutable] variant of [this] instance with [immutable] factory + * + * @param locked Preset state of [freePermits] and its internal [_permitsStateFlow] + */ + class Mutable(private val permits: Int, acquiredPermits: Int = 0) : SmartSemaphore { + private val _permitsStateFlow = MutableStateFlow(permits - acquiredPermits) + override val permitsStateFlow: StateFlow = _permitsStateFlow.asStateFlow() + + private val internalChangesMutex = Mutex(false) + + fun immutable() = Immutable(permitsStateFlow) + + private fun checkedPermits(permits: Int) = permits.coerceIn(1 .. this.permits) + + /** + * Holds call until this [SmartSemaphore] will be re-locked. That means that while [freePermits] == true, [holds] will + * wait for [freePermits] == false and then try to lock + */ + suspend fun acquire(permits: Int = 1) { + do { + val checkedPermits = checkedPermits(permits) + waitRelease(checkedPermits) + val shouldContinue = internalChangesMutex.withLock { + if (_permitsStateFlow.value < checkedPermits) { + true + } else { + _permitsStateFlow.value -= checkedPermits + false + } + } + } while (shouldContinue && currentCoroutineContext().isActive) + } + + /** + * Will try to lock this [SmartSemaphore] immediataly + * + * @return True if lock was successful. False otherwise + */ + suspend fun tryAcquire(permits: Int = 1): Boolean { + val checkedPermits = checkedPermits(permits) + return if (_permitsStateFlow.value >= checkedPermits) { + internalChangesMutex.withLock { + if (_permitsStateFlow.value >= checkedPermits) { + _permitsStateFlow.value -= checkedPermits + true + } else { + false + } + } + } else { + false + } + } + + /** + * If [freePermits] == true - will change it to false and return true. If current call will not unlock this + * [SmartSemaphore] - false + */ + suspend fun release(permits: Int = 1): Boolean { + val checkedPermits = checkedPermits(permits) + return if (this.permits - _permitsStateFlow.value > checkedPermits) { + internalChangesMutex.withLock { + if (this.permits - _permitsStateFlow.value > checkedPermits) { + _permitsStateFlow.value += checkedPermits + true + } else { + false + } + } + } else { + false + } + } + } +} + +/** + * Will call [SmartSemaphore.Mutable.lock], then execute [action] and return the result after [SmartSemaphore.Mutable.unlock] + */ +@OptIn(ExperimentalContracts::class) +suspend inline fun SmartSemaphore.Mutable.withAcquire(permits: Int = 1, action: () -> T): T { + contract { + callsInPlace(action, InvocationKind.EXACTLY_ONCE) + } + + acquire(permits) + try { + return action() + } finally { + release(permits) + } +} + +/** + * Will wait until the [SmartSemaphore.permitsStateFlow] of [this] instance will have [permits] count free permits. + * + * Anyway, after the end of this block there are no any guaranties that [SmartSemaphore.freePermits] >= [permits] due to + * the fact that some other parties may lock it again + */ +suspend fun SmartSemaphore.waitRelease(permits: Int = 1) = permitsStateFlow.first { it >= permits } From 6ce1eb3f2da9c3df200372c5235c9aa0e38e1a39 Mon Sep 17 00:00:00 2001 From: InsanusMokrassar Date: Sat, 12 Aug 2023 22:37:35 +0600 Subject: [PATCH 4/4] add tests for smartrwlocker --- .../micro_utils/coroutines/SmartRWLocker.kt | 4 +- .../micro_utils/coroutines/SmartSemaphore.kt | 10 ++-- .../commonTest/kotlin/SmartRWLockerTests.kt | 60 +++++++++++++++++++ mppAndroidProject.gradle | 1 + mppJavaProject.gradle | 1 + mppJvmJsLinuxMingwProject.gradle | 1 + mppProjectWithSerialization.gradle | 1 + mppProjectWithSerializationAndCompose.gradle | 1 + 8 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 coroutines/src/commonTest/kotlin/SmartRWLockerTests.kt diff --git a/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartRWLocker.kt b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartRWLocker.kt index 0e79aca6f21..2ca18d8d4ed 100644 --- a/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartRWLocker.kt +++ b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartRWLocker.kt @@ -12,9 +12,9 @@ import kotlin.contracts.contract * * [lockWrite] will lock [writeMutex] and then await while all [readSemaphore] will be freed * * [unlockWrite] will just unlock [writeMutex] */ -class SmartRWLocker(private val readPermits: Int = Int.MAX_VALUE) { +class SmartRWLocker(private val readPermits: Int = Int.MAX_VALUE, writeIsLocked: Boolean = false) { private val _readSemaphore = SmartSemaphore.Mutable(permits = readPermits, acquiredPermits = 0) - private val _writeMutex = SmartMutex.Mutable(locked = false) + private val _writeMutex = SmartMutex.Mutable(locked = writeIsLocked) val readSemaphore: SmartSemaphore.Immutable = _readSemaphore.immutable() val writeMutex: SmartMutex.Immutable = _writeMutex.immutable() diff --git a/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartSemaphore.kt b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartSemaphore.kt index bfb29feb599..fd072f1e415 100644 --- a/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartSemaphore.kt +++ b/coroutines/src/commonMain/kotlin/dev/inmo/micro_utils/coroutines/SmartSemaphore.kt @@ -80,9 +80,9 @@ sealed interface SmartSemaphore { */ suspend fun tryAcquire(permits: Int = 1): Boolean { val checkedPermits = checkedPermits(permits) - return if (_permitsStateFlow.value >= checkedPermits) { + return if (_permitsStateFlow.value < checkedPermits) { internalChangesMutex.withLock { - if (_permitsStateFlow.value >= checkedPermits) { + if (_permitsStateFlow.value < checkedPermits) { _permitsStateFlow.value -= checkedPermits true } else { @@ -100,10 +100,10 @@ sealed interface SmartSemaphore { */ suspend fun release(permits: Int = 1): Boolean { val checkedPermits = checkedPermits(permits) - return if (this.permits - _permitsStateFlow.value > checkedPermits) { + return if (_permitsStateFlow.value < this.permits) { internalChangesMutex.withLock { - if (this.permits - _permitsStateFlow.value > checkedPermits) { - _permitsStateFlow.value += checkedPermits + if (_permitsStateFlow.value < this.permits) { + _permitsStateFlow.value = minOf(_permitsStateFlow.value + checkedPermits, this.permits) true } else { false diff --git a/coroutines/src/commonTest/kotlin/SmartRWLockerTests.kt b/coroutines/src/commonTest/kotlin/SmartRWLockerTests.kt new file mode 100644 index 00000000000..cef0ff93526 --- /dev/null +++ b/coroutines/src/commonTest/kotlin/SmartRWLockerTests.kt @@ -0,0 +1,60 @@ +import dev.inmo.micro_utils.coroutines.SmartRWLocker +import dev.inmo.micro_utils.coroutines.withReadAcquire +import dev.inmo.micro_utils.coroutines.withWriteLock +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.delay +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SmartRWLockerTests { + @Test + fun compositeTest() { + val locker = SmartRWLocker() + + val readAndWriteWorkers = 10 + runTest { + var started = 0 + var done = 0 + val doneMutex = Mutex() + val readWorkers = (0 until readAndWriteWorkers).map { + launch(start = CoroutineStart.LAZY) { + locker.withReadAcquire { + doneMutex.withLock { + started++ + } + delay(100L) + doneMutex.withLock { + done++ + } + } + } + } + + var doneWrites = 0 + + val writeWorkers = (0 until readAndWriteWorkers).map { + launch(start = CoroutineStart.LAZY) { + locker.withWriteLock { + assertTrue(done == readAndWriteWorkers || started == 0) + delay(10L) + doneWrites++ + } + } + } + readWorkers.forEach { it.start() } + writeWorkers.forEach { it.start() } + + readWorkers.joinAll() + writeWorkers.joinAll() + + assertEquals(expected = readAndWriteWorkers, actual = done) + assertEquals(expected = readAndWriteWorkers, actual = doneWrites) + } + } +} diff --git a/mppAndroidProject.gradle b/mppAndroidProject.gradle index 1817e67049d..7bd45386817 100644 --- a/mppAndroidProject.gradle +++ b/mppAndroidProject.gradle @@ -18,6 +18,7 @@ kotlin { dependencies { implementation kotlin('test-common') implementation kotlin('test-annotations-common') + implementation libs.kt.coroutines.test } } } diff --git a/mppJavaProject.gradle b/mppJavaProject.gradle index 5f6d62de0cb..4d2f2b6d5e4 100644 --- a/mppJavaProject.gradle +++ b/mppJavaProject.gradle @@ -22,6 +22,7 @@ kotlin { dependencies { implementation kotlin('test-common') implementation kotlin('test-annotations-common') + implementation libs.kt.coroutines.test } } diff --git a/mppJvmJsLinuxMingwProject.gradle b/mppJvmJsLinuxMingwProject.gradle index 1a5b08c7de5..a38f9435da1 100644 --- a/mppJvmJsLinuxMingwProject.gradle +++ b/mppJvmJsLinuxMingwProject.gradle @@ -28,6 +28,7 @@ kotlin { dependencies { implementation kotlin('test-common') implementation kotlin('test-annotations-common') + implementation libs.kt.coroutines.test } } diff --git a/mppProjectWithSerialization.gradle b/mppProjectWithSerialization.gradle index 8d0239816e5..2ed6bff423d 100644 --- a/mppProjectWithSerialization.gradle +++ b/mppProjectWithSerialization.gradle @@ -32,6 +32,7 @@ kotlin { dependencies { implementation kotlin('test-common') implementation kotlin('test-annotations-common') + implementation libs.kt.coroutines.test } } jvmTest { diff --git a/mppProjectWithSerializationAndCompose.gradle b/mppProjectWithSerializationAndCompose.gradle index e7b75fbd456..d05dcb9f3b6 100644 --- a/mppProjectWithSerializationAndCompose.gradle +++ b/mppProjectWithSerializationAndCompose.gradle @@ -31,6 +31,7 @@ kotlin { dependencies { implementation kotlin('test-common') implementation kotlin('test-annotations-common') + implementation libs.kt.coroutines.test } } jvmMain {