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