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",