Initial implementation

This commit is contained in:
Gergely Hegedus 2022-06-14 10:24:02 +03:00
parent 256c243ce0
commit 0b4fa9ece7
69 changed files with 2561 additions and 0 deletions

1
picker/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

51
picker/build.gradle Normal file
View file

@ -0,0 +1,51 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
id 'kotlin-kapt'
}
android {
namespace 'org.fknives.android.compose.picker'
compileSdk defaultCompileSdkVersion
defaultConfig {
minSdk defaultMinSdkVersion
targetSdk defaultTargetSdkVersion
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles "consumer-rules.pro"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion composeKotlinCompilerExtensionVersion
}
compileOptions {
sourceCompatibility compileCompatibility
targetCompatibility compileCompatibility
}
kotlinOptions {
jvmTarget = kotlinJvmTarget
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutine_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutine_version"
api "androidx.compose.material:material:$compose_version"
api "androidx.compose.animation:animation:$compose_version"
implementation "androidx.compose.ui:ui-tooling:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
}

View file

21
picker/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,24 @@
package org.fknives.android.compose.picker
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("org.fknives.android.compose.picker.test", appContext.packageName)
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View file

@ -0,0 +1,53 @@
package org.fknives.android.compose.picker.number
import androidx.compose.material.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import org.fknives.android.compose.picker.text.util.TextPickerDefaults
import org.fknives.android.compose.picker.text.TextPickerState
@Composable
fun NumberPicker(
modifier: Modifier = Modifier,
config: NumberPickerConfig,
selectedValue: Int,
onSelectedChange: (Int) -> Unit,
onIndexDifferenceChanging: (Int) -> Unit = TextPickerDefaults.onIndexDifferenceChanging,
textStyle: TextStyle = LocalTextStyle.current,
roundAround: Boolean = TextPickerDefaults.roundAround,
) {
NumberPicker(
config = config,
selectedValue = selectedValue,
onSelectedChange = onSelectedChange,
onIndexDifferenceChanging = onIndexDifferenceChanging
) {
CustomInnerTextPicker(
modifier = modifier,
textStyle = textStyle,
roundAround = roundAround
)
}
}
@Composable
fun NumberPicker(
config: NumberPickerConfig,
selectedValue: Int,
onSelectedChange: (Int) -> Unit,
onIndexDifferenceChanging: (Int) -> Unit = TextPickerDefaults.onIndexDifferenceChanging,
state: TextPickerState = rememberNumberPickerState(selectedValue = selectedValue, config = config),
timePicker: @Composable NumberPickerScope.() -> Unit = { StandardInnerTextPicker() }
) {
require(selectedValue >= config.minimum) { "Selected Value($selectedValue) is less than Minimum (${config.minimum})!" }
require(selectedValue <= config.maximum) { "Selected Value($selectedValue) is more than Maximum (${config.maximum})!" }
val numberPickerScope = rememberNumberPickerScope(
state,
config,
onIndexDifferenceChanging,
onSelectedChange
)
timePicker(numberPickerScope)
}

View file

@ -0,0 +1,36 @@
package org.fknives.android.compose.picker.number
import androidx.compose.runtime.Immutable
@Immutable
data class NumberPickerConfig(
val maximum: Int,
val minimum: Int = 0,
val reversedOrder: Boolean = false,
val skipInBetween: Int = 0
) {
init {
require(skipInBetween >= 0) { "Skip In Between cannot be negative!" }
}
companion object {
val configMinutePicker get() = NumberPickerConfig(
minimum = 0,
maximum = 59,
reversedOrder = false,
skipInBetween = 0
)
val configHourPicker24 get() = NumberPickerConfig(
minimum = 0,
maximum = 23,
reversedOrder = false,
skipInBetween = 0
)
val configHourPicker12 get() = NumberPickerConfig(
minimum = 1,
maximum = 12,
reversedOrder = false,
skipInBetween = 0
)
}
}

View file

@ -0,0 +1,49 @@
package org.fknives.android.compose.picker.number
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import org.fknives.android.compose.picker.text.util.TextPickerDefaults
import org.fknives.android.compose.picker.text.TextPickerState
@Immutable
data class NumberPickerScopeImpl(
override val state: TextPickerState,
override val onIndexDifferenceChanging: (Int) -> Unit,
override val onSelectedIndexChange: (Int) -> Unit,
override val textForIndex: (Int) -> String,
) : NumberPickerScope {
override val selectedIndex: Int get() = state.selected
override val itemCount: Int get() = state.itemCount
}
@Composable
fun rememberNumberPickerScope(
state: TextPickerState,
config: NumberPickerConfig,
onSelectedChange: (Int) -> Unit,
onIndexDifferenceChanging: (Int) -> Unit = TextPickerDefaults.onIndexDifferenceChanging,
vararg keys: Any?
): NumberPickerScope {
val indexToNumber = rememberNumberPickerIndexToNumber(config = config)
return remember(state, onIndexDifferenceChanging, config, keys) {
NumberPickerScopeImpl(
state = state,
textForIndex = { "${indexToNumber(it)}" },
onIndexDifferenceChanging = onIndexDifferenceChanging,
onSelectedIndexChange = { onSelectedChange(indexToNumber(it)) }
)
}
}
@Immutable
interface NumberPickerScope {
val state: TextPickerState
val onIndexDifferenceChanging: (Int) -> Unit
val onSelectedIndexChange: (Int) -> Unit
val textForIndex: (Int) -> String
val selectedIndex: Int get() = state.selected
val itemCount: Int get() = state.itemCount
}

View file

@ -0,0 +1,55 @@
package org.fknives.android.compose.picker.number
import androidx.compose.material.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import org.fknives.android.compose.picker.text.LinedTextPicker
import org.fknives.android.compose.picker.text.TextPicker
import org.fknives.android.compose.picker.text.util.TextPickerDefaults
@Composable
fun NumberPickerScope.CustomInnerTextPicker(
modifier: Modifier = Modifier,
textStyle: TextStyle = LocalTextStyle.current,
roundAround: Boolean = TextPickerDefaults.roundAround
) {
TextPicker(
modifier = modifier,
textStyle = textStyle,
roundAround = roundAround,
textForIndex = textForIndex,
itemCount = itemCount,
selectedIndex = selectedIndex,
onSelectedIndexChange = onSelectedIndexChange,
onIndexDifferenceChanging = onIndexDifferenceChanging,
state = state
)
}
@Composable
fun NumberPickerScope.StandardInnerTextPicker(modifier: Modifier = Modifier) {
TextPicker(
modifier = modifier,
textForIndex = textForIndex,
itemCount = itemCount,
selectedIndex = selectedIndex,
onSelectedIndexChange = onSelectedIndexChange,
onIndexDifferenceChanging = onIndexDifferenceChanging,
state = state
)
}
@Composable
fun NumberPickerScope.LinedInnerTextPicker(modifier: Modifier = Modifier) {
LinedTextPicker(
modifier = modifier,
textForIndex = textForIndex,
itemCount = itemCount,
selected = selectedIndex,
onSelectedChange = onSelectedIndexChange,
onIndexChanging = onIndexDifferenceChanging,
state = state
)
}

View file

@ -0,0 +1,36 @@
package org.fknives.android.compose.picker.number
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import org.fknives.android.compose.picker.text.TextPickerState
import org.fknives.android.compose.picker.text.util.rememberTextPickerState
@Composable
fun rememberNumberPickerIndexToNumber(config: NumberPickerConfig): (Int) -> Int =
remember(config) {
if (config.reversedOrder) {
{ index: Int ->
config.maximum - index - config.skipInBetween * index
}
} else {
{ index: Int ->
config.minimum + index + config.skipInBetween * index
}
}
}
private fun valueToIndex(config: NumberPickerConfig, value: Int): Int =
if (config.reversedOrder) {
(config.maximum - value) / (config.skipInBetween + 1)
} else {
(value - config.minimum) / (config.skipInBetween + 1)
}
@Composable
fun rememberNumberPickerState(selectedValue: Int, config: NumberPickerConfig): TextPickerState {
val selected = valueToIndex(value = selectedValue, config = config)
val itemCount = (config.maximum - config.minimum) / (config.skipInBetween + 1) + 1
return rememberTextPickerState(selected = selected, itemCount = itemCount)
}

View file

@ -0,0 +1,44 @@
package org.fknives.android.compose.picker.text
import androidx.compose.material.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.text.TextStyle
import org.fknives.android.compose.picker.text.content.*
import org.fknives.android.compose.picker.text.util.TextPickerDefaults
import org.fknives.android.compose.picker.text.util.rememberTextPickerAnimator
import org.fknives.android.compose.picker.text.util.rememberTextPickerState
@Composable
fun LinedTextPicker(
modifier: Modifier = Modifier,
textForIndex: (Int) -> String,
itemCount: Int,
selected: Int,
onSelectedChange: (Int) -> Unit,
textStyle: TextStyle = LocalTextStyle.current,
roundAround: Boolean = TextPickerDefaults.roundAround,
onIndexChanging: (Int) -> Unit = TextPickerDefaults.onIndexDifferenceChanging,
state: TextPickerState = rememberTextPickerState(selected = selected, itemCount = itemCount),
animator: TextPickerAnimator = rememberTextPickerAnimator(roundAround = roundAround, onIndexDifferenceChanging = onIndexChanging),
textPickerContent: TextPickerContent = LinedTextPickerContent(),
textPickerMeasurePolicy: MeasurePolicy = remember(state) { LinedTextPickerMeasurePolicy(state) },
pickerItem: @Composable (text: String, translation: Float) -> Unit = defaultNumberPickerTextAlphaModifier()
) =
TextPicker(
modifier = modifier,
textForIndex = textForIndex,
itemCount = itemCount,
selectedIndex = selected,
onSelectedIndexChange = onSelectedChange,
textStyle = textStyle,
roundAround = roundAround,
onIndexDifferenceChanging = onIndexChanging,
state = state,
animator = animator,
textPickerContent = textPickerContent,
textPickerMeasurePolicy = textPickerMeasurePolicy,
pickerItem = pickerItem,
)

View file

@ -0,0 +1,98 @@
package org.fknives.android.compose.picker.text
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.ProvideTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.text.TextStyle
import org.fknives.android.compose.picker.text.content.DefaultTextPickerContent
import org.fknives.android.compose.picker.text.content.DefaultTextPickerMeasurePolicy
import org.fknives.android.compose.picker.text.content.defaultNumberPickerTextAlphaModifier
import org.fknives.android.compose.picker.text.util.TextPickerDefaults
import org.fknives.android.compose.picker.text.util.rememberDefaultMoveUnsafeToProperIndex
import org.fknives.android.compose.picker.text.util.rememberTextPickerAnimator
import org.fknives.android.compose.picker.text.util.rememberTextPickerState
import org.fknives.android.compose.picker.text.util.rememberWrappedTextForIndex
@Composable
fun TextPicker(
modifier: Modifier = Modifier,
textForIndex: (Int) -> String,
itemCount: Int,
selectedIndex: Int,
onSelectedIndexChange: (Int) -> Unit,
textStyle: TextStyle = LocalTextStyle.current,
roundAround: Boolean = TextPickerDefaults.roundAround,
onIndexDifferenceChanging: (Int) -> Unit = TextPickerDefaults.onIndexDifferenceChanging,
state: TextPickerState = rememberTextPickerState(selected = selectedIndex, itemCount = itemCount),
animator: TextPickerAnimator = rememberTextPickerAnimator(roundAround = roundAround, onIndexDifferenceChanging = onIndexDifferenceChanging),
textPickerContent: TextPickerContent = DefaultTextPickerContent(),
textPickerMeasurePolicy: MeasurePolicy = remember(state) { DefaultTextPickerMeasurePolicy(state) },
pickerItem: @Composable (text: String, translation: Float) -> Unit = defaultNumberPickerTextAlphaModifier()
) {
val moveUnsafeToProperIndex: (Int) -> Int = rememberDefaultMoveUnsafeToProperIndex(itemCount = itemCount, roundAround = roundAround)
val rememberTextForIndex = rememberWrappedTextForIndex(itemCount = itemCount, roundAround = roundAround, textForIndex = textForIndex)
Layout(
modifier = modifier
.clipToBounds()
.draggable(
orientation = Orientation.Vertical,
onDragStopped = { velocity ->
animator.flingAndSnap(this, state, velocity) { change ->
val index = moveUnsafeToProperIndex(selectedIndex + change)
state.onPreviouslySelectedChange(index)
onSelectedIndexChange(moveUnsafeToProperIndex(selectedIndex + change))
}
},
state = rememberDraggableState {
animator.onDeltaY(state, it)
}
),
content = {
if (state.previousSelected != selectedIndex) {
LaunchedEffect(selectedIndex) {
animator.snapToIndex(itemCount = itemCount, state = state)
state.onPreviouslySelectedChange(selectedIndex)
}
}
ProvideTextStyle(textStyle) {
textPickerContent.Content(
textForIndex = rememberTextForIndex,
item = pickerItem,
moveUnsafeToProperIndex = moveUnsafeToProperIndex,
selected = moveUnsafeToProperIndex(selectedIndex + state.indexOffset),
before1TranslatePercent = state.before1TranslatePercent,
itemTranslatePercent = state.itemTranslatePercent,
after1TranslatePercent = state.after1TranslatePercent,
after2TranslatePercent = state.after2TranslatePercent
)
}
},
measurePolicy = textPickerMeasurePolicy
)
}
fun interface TextPickerContent {
@Composable
fun Content(
textForIndex: (Int) -> String,
selected: Int,
moveUnsafeToProperIndex: (Int) -> Int,
before1TranslatePercent: Float,
itemTranslatePercent: Float,
after1TranslatePercent: Float,
after2TranslatePercent: Float,
item: @Composable (text: String, translation: Float) -> Unit
)
}

View file

@ -0,0 +1,117 @@
package org.fknives.android.compose.picker.text
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.DecayAnimationSpec
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.calculateTargetValue
import androidx.compose.animation.core.spring
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.fknives.android.compose.picker.text.util.OffsetLimiter
import org.fknives.android.compose.picker.text.util.TextPickerDefaults
import kotlin.math.abs
import kotlin.math.roundToInt
class TextPickerAnimator(
private val flingDecaySpec: DecayAnimationSpec<Float>,
private val flingAnimationSpec: AnimationSpec<Float> = spring(0.75f),
private val velocityMultiplier: Float,
private val offsetLimiter: OffsetLimiter,
private val onIndexDifferenceChanging: (Int) -> Unit = TextPickerDefaults.onIndexDifferenceChanging
) {
private var animationJob by mutableStateOf<Job?>(null)
fun onDeltaY(state: TextPickerState, deltaY: Float) {
modifyOffset(state, state.offset + deltaY)
}
suspend fun snapToIndex(itemCount: Int, state: TextPickerState) {
if (state.previousSelected == state.selected) return
val scrollingIndexChange = findIndexChangeForProperScrollingAnimation(
fromIndex = state.previousSelected,
toIndex = state.selected,
itemCount = itemCount
)
val offset = scrollingIndexChange * state.itemSize
animateOffset(initialValue = offset, targetValue = 0f, state = state)
}
fun flingAndSnap(scope: CoroutineScope, state: TextPickerState, rawVelocity: Float, onIndexChange: (Int) -> Unit) {
val velocity = rawVelocity * velocityMultiplier
val flingOffsetValue = flingDecaySpec.calculateTargetValue(0f, velocity)
val endFlingValue = state.offset + flingOffsetValue
val limitedEndFlingValue = offsetLimiter.limit(endFlingValue, state)
val changeIndex = -(limitedEndFlingValue / state.itemSize).roundToInt()
val offsetTarget = -changeIndex * state.itemSize
animationJob?.cancel()
animationJob = scope.launch {
animateOffset(initialValue = state.offset, targetValue = offsetTarget, velocity = velocity, state = state)
onIndexChange(changeIndex)
modifyOffset(state, 0f)
}
}
private suspend fun animateOffset(state: TextPickerState, initialValue: Float, targetValue: Float, velocity: Float = 0f) {
animate(
initialValue = initialValue,
targetValue = targetValue,
animationSpec = flingAnimationSpec,
initialVelocity = velocity
) { value, _ ->
modifyOffset(state, value)
}
}
private fun modifyOffset(state: TextPickerState, offset: Float) {
val animationLimitedOffset = offsetLimiter.overshootLimit(offset, state)
state.onOffsetChange(animationLimitedOffset)
if (onIndexDifferenceChanging !== TextPickerDefaults.onIndexDifferenceChanging) {
val hardLimitedOffset = offsetLimiter.limit(offset, state)
val fullIndex = (-hardLimitedOffset / state.itemSize).toInt()
val halfIndexChange = ((-hardLimitedOffset - fullIndex * state.itemSize)*2 / state.itemSize).toInt()
val index = halfIndexChange + fullIndex
onIndexDifferenceChanging(index)
}
}
companion object {
private fun findIndexChangeForProperScrollingAnimation(fromIndex: Int, toIndex: Int, itemCount: Int): Int {
val indexDifference = toIndex - fromIndex
return if (shouldNotScrollByDistance(indexDifference = indexDifference)) {
0
} else if (shouldScrollUpByDistance(indexDifference = indexDifference, itemCount = itemCount)) {
calculateIndexChangeScrollingUp(indexDifference = indexDifference, itemCount = itemCount)
} else {
calculateIndexChangeScrollingDown(indexDifference = indexDifference, itemCount = itemCount)
}
}
private fun shouldNotScrollByDistance(indexDifference: Int) = indexDifference == 0
private fun calculateIndexChangeScrollingUp(indexDifference: Int, itemCount: Int): Int =
if (indexDifference < 0) {
indexDifference
} else {
indexDifference - itemCount
}
private fun calculateIndexChangeScrollingDown(indexDifference: Int, itemCount: Int): Int =
if (indexDifference > 0) {
indexDifference
} else {
indexDifference + itemCount
}
private fun shouldScrollUpByDistance(indexDifference: Int, itemCount: Int): Boolean {
val scrollUpIndexDifference = calculateIndexChangeScrollingUp(indexDifference = indexDifference, itemCount = itemCount)
val scrollUpDistance = abs(scrollUpIndexDifference)
return scrollUpDistance < itemCount / 2
}
}
}

View file

@ -0,0 +1,48 @@
package org.fknives.android.compose.picker.text
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class TextPickerState(
itemSizeState: MutableState<Float>,
previousSelectedState: MutableState<Int>,
val selected: Int,
val itemCount: Int
) {
var offset by mutableStateOf(0f)
private set
var itemSize by itemSizeState
private set
var previousSelected by previousSelectedState
private set
val translateOffset by derivedStateOf {
(offset % itemSize) - (animationShiftOffset * itemSize)
}
val indexOffset by derivedStateOf {
(-offset / itemSize).toInt() - animationShiftOffset
}
private val animationShiftOffset get() = if (offset > 0) 1 else 0
val itemTranslatePercent by derivedStateOf { translateOffset / itemSize / 2f + 1f }
val before1TranslatePercent by derivedStateOf { itemTranslatePercent - 0.5f }
val after1TranslatePercent by derivedStateOf { 1.5f - itemTranslatePercent }
val after2TranslatePercent by derivedStateOf { 1f - itemTranslatePercent }
fun onItemSizeChange(itemSize: Float) {
this.itemSize = itemSize
}
fun onPreviouslySelectedChange(index: Int) {
previousSelected = index
}
fun onOffsetChange(offset: Float) {
this.offset = offset
}
}

View file

@ -0,0 +1,39 @@
package org.fknives.android.compose.picker.text.content
import androidx.compose.runtime.Composable
import org.fknives.android.compose.picker.text.TextPickerContent
class DefaultTextPickerContent : TextPickerContent {
@Composable
override fun Content(
textForIndex: (Int) -> String,
selected: Int,
moveUnsafeToProperIndex: (Int) -> Int,
before1TranslatePercent: Float,
itemTranslatePercent: Float,
after1TranslatePercent: Float,
after2TranslatePercent: Float,
item: @Composable (text: String, translation: Float) -> Unit
) {
val before1 = moveUnsafeToProperIndex(selected - 1)
val after1 = moveUnsafeToProperIndex(selected + 1)
val after2 = moveUnsafeToProperIndex(selected + 2)
item(
text = textForIndex(before1),
translation = before1TranslatePercent
)
item(
text = textForIndex(selected),
translation = itemTranslatePercent
)
item(
text = textForIndex(after1),
translation = after1TranslatePercent
)
item(
text = textForIndex(after2),
translation = after2TranslatePercent
)
}
}

View file

@ -0,0 +1,39 @@
package org.fknives.android.compose.picker.text.content
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.style.TextAlign
import org.fknives.android.compose.picker.text.util.TextPickerDefaults
@Composable
fun DefaultNumberPickerText(
text: String,
modifier: Modifier
) {
Text(
text = text,
modifier = modifier,
textAlign = TextAlign.Center
)
}
fun defaultNumberPickerTextAlphaModifier(
modifier: Modifier = Modifier,
unselectedAlpha: Float = TextPickerDefaults.unselectedAlpha,
selectedAlpha: Float = TextPickerDefaults.selectedAlpha,
numberPickerLabel: @Composable (text: String, alpha: Float) -> Unit = { text, alpha ->
DefaultNumberPickerText(modifier = modifier.alpha(alpha), text = text)
}
): @Composable (String, Float) -> Unit = { text: String, translation: Float ->
val calculatedAlpha = if (translation < 0.5f) {
val between0And1Proportionally = (translation * 2)
between0And1Proportionally * unselectedAlpha
} else {
val between0And1Proportionally = (translation - 0.5f) * 2
between0And1Proportionally * (selectedAlpha - unselectedAlpha) + unselectedAlpha
}
numberPickerLabel(alpha = calculatedAlpha, text = text)
}

View file

@ -0,0 +1,43 @@
package org.fknives.android.compose.picker.text.content
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.unit.Constraints
import org.fknives.android.compose.picker.text.TextPickerState
open class DefaultTextPickerMeasurePolicy(private val state: TextPickerState) : MeasurePolicy {
open val numberOfItemsToPlace = 4
open val numberOfHeightMeasuringItems = 3
override fun MeasureScope.measure(measurables: List<Measurable>, constraints: Constraints): MeasureResult {
val placeables = measurables.map { measurable ->
measurable.measure(constraints)
}
val placeablesHeight = measureHeight(placeables)
val minWidth = measureWidth(placeables)
state.onItemSizeChange(placeablesHeight / numberOfHeightMeasuringItems.toFloat())
return layout(minWidth, placeablesHeight) {
var yPosition = state.translateOffset.toInt()
placeables.take(numberOfItemsToPlace).forEach { placeable ->
placeable.placeRelative(x = 0, y = yPosition)
yPosition += placeable.height
}
placementAfterItems(placeables, state)
}
}
open fun Placeable.PlacementScope.placementAfterItems(placeables: List<Placeable>, state: TextPickerState) = Unit
open fun measureWidth(placeables: List<Placeable>) =
placeables.take(numberOfItemsToPlace).maxOf { it.measuredWidth }
open fun measureHeight(placeables: List<Placeable>) =
placeables.take(numberOfHeightMeasuringItems).sumOf { it.measuredHeight }
}

View file

@ -0,0 +1,75 @@
package org.fknives.android.compose.picker.text.content
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.unit.Dp
import org.fknives.android.compose.picker.text.TextPickerContent
import org.fknives.android.compose.picker.text.util.TextPickerDefaults
import org.fknives.android.compose.picker.text.TextPickerState
class LinedTextPickerContent(
private val dividerModifier: Modifier = Modifier,
private val dividerColor: @Composable () -> Color = { MaterialTheme.colors.onSurface.copy(alpha = TextPickerDefaults.dividerAlpha) },
private val dividerThickness: Dp = TextPickerDefaults.dividerThickness,
private val dividerStartIndent: Dp = TextPickerDefaults.dividerStartIndent
) : TextPickerContent {
@Composable
override fun Content(
textForIndex: (Int) -> String,
selected: Int,
moveUnsafeToProperIndex: (Int) -> Int,
before1TranslatePercent: Float,
itemTranslatePercent: Float,
after1TranslatePercent: Float,
after2TranslatePercent: Float,
item: @Composable (text: String, translation: Float) -> Unit
) {
val before1 = moveUnsafeToProperIndex(selected - 1)
val after1 = moveUnsafeToProperIndex(selected + 1)
val after2 = moveUnsafeToProperIndex(selected + 2)
item(
text = textForIndex(before1),
translation = before1TranslatePercent
)
item(
text = textForIndex(selected),
translation = itemTranslatePercent
)
item(
text = textForIndex(after1),
translation = after1TranslatePercent
)
item(
text = textForIndex(after2),
translation = after2TranslatePercent
)
Divider(
modifier = dividerModifier,
color = dividerColor(),
thickness = dividerThickness,
startIndent = dividerStartIndent,
)
Divider(
modifier = dividerModifier,
color = dividerColor(),
thickness = dividerThickness,
startIndent = dividerStartIndent,
)
}
}
class LinedTextPickerMeasurePolicy(state: TextPickerState) : DefaultTextPickerMeasurePolicy(state = state) {
override fun Placeable.PlacementScope.placementAfterItems(placeables: List<Placeable>, state: TextPickerState) {
val dividers = placeables.drop(numberOfItemsToPlace)
dividers.forEachIndexed { index, placeable ->
val y = ((index + 1) * state.itemSize) - placeable.measuredHeight / 2f
placeable.placeRelative(x = 0, y = y.toInt())
}
}
}

View file

@ -0,0 +1,36 @@
package org.fknives.android.compose.picker.text.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
fun createLapsCounterByOnIndexDifference(itemCount: Int, selectedIndex: Int): (Int) -> Int = { indexDifference ->
lapCounterByIndexOfDifference(itemCount = itemCount, indexDifference = indexDifference, selectedIndex = selectedIndex)
}
fun lapCounterByIndexOfDifference(
itemCount: Int,
selectedIndex: Int,
indexDifference: Int
): Int {
var changedIndex = selectedIndex + indexDifference
var counter = 0
if (changedIndex < 0) {
counter--
while (changedIndex <= -itemCount) {
counter--
changedIndex += itemCount
}
} else {
while (changedIndex >= itemCount) {
counter++
changedIndex -= itemCount
}
}
return counter
}
@Composable
fun rememberLapsCounterByOnIndexDifference(selectedIndex: Int, itemCount: Int) = remember(selectedIndex, itemCount) {
createLapsCounterByOnIndexDifference(selectedIndex = selectedIndex, itemCount = itemCount)
}

View file

@ -0,0 +1,42 @@
package org.fknives.android.compose.picker.text.util
import org.fknives.android.compose.picker.text.TextPickerState
import kotlin.math.max
import kotlin.math.min
fun interface OffsetLimiter {
fun limit(offset: Float, state: TextPickerState): Float
fun overshootLimit(offset: Float, state: TextPickerState): Float =
limit(offset, state)
class NoLimit : OffsetLimiter {
override fun limit(offset: Float, state: TextPickerState): Float = offset
}
class MinMaxLimit : OffsetLimiter {
override fun limit(offset: Float, state: TextPickerState): Float {
val max = maximalOffset(state)
val min = minimalOffset(state)
return max(min(offset, max), min)
}
private fun maximalOffset(state: TextPickerState) = state.selected * state.itemSize
private fun minimalOffset(state: TextPickerState) = (state.selected + 1 - state.itemCount) * state.itemSize
override fun overshootLimit(offset: Float, state: TextPickerState): Float {
val max = maximalOffset(state) + state.itemSize / 2
val min = minimalOffset(state) - state.itemSize / 2
return max(min(offset, max), min)
}
}
companion object {
fun default(roundAround: Boolean): OffsetLimiter =
if (roundAround) NoLimit() else MinMaxLimit()
}
}

View file

@ -0,0 +1,15 @@
package org.fknives.android.compose.picker.text.util
import androidx.compose.ui.unit.dp
object TextPickerDefaults {
const val selected = 0
const val velocityMultiplier = 0.3f
const val unselectedAlpha = 0.6f
const val selectedAlpha = 1f
internal val onIndexDifferenceChanging: (Int) -> Unit = {}
const val roundAround = true
const val dividerAlpha = 0.12f
val dividerThickness = 2.dp
val dividerStartIndent = 0.dp
}

View file

@ -0,0 +1,97 @@
package org.fknives.android.compose.picker.text.util
import androidx.compose.animation.splineBasedDecay
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import org.fknives.android.compose.picker.text.TextPickerAnimator
import org.fknives.android.compose.picker.text.TextPickerState
@Composable
fun rememberTextPickerState(selected: Int, itemCount: Int): TextPickerState {
require(selected >= 0) { "Selected value ($selected) is less than 0!" }
require(selected < itemCount) { "Selected value ($selected) is more or equal to ItemCount = $itemCount!" }
val itemSizeState = remember { mutableStateOf(1f) }
val previousSelected = remember { mutableStateOf(selected) }
return remember(selected, itemCount) {
TextPickerState(
itemSizeState = itemSizeState,
previousSelectedState = previousSelected,
selected = selected,
itemCount = itemCount
)
}
}
@Composable
fun rememberTextPickerAnimator(
roundAround: Boolean,
onIndexDifferenceChanging: (Int) -> Unit = TextPickerDefaults.onIndexDifferenceChanging,
velocityMultiplier: Float = TextPickerDefaults.velocityMultiplier,
density: Density = LocalDensity.current,
): TextPickerAnimator {
require(velocityMultiplier > 0) { "0 Velocity would not work" }
val offsetLimiter = remember(roundAround) { OffsetLimiter.default(roundAround) }
return rememberTextPickerAnimator(
offsetLimiter = offsetLimiter,
velocityMultiplier = velocityMultiplier,
density = density,
onIndexDifferenceChanging = onIndexDifferenceChanging
)
}
@Composable
fun rememberTextPickerAnimator(
offsetLimiter: OffsetLimiter,
velocityMultiplier: Float = TextPickerDefaults.velocityMultiplier,
density: Density = LocalDensity.current,
onIndexDifferenceChanging: (Int) -> Unit = TextPickerDefaults.onIndexDifferenceChanging
): TextPickerAnimator {
require(velocityMultiplier > 0) { "0 Velocity would not work" }
return remember(offsetLimiter, velocityMultiplier, density, onIndexDifferenceChanging) {
TextPickerAnimator(
offsetLimiter = offsetLimiter,
flingDecaySpec = splineBasedDecay(density),
velocityMultiplier = velocityMultiplier,
onIndexDifferenceChanging = onIndexDifferenceChanging
)
}
}
@Composable
fun rememberDefaultMoveUnsafeToProperIndex(itemCount: Int, roundAround: Boolean) =
if (roundAround) {
remember(itemCount) { createMoveUnsafeToProperIndex(itemCount = itemCount) }
} else {
remember { { index: Int -> index } }
}
fun createMoveUnsafeToProperIndex(itemCount: Int) = moveUnsafeToProperIndex@{ unsafeIndex: Int ->
moveUnsafeToProperIndex(unsafeIndex, itemCount)
}
fun moveUnsafeToProperIndex(unsafeIndex: Int, itemCount: Int) : Int {
var index = unsafeIndex
while (index < 0) {
index += itemCount
}
while (index >= itemCount) {
index -= itemCount
}
return index
}
@Composable
fun rememberWrappedTextForIndex(itemCount: Int, roundAround: Boolean, textForIndex: (Int) -> String): (Int) -> String =
if (roundAround) {
textForIndex
} else {
remember(itemCount, textForIndex) {
{ index -> if (index < 0 || index >= itemCount) "" else textForIndex(index) }
}
}

View file

@ -0,0 +1,14 @@
package org.fknives.android.compose.picker.time
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import java.text.DateFormatSymbols
import java.util.Locale
fun defaultAmPmStrings(locale: Locale = Locale.getDefault()) =
DateFormatSymbols.getInstance(locale).amPmStrings.toList()
@Composable
fun rememberDefaultAMPMList(locale: Locale = Locale.getDefault()) = remember(locale) {
DateFormatSymbols.getInstance(locale).amPmStrings.toList()
}

View file

@ -0,0 +1,28 @@
package org.fknives.android.compose.picker.time
import androidx.compose.runtime.Immutable
import java.util.Calendar
@Immutable
data class SelectedTime(
val hour: Int,
val minute: Int,
val isAM: Boolean
) {
companion object {
fun get(time: Long = System.currentTimeMillis()): SelectedTime {
val calendar = Calendar.getInstance()
calendar.timeInMillis = time
val hour = calendar.get(Calendar.HOUR)
val minute = calendar.get(Calendar.MINUTE)
val isAm = calendar.get(Calendar.AM_PM) == Calendar.AM
return SelectedTime(
hour = if (hour == 0) 12 else hour,
minute = minute,
isAM = isAm
)
}
}
}

View file

@ -0,0 +1,54 @@
package org.fknives.android.compose.picker.time
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.fknives.android.compose.picker.number.NumberPickerConfig
import org.fknives.android.compose.picker.number.rememberNumberPickerScope
import org.fknives.android.compose.picker.number.rememberNumberPickerState
@Composable
fun TimePicker(
timePickersMinWidth: Dp = 40.dp,
selectedTime: SelectedTime,
amPm: List<String> = rememberDefaultAMPMList(),
onSelectedTimeChanged: (SelectedTime) -> Unit,
timePickers: @Composable (TimePickerScope) -> Unit = { StandardTimePickers(it) }
) {
val hourConfig = remember { NumberPickerConfig.configHourPicker12 }
val hourState = rememberNumberPickerState(selectedValue = selectedTime.hour, config = hourConfig)
val hourScope = rememberNumberPickerScope(
state = hourState,
config = hourConfig,
onSelectedChange = {
onSelectedTimeChanged(selectedTime.copy(hour = it))
}
)
val minuteConfig = remember { NumberPickerConfig.configMinutePicker }
val minuteState = rememberNumberPickerState(selectedValue = selectedTime.minute, config = minuteConfig)
val minuteScope = rememberNumberPickerScope(
state = minuteState,
config = minuteConfig,
onSelectedChange = {
onSelectedTimeChanged(selectedTime.copy(minute = it))
}
)
val amORpmScope = rememberAMorPMPickerScope(
listOfAMorPM = amPm,
isAM = selectedTime.isAM,
onAMSelectedChange = {
onSelectedTimeChanged(selectedTime.copy(isAM = it))
})
val scope = rememberTimePickerScope(
timePickerMinWidth = timePickersMinWidth,
hoursPickerScope = hourScope,
minutesPickerScope = minuteScope,
amORpmPickerScope = amORpmScope
)
timePickers(scope)
}

View file

@ -0,0 +1,73 @@
package org.fknives.android.compose.picker.time
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.Dp
import org.fknives.android.compose.picker.number.NumberPickerScope
import org.fknives.android.compose.picker.text.util.TextPickerDefaults
@Immutable
interface TimePickerScope {
val timePickerMinWidth: Dp
val hoursPickerScope: NumberPickerScope
val minutesPickerScope: NumberPickerScope
val amORpmPickerScope: AMorPMPickerScope
}
@Immutable
data class TimePickerScopeImpl(
override val timePickerMinWidth: Dp,
override val hoursPickerScope: NumberPickerScope,
override val minutesPickerScope: NumberPickerScope,
override val amORpmPickerScope: AMorPMPickerScope
) : TimePickerScope
@Immutable
interface AMorPMPickerScope {
val listOfAMorPM: List<String>
val isAM: Boolean
val onAMSelectedChange: (Boolean) -> Unit
val onIndexDifferenceChanging: (Int) -> Unit
}
@Immutable
data class AMorPMPickerScopeImpl(
override val listOfAMorPM: List<String>,
override val isAM: Boolean,
override val onAMSelectedChange: (Boolean) -> Unit,
override val onIndexDifferenceChanging: (Int) -> Unit = TextPickerDefaults.onIndexDifferenceChanging
) : AMorPMPickerScope
@Composable
fun rememberAMorPMPickerScope(
listOfAMorPM: List<String>,
isAM: Boolean,
onAMSelectedChange: (Boolean) -> Unit,
onIndexDifferenceChanging: (Int) -> Unit = TextPickerDefaults.onIndexDifferenceChanging
): AMorPMPickerScope {
return remember(listOfAMorPM, isAM, onAMSelectedChange, onIndexDifferenceChanging) {
AMorPMPickerScopeImpl(
listOfAMorPM = listOfAMorPM,
isAM = isAM,
onAMSelectedChange = onAMSelectedChange,
onIndexDifferenceChanging = onIndexDifferenceChanging
)
}
}
@Composable
fun rememberTimePickerScope(
timePickerMinWidth: Dp,
hoursPickerScope: NumberPickerScope,
minutesPickerScope: NumberPickerScope,
amORpmPickerScope: AMorPMPickerScope
) = remember(timePickerMinWidth, hoursPickerScope, minutesPickerScope, amORpmPickerScope) {
TimePickerScopeImpl(
timePickerMinWidth = timePickerMinWidth,
hoursPickerScope = hoursPickerScope,
minutesPickerScope = minutesPickerScope,
amORpmPickerScope = amORpmPickerScope
)
}

View file

@ -0,0 +1,72 @@
package org.fknives.android.compose.picker.time
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.material.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import org.fknives.android.compose.picker.number.CustomInnerTextPicker
import org.fknives.android.compose.picker.text.TextPicker
import org.fknives.android.compose.picker.text.util.TextPickerDefaults
@Composable
fun StandardTimePickers(scope: TimePickerScope) {
Row {
scope.apply {
HourPicker()
MinutePicker()
IsAMorPMPicker()
}
}
}
@Composable
fun TimePickerScope.HourPicker(
modifier: Modifier = Modifier,
textStyle: TextStyle = LocalTextStyle.current,
roundAround: Boolean = TextPickerDefaults.roundAround
) {
hoursPickerScope.apply {
CustomInnerTextPicker(
modifier = modifier.defaultMinSize(timePickerMinWidth),
textStyle = textStyle,
roundAround = roundAround
)
}
}
@Composable
fun TimePickerScope.MinutePicker(
modifier: Modifier = Modifier,
textStyle: TextStyle = LocalTextStyle.current,
roundAround: Boolean = TextPickerDefaults.roundAround
) {
minutesPickerScope.apply {
CustomInnerTextPicker(
modifier = modifier.defaultMinSize(timePickerMinWidth),
textStyle = textStyle,
roundAround = roundAround
)
}
}
@Composable
fun TimePickerScope.IsAMorPMPicker(
modifier: Modifier = Modifier,
textStyle: TextStyle = LocalTextStyle.current,
roundAround: Boolean = false
) {
amORpmPickerScope.apply {
TextPicker(
modifier = modifier.defaultMinSize(timePickerMinWidth),
textStyle = textStyle,
roundAround = roundAround,
textForIndex = listOfAMorPM::get,
itemCount = listOfAMorPM.size,
selectedIndex = if (isAM) 0 else 1,
onSelectedIndexChange = { onAMSelectedChange(it == 0) },
onIndexDifferenceChanging = onIndexDifferenceChanging,
)
}
}

View file

@ -0,0 +1,97 @@
package org.fknives.android.compose.picker.time.clock
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.fknives.android.compose.picker.number.NumberPickerConfig
import org.fknives.android.compose.picker.number.rememberNumberPickerScope
import org.fknives.android.compose.picker.number.rememberNumberPickerState
import org.fknives.android.compose.picker.text.util.rememberLapsCounterByOnIndexDifference
import org.fknives.android.compose.picker.time.SelectedTime
import org.fknives.android.compose.picker.time.StandardTimePickers
import org.fknives.android.compose.picker.time.TimePickerScope
import org.fknives.android.compose.picker.time.rememberAMorPMPickerScope
import org.fknives.android.compose.picker.time.rememberDefaultAMPMList
import org.fknives.android.compose.picker.time.rememberTimePickerScope
import kotlin.math.abs
@Composable
fun ClockTimePicker(
timePickerMinWidth: Dp = 40.dp,
selectedTime: SelectedTime,
amPm: List<String> = rememberDefaultAMPMList(),
onSelectedTimeChanged: (SelectedTime) -> Unit,
timePickers: @Composable (TimePickerScope) -> Unit = { StandardTimePickers(it) }
) {
var changingIsAM by remember(selectedTime.isAM) { mutableStateOf(selectedTime.isAM) }
var changingHour by remember(selectedTime.hour) { mutableStateOf(0) }
val hoursLapCounterByIndex = rememberLapsCounterByOnIndexDifference(selectedIndex = selectedTime.hour - 1, itemCount = 12)
val minutesLapCounterByIndex = rememberLapsCounterByOnIndexDifference(selectedIndex = selectedTime.minute, itemCount = 60)
val hourConfig = remember { NumberPickerConfig.configHourPicker12 }
val hourPickerState = rememberNumberPickerState(selectedValue = selectedTime.hour + changingHour, config = hourConfig)
val hourScope = rememberNumberPickerScope(
state = hourPickerState,
config = hourConfig,
onIndexDifferenceChanging = {
if (changingHour == 0) {
val lapsCounter = hoursLapCounterByIndex(it)
changingIsAM = isAMResultByHourLapsCounter(lapsCounter, selectedTime.isAM)
}
},
onSelectedChange = {
onSelectedTimeChanged(selectedTime.copy(hour = it, isAM = changingIsAM))
},
keys = arrayOf(changingHour, hoursLapCounterByIndex, changingIsAM)
)
val minuteConfig = remember { NumberPickerConfig.configMinutePicker }
val minutePickerState = rememberNumberPickerState(selectedValue = selectedTime.minute, config = minuteConfig)
val minutesScope = rememberNumberPickerScope(
state = minutePickerState,
config = minuteConfig,
onIndexDifferenceChanging = {
val hourIndexDifference = minutesLapCounterByIndex(it)
var hourDifference = hourIndexDifference
while (selectedTime.hour + hourDifference <= 0) {
hourDifference += 12
}
while (selectedTime.hour + hourDifference > 12) {
hourDifference -= 12
}
val lapsCounter = hoursLapCounterByIndex(hourIndexDifference)
changingIsAM = isAMResultByHourLapsCounter(lapsCounter, selectedTime.isAM)
changingHour = hourDifference
},
onSelectedChange = {
onSelectedTimeChanged(selectedTime.copy(hour = selectedTime.hour + changingHour, minute = it, isAM = changingIsAM))
},
keys = arrayOf(changingHour, hoursLapCounterByIndex, changingIsAM)
)
val amORpmScope = rememberAMorPMPickerScope(
listOfAMorPM = amPm,
isAM = changingIsAM,
onAMSelectedChange = {
onSelectedTimeChanged(selectedTime.copy(hour = selectedTime.hour + changingHour, isAM = it))
}
)
val scope = rememberTimePickerScope(
timePickerMinWidth = timePickerMinWidth,
hoursPickerScope = hourScope,
minutesPickerScope = minutesScope,
amORpmPickerScope = amORpmScope
)
timePickers(scope)
}
private fun isAMResultByHourLapsCounter(hourLapCounter: Int, selectedTimeIsAm: Boolean): Boolean =
if (abs(hourLapCounter) % 2 == 0) {
selectedTimeIsAm
} else {
!selectedTimeIsAm
}

View file

@ -0,0 +1,17 @@
package org.fknives.android.compose.picker
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}