diff --git a/timepicker/src/main/java/com/dongchyeon/timepicker/TimePicker.kt b/timepicker/src/main/java/com/dongchyeon/timepicker/TimePicker.kt index 0543cac..89d47cc 100644 --- a/timepicker/src/main/java/com/dongchyeon/timepicker/TimePicker.kt +++ b/timepicker/src/main/java/com/dongchyeon/timepicker/TimePicker.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -23,9 +22,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.dongchyeon.timepicker.model.PickerState import com.dongchyeon.timepicker.model.rememberPickerState @@ -39,35 +36,33 @@ import kotlinx.datetime.toLocalDateTime @Composable fun TimePicker( modifier: Modifier = Modifier, - itemSpacing: Dp = 2.dp, - visibleItemsCount: Int = TimePickerDefaults.visibleItemsCount, - itemLabel: ItemLabel = TimePickerDefaults.itemLabel(), initialTime: LocalTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).time, + visibleItemsCount: Int = TimePickerDefaults.visibleItemsCount, timeFormat: TimeFormat = TimePickerDefaults.timeFormat, + style: PickerStyle = TimePickerDefaults.pickerStyle(), selector: PickerSelector = TimePickerDefaults.pickerSelector(), + curveEffect: CurveEffect = TimePickerDefaults.curveEffect(), onValueChange: (LocalTime) -> Unit ) { if (timeFormat.is24Hour) { TimePicker24Hour( modifier = modifier, - itemSpacing = itemSpacing, visibleItemsCount = visibleItemsCount, - textStyle = itemLabel.style, - textColor = itemLabel.color, initialTime = initialTime, + style = style, selector = selector, + curveEffect = curveEffect, onValueChange = onValueChange ) } else { TimePicker12Hour( modifier = modifier, - itemSpacing = itemSpacing, visibleItemsCount = visibleItemsCount, - textStyle = itemLabel.style, - textColor = itemLabel.color, initialTime = initialTime, localeTimeFormat = timeFormat.localeTimeFormat, + style = style, selector = selector, + curveEffect = curveEffect, onValueChange = onValueChange ) } @@ -76,182 +71,158 @@ fun TimePicker( @Composable private fun TimePicker12Hour( modifier: Modifier = Modifier, - itemSpacing: Dp = 2.dp, - visibleItemsCount: Int = 5, - textStyle: TextStyle = MaterialTheme.typography.bodyLarge, - textColor: Color = Color.White, - initialTime: LocalTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).time, + initialTime: LocalTime, + visibleItemsCount: Int, localeTimeFormat: LocaleTimeFormat, + style: PickerStyle, selector: PickerSelector, + curveEffect: CurveEffect, onValueChange: (LocalTime) -> Unit ) { - Box(modifier = modifier.fillMaxWidth()) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Bottom - ) { - Box( - modifier = modifier.fillMaxWidth() - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Bottom - ) { - val amPmItems = remember { - listOf( - TimePeriod.AM.getLabel(localeTimeFormat), - TimePeriod.PM.getLabel(localeTimeFormat) - ) - } - val hourItems = remember { (1..12).toList() } - val minuteItems = remember { (0..59).toList() } - - val amPmPickerState = rememberPickerState( - initialIndex = if (initialTime.hour < 12) 0 else 1, - items = amPmItems - ) - val hourPickerState = rememberPickerState( - initialIndex = hourItems.indexOf(if (initialTime.hour % 12 == 0) 12 else initialTime.hour % 12), - items = hourItems - ) - val minutePickerState = rememberPickerState( - initialIndex = minuteItems.indexOf(initialTime.minute), - items = minuteItems - ) - - var previousHour by remember { mutableIntStateOf(initialTime.hour) } - - val scope = rememberCoroutineScope() - - Box( - modifier = Modifier.fillMaxWidth() - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center) - .padding(horizontal = 20.dp) - .height(with(LocalDensity.current) { textStyle.lineHeight.toDp() } + 20.dp) - .background( - color = selector.color, - shape = selector.shape - ) - .then( - if (selector.border != null) { - Modifier.border( - border = selector.border, - shape = selector.shape - ) - } else { - Modifier - } - ) - ) + val amPmItems = remember { + listOf( + TimePeriod.AM.getLabel(localeTimeFormat), + TimePeriod.PM.getLabel(localeTimeFormat) + ) + } + val hourItems = remember { (1..12).toList() } + val minuteItems = remember { (0..59).toList() } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 50.dp), - verticalAlignment = Alignment.CenterVertically - ) { - PickerItem( - state = amPmPickerState, - items = amPmItems, - visibleItemsCount = 3, - itemSpacing = itemSpacing, - textStyle = textStyle, - textColor = textColor, - modifier = Modifier.weight(1f), - textModifier = Modifier.padding(8.dp), - infiniteScroll = false, - onValueChange = { - onPickerValueChange( - amPmPickerState, - hourPickerState, - minutePickerState, - localeTimeFormat, - onValueChange - ) - } - ) + val amPmPickerState = rememberPickerState( + initialIndex = if (initialTime.hour < 12) 0 else 1, + items = amPmItems + ) + val hourPickerState = rememberPickerState( + initialIndex = hourItems.indexOf(if (initialTime.hour % 12 == 0) 12 else initialTime.hour % 12), + items = hourItems + ) + val minutePickerState = rememberPickerState( + initialIndex = minuteItems.indexOf(initialTime.minute), + items = minuteItems + ) - PickerItem( - state = hourPickerState, - items = hourItems, - visibleItemsCount = visibleItemsCount, - itemSpacing = itemSpacing, - textStyle = textStyle, - textColor = textColor, - modifier = Modifier.weight(1f), - textModifier = Modifier.padding(8.dp), - infiniteScroll = true, - onValueChange = { - onPickerValueChange( - amPmPickerState, - hourPickerState, - minutePickerState, - localeTimeFormat, - onValueChange - ) + var previousHour by remember { mutableIntStateOf(initialTime.hour) } + val scope = rememberCoroutineScope() - scope.launch { - val currentHour = hourPickerState.selectedItem + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + SelectorBackground( + style = style, + selector = selector + ) - if (currentHour == 12 && previousHour == 11) { - val currentIndex = amPmPickerState.lazyListState.firstVisibleItemIndex % amPmItems.size - val nextIndex = (currentIndex + 1) % amPmItems.size - amPmPickerState.lazyListState.animateScrollToItem(nextIndex) - } else if (currentHour == 11 && previousHour == 12) { - val currentIndex = amPmPickerState.lazyListState.firstVisibleItemIndex % amPmItems.size - val nextIndex = (currentIndex + 1) % amPmItems.size - amPmPickerState.lazyListState.animateScrollToItem(nextIndex) - } + Row( + modifier = Modifier.padding(horizontal = 50.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PickerItem( + items = amPmItems, + state = amPmPickerState, + visibleItemsCount = visibleItemsCount, + style = style, + modifier = Modifier.weight(1f), + textModifier = Modifier.padding(8.dp), + infiniteScroll = false, + curveEffect = curveEffect, + onValueChange = { + onPickerValueChange( + amPmPickerState, + hourPickerState, + minutePickerState, + localeTimeFormat, + onValueChange + ) + } + ) - previousHour = currentHour - } - } - ) + PickerItem( + items = hourItems, + state = hourPickerState, + visibleItemsCount = visibleItemsCount, + style = style, + modifier = Modifier.weight(1f), + textModifier = Modifier.padding(8.dp), + infiniteScroll = true, + curveEffect = curveEffect, + onValueChange = { + onPickerValueChange( + amPmPickerState, + hourPickerState, + minutePickerState, + localeTimeFormat, + onValueChange + ) + scope.launch { + val currentHour = hourPickerState.selectedItem + val currentIndex = amPmPickerState.lazyListState.firstVisibleItemIndex % amPmItems.size + val nextIndex = (currentIndex + 1) % amPmItems.size - PickerItem( - state = minutePickerState, - items = minuteItems, - visibleItemsCount = visibleItemsCount, - itemSpacing = itemSpacing, - textStyle = textStyle, - textColor = textColor, - modifier = Modifier.weight(1f), - textModifier = Modifier.padding(8.dp), - infiniteScroll = true, - itemFormatter = { item -> - item.toString().padStart(2, '0') - }, - onValueChange = { - onPickerValueChange( - amPmPickerState, - hourPickerState, - minutePickerState, - localeTimeFormat, - onValueChange - ) - } - ) + if ((currentHour == 12 && previousHour == 11) || + (currentHour == 11 && previousHour == 12) + ) { + amPmPickerState.lazyListState.animateScrollToItem(nextIndex) } + previousHour = currentHour } } - } + ) + + PickerItem( + items = minuteItems, + state = minutePickerState, + visibleItemsCount = visibleItemsCount, + style = style, + modifier = Modifier.weight(1f), + textModifier = Modifier.padding(8.dp), + infiniteScroll = true, + itemFormatter = { it.toString().padStart(2, '0') }, + curveEffect = curveEffect, + onValueChange = { + onPickerValueChange( + amPmPickerState, + hourPickerState, + minutePickerState, + localeTimeFormat, + onValueChange + ) + } + ) } } } +@Composable +private fun SelectorBackground( + modifier: Modifier = Modifier, + style: PickerStyle, + selector: PickerSelector +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .height(with(LocalDensity.current) { style.textStyle.lineHeight.toDp() } + 20.dp) + .background(color = selector.color, shape = selector.shape) + .then( + if (selector.border != null) { + Modifier.border(border = selector.border, shape = selector.shape) + } else { + Modifier + } + ) + ) +} + @Composable private fun TimePicker24Hour( modifier: Modifier = Modifier, - itemSpacing: Dp = 2.dp, - visibleItemsCount: Int = 5, - textStyle: TextStyle = MaterialTheme.typography.bodyLarge, - textColor: Color = Color.White, - initialTime: LocalTime = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).time, + initialTime: LocalTime, + visibleItemsCount: Int, + style: PickerStyle, selector: PickerSelector, + curveEffect: CurveEffect, onValueChange: (LocalTime) -> Unit ) { val hourItems = remember { (0..23).toList() } @@ -266,99 +237,55 @@ private fun TimePicker24Hour( items = minuteItems ) - Box(modifier = modifier.fillMaxWidth()) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Bottom - ) { - Box( - modifier = modifier.fillMaxWidth() - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Bottom - ) { - Box( - modifier = Modifier.fillMaxWidth() - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Center) - .padding(horizontal = 20.dp) - .height(with(LocalDensity.current) { textStyle.lineHeight.toDp() } + 20.dp) - .background( - color = selector.color, - shape = selector.shape - ) - .then( - if (selector.border != null) { - Modifier.border( - border = selector.border, - shape = selector.shape - ) - } else { - Modifier - } - ) - ) + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + SelectorBackground( + style = style, + selector = selector + ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 50.dp), - verticalAlignment = Alignment.CenterVertically - ) { - PickerItem( - state = hourPickerState, - items = hourItems, - visibleItemsCount = visibleItemsCount, - itemSpacing = itemSpacing, - textStyle = textStyle, - textColor = textColor, - modifier = Modifier.weight(1f), - textModifier = Modifier.padding(8.dp), - infiniteScroll = true, - onValueChange = { - onPickerValueChange( - hourPickerState, - minutePickerState, - onValueChange - ) - } - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 50.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PickerItem( + items = hourItems, + state = hourPickerState, + visibleItemsCount = visibleItemsCount, + style = style, + modifier = Modifier.weight(1f), + textModifier = Modifier.padding(8.dp), + infiniteScroll = true, + curveEffect = curveEffect, + onValueChange = { + onPickerValueChange(hourPickerState, minutePickerState, onValueChange) + } + ) - Text( - text = ":", - style = textStyle, - color = textColor - ) + Text( + text = ":", + style = style.textStyle, + color = style.textColor + ) - PickerItem( - state = minutePickerState, - items = minuteItems, - visibleItemsCount = visibleItemsCount, - itemSpacing = itemSpacing, - textStyle = textStyle, - textColor = textColor, - modifier = Modifier.weight(1f), - textModifier = Modifier.padding(8.dp), - infiniteScroll = true, - itemFormatter = { item -> - item.toString().padStart(2, '0') - }, - onValueChange = { - onPickerValueChange( - hourPickerState, - minutePickerState, - onValueChange - ) - } - ) - } - } + PickerItem( + items = minuteItems, + state = minutePickerState, + visibleItemsCount = visibleItemsCount, + style = style, + modifier = Modifier.weight(1f), + textModifier = Modifier.padding(8.dp), + infiniteScroll = true, + itemFormatter = { it.toString().padStart(2, '0') }, + curveEffect = curveEffect, + onValueChange = { + onPickerValueChange(hourPickerState, minutePickerState, onValueChange) } - } + ) } } } diff --git a/timepicker/src/main/java/com/dongchyeon/timepicker/TimePickerDefaults.kt b/timepicker/src/main/java/com/dongchyeon/timepicker/TimePickerDefaults.kt index 6bc240e..89569e7 100644 --- a/timepicker/src/main/java/com/dongchyeon/timepicker/TimePickerDefaults.kt +++ b/timepicker/src/main/java/com/dongchyeon/timepicker/TimePickerDefaults.kt @@ -7,9 +7,23 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp object TimePickerDefaults { + @Composable + fun pickerStyle( + textStyle: TextStyle = MaterialTheme.typography.titleMedium, + textColor: Color = Color.White, + itemSpacing: Dp = 2.dp + ): PickerStyle { + return PickerStyle( + textStyle = textStyle, + textColor = textColor, + itemSpacing = itemSpacing + ) + } + @Composable fun pickerSelector( enabled: Boolean = true, @@ -25,14 +39,17 @@ object TimePickerDefaults { ) } - @Composable - fun itemLabel( - style: TextStyle = MaterialTheme.typography.titleMedium, - color: Color = Color.White - ): ItemLabel { - return ItemLabel( - style = style, - color = color + fun curveEffect( + alphaEnabled: Boolean = true, + minAlpha: Float = 0.2f, + scaleYEnabled: Boolean = true, + minScaleY: Float = 0.8f + ): CurveEffect { + return CurveEffect( + alphaEnabled = alphaEnabled, + minAlpha = minAlpha, + scaleYEnabled = scaleYEnabled, + minScaleY = minScaleY ) } @@ -41,43 +58,40 @@ object TimePickerDefaults { } @Immutable -class PickerSelector( - val enabled: Boolean, - val shape: RoundedCornerShape, - val color: Color, - val border: BorderStroke? -) { - fun copy( - enabled: Boolean = this.enabled, - shape: RoundedCornerShape = this.shape, - color: Color = this.color, - border: BorderStroke? = this.border - ): PickerSelector { - return PickerSelector( - enabled = enabled, - shape = shape, - color = color, - border = border - ) - } -} +data class PickerStyle( + val textStyle: TextStyle, + val textColor: Color, + val itemSpacing: Dp +) @Immutable -class ItemLabel( - val style: TextStyle, - val color: Color +data class CurveEffect( + val alphaEnabled: Boolean = true, + val minAlpha: Float = 0.2f, + val scaleYEnabled: Boolean = true, + val minScaleY: Float = 0.8f ) { - fun copy( - style: TextStyle = this.style, - color: Color = this.color - ): ItemLabel { - return ItemLabel( - style = style, - color = color - ) + fun calculateAlpha(distanceFromCenter: Float, maxDistance: Float): Float { + if (!alphaEnabled) return 1f + val ratio = (distanceFromCenter / maxDistance).coerceIn(0f, 1f) + return ((1f - ratio) * (1f - minAlpha) + minAlpha) + } + + fun calculateScaleY(distanceFromCenter: Float, maxDistance: Float): Float { + if (!scaleYEnabled) return 1f + val ratio = (distanceFromCenter / maxDistance).coerceIn(0f, 1f) + return ((1f - ratio) * (1f - minScaleY) + minScaleY) } } +@Immutable +data class PickerSelector( + val enabled: Boolean, + val shape: RoundedCornerShape, + val color: Color, + val border: BorderStroke? +) + enum class TimeFormat(val is24Hour: Boolean, val localeTimeFormat: LocaleTimeFormat) { DEFAULT(false, LocaleTimeFormat.ENGLISH), TWELVE_HOUR(false, LocaleTimeFormat.ENGLISH), diff --git a/timepicker/src/main/java/com/dongchyeon/timepicker/ui/PickerItem.kt b/timepicker/src/main/java/com/dongchyeon/timepicker/ui/PickerItem.kt index 892ddd1..5e6d617 100644 --- a/timepicker/src/main/java/com/dongchyeon/timepicker/ui/PickerItem.kt +++ b/timepicker/src/main/java/com/dongchyeon/timepicker/ui/PickerItem.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -19,15 +18,14 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp +import com.dongchyeon.timepicker.CurveEffect +import com.dongchyeon.timepicker.PickerStyle +import com.dongchyeon.timepicker.TimePickerDefaults import com.dongchyeon.timepicker.model.PickerState import com.dongchyeon.timepicker.model.rememberPickerState import com.dongchyeon.timepicker.ui.util.toPx @@ -41,12 +39,11 @@ internal fun PickerItem( items: List, state: PickerState = rememberPickerState(items = items), visibleItemsCount: Int, + style: PickerStyle, textModifier: Modifier = Modifier, - infiniteScroll: Boolean = true, - textStyle: TextStyle, - textColor: Color, - itemSpacing: Dp, itemFormatter: (T) -> String = { it.toString() }, + infiniteScroll: Boolean, + curveEffect: CurveEffect, onValueChange: (T) -> Unit ) { val visibleItemsMiddle = visibleItemsCount / 2 @@ -104,7 +101,8 @@ internal fun PickerItem( } } - val totalItemHeight = itemHeightDp + itemSpacing + val totalItemHeight = itemHeightDp + style.itemSpacing + val totalItemHeightPx = totalItemHeight.toPx() Box(modifier = modifier) { LazyColumn( @@ -125,16 +123,11 @@ internal fun PickerItem( val itemInfo = layoutInfo.visibleItemsInfo.find { it.index == index } val itemCenterOffset = itemInfo?.offset?.let { it + (itemInfo.size / 2) } ?: 0 - val distanceFromCenter = abs(viewportCenterOffset - itemCenterOffset) - val maxDistance = totalItemHeight.toPx() * visibleItemsMiddle - - val alpha = if (distanceFromCenter <= maxDistance) { - ((maxDistance - distanceFromCenter) / maxDistance).coerceIn(0.2f, 1f) - } else { - 0.2f - } + val distanceFromCenter = abs(viewportCenterOffset - itemCenterOffset).toFloat() + val maxDistance = totalItemHeightPx * visibleItemsMiddle - val scaleY = 1f - (0.2f * (distanceFromCenter / maxDistance)).coerceIn(0f, 0.4f) + val alpha = curveEffect.calculateAlpha(distanceFromCenter, maxDistance) + val scaleY = curveEffect.calculateScaleY(distanceFromCenter, maxDistance) val item = getItemForIndex( index = index, @@ -146,10 +139,10 @@ internal fun PickerItem( Text( text = item?.let { itemFormatter(it) } ?: "", maxLines = 1, - style = textStyle, - color = textColor.copy(alpha = alpha), + style = style.textStyle, + color = style.textColor.copy(alpha = alpha), modifier = Modifier - .padding(vertical = itemSpacing / 2) + .padding(vertical = style.itemSpacing / 2) .graphicsLayer(scaleY = scaleY) .onSizeChanged { size -> itemHeightPixels = size.height } .then(textModifier) @@ -180,6 +173,10 @@ private fun getStartIndexForInfiniteScroll( visibleItemsMiddle: Int, startIndex: Int ): Int { + if (itemSize == 0) { + return listScrollMiddle - visibleItemsMiddle + startIndex + } + return listScrollMiddle - listScrollMiddle % itemSize - visibleItemsMiddle + startIndex } @@ -189,9 +186,9 @@ private fun PickerItemPreview() { PickerItem( items = (0..100).map { it }, visibleItemsCount = 5, - textStyle = MaterialTheme.typography.bodyLarge, - textColor = Color.White, - itemSpacing = 8.dp, + style = TimePickerDefaults.pickerStyle(), + curveEffect = TimePickerDefaults.curveEffect(), + infiniteScroll = true, onValueChange = {} ) }