diff --git a/fluent/src/androidMain/kotlin/io/github/composefluent/component/TimePicker.android.kt b/fluent/src/androidMain/kotlin/io/github/composefluent/component/TimePicker.android.kt new file mode 100644 index 00000000..67608809 --- /dev/null +++ b/fluent/src/androidMain/kotlin/io/github/composefluent/component/TimePicker.android.kt @@ -0,0 +1,23 @@ +package io.github.composefluent.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.datetime.LocalTime + +@Composable +actual fun TimePicker( + value: LocalTime?, + onValueChange: (LocalTime?) -> Unit, + modifier: Modifier, + is12hour: Boolean, + disabled: Boolean +) { + TimePickerImpl( + value = value, + onValueChange = onValueChange, + modifier = modifier, + is12hour = is12hour, + disabled = disabled, + userScrollEnabled = true + ) +} \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/io/github/composefluent/component/FontIcon.kt b/fluent/src/commonMain/kotlin/io/github/composefluent/component/FontIcon.kt index a4d98f06..52d9578b 100644 --- a/fluent/src/commonMain/kotlin/io/github/composefluent/component/FontIcon.kt +++ b/fluent/src/commonMain/kotlin/io/github/composefluent/component/FontIcon.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf 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.graphics.vector.ImageVector import androidx.compose.ui.layout.layout @@ -67,13 +68,14 @@ internal fun FontIcon( modifier: Modifier = Modifier, iconSize: TextUnit = FontIconSize.Standard.value.sp, fallback: (@Composable () -> Unit)? = null, + tint: Color = LocalContentColor.current.copy(LocalContentAlpha.current) ) { if (LocalFontIconFontFamily.current != null || fallback == null) { Text( text = glyph.toString(), fontFamily = LocalFontIconFontFamily.current, fontSize = iconSize, - color = LocalContentColor.current.copy(LocalContentAlpha.current), + color = tint, modifier = Modifier.then(modifier) .height(with(LocalDensity.current) { iconSize.toDp() }), onTextLayout = { @@ -91,7 +93,8 @@ internal fun FontIcon( contentDescription: String?, modifier: Modifier = Modifier, iconSize: FontIconSize = FontIconSize.Standard, - fallbackSize: FontIconSize = iconSize + fallbackSize: FontIconSize = iconSize, + tint: Color = LocalContentColor.current.copy(LocalContentAlpha.current) ) { FontIcon( glyph = glyph, @@ -104,6 +107,7 @@ internal fun FontIcon( Icon( imageVector = vector(), contentDescription = contentDescription, + tint = tint, modifier = modifier .layout { measurable, constraints -> val size = fallbackSize.value.dp.roundToPx() @@ -120,7 +124,8 @@ internal fun FontIcon( } ) } - } + }, + tint = tint ) } @@ -196,7 +201,8 @@ fun FontIcon( contentDescription: String?, modifier: Modifier = Modifier, size: FontIconSize = FontIconSize.Standard, - fallbackSize: FontIconSize = FontIconSize(size.value + 2f) + fallbackSize: FontIconSize = FontIconSize(size.value + 2f), + tint: Color = LocalContentColor.current.copy(LocalContentAlpha.current) ) { FontIcon( glyph = type.glyph, @@ -204,7 +210,8 @@ fun FontIcon( contentDescription = contentDescription, iconSize = size, fallbackSize = fallbackSize, - modifier = modifier + modifier = modifier, + tint = tint ) } diff --git a/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt new file mode 100644 index 00000000..12430f53 --- /dev/null +++ b/fluent/src/commonMain/kotlin/io/github/composefluent/component/TimePicker.kt @@ -0,0 +1,495 @@ +package io.github.composefluent.component + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.* +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceAtLeast +import io.github.composefluent.ExperimentalFluentApi +import io.github.composefluent.FluentTheme +import io.github.composefluent.animation.FluentDuration +import io.github.composefluent.animation.FluentEasing +import kotlinx.coroutines.launch +import kotlinx.datetime.LocalTime + +@Composable +@ExperimentalFluentApi +expect fun TimePicker( + value: LocalTime?, + onValueChange: (LocalTime?) -> Unit, + modifier: Modifier = Modifier, + is12hour: Boolean = false, + disabled: Boolean = false +) + +@Composable +internal fun TimePickerImpl( + value: LocalTime?, + onValueChange: (LocalTime?) -> Unit, + modifier: Modifier = Modifier, + is12hour: Boolean = false, + disabled: Boolean = false, + userScrollEnabled: Boolean +) { + var open by remember { mutableStateOf(false) } + + BasicFlyoutContainer( + flyout = { + BasicFlyout( + visible = open, + onDismissRequest = { open = false }, + contentPadding = PaddingValues(0.dp) + ) { + var candidateHour by remember { mutableStateOf(0) } + var candidateMinutes by remember { mutableStateOf(0) } + var candidateSeconds by remember { mutableStateOf(0) } + var candidateAmPm by remember { mutableStateOf("AM") } + + Column( + Modifier.width(300.dp) + // TODO[optimize](time-picker): Should we use acrylic effect? + // If we use acrylic effect, it would be difficult to hide the text below caret buttons + .background(FluentTheme.colors.background.acrylic.default) + ) { + Box { + // Base indicator + BaseIndicator(is12hour) + + // Wheels + Row(Modifier.height(360.dp)) { + // Hour + Box(Modifier.weight(1f)) { + InfiniteWheelPicker( + items = if (is12hour) hours12 else hours24, + initialValue = value?.let { + if (is12hour) hour24to12(it.hour) + else it.hour + }?.toString(), + onSelectedValueChange = { candidateHour = it.toInt() }, + visibleItemsCount = 9, + ring = true, + userScrollEnabled = userScrollEnabled + ) + } + Box( + Modifier.width(1.dp).fillMaxHeight() + .background(FluentTheme.colors.stroke.divider.default) + ) + // Minute + Box(Modifier.weight(1f)) { + InfiniteWheelPicker( + items = minutes, + initialValue = value?.minute?.let(::formatMinute), + onSelectedValueChange = { candidateMinutes = it.toInt() }, + visibleItemsCount = 9, + ring = true, + userScrollEnabled = userScrollEnabled + ) + } + if (is12hour) { + Box( + Modifier.width(1.dp).fillMaxHeight() + .background(FluentTheme.colors.stroke.divider.default) + ) + // AM/PM + Box(Modifier.weight(1f)) { + InfiniteWheelPicker( + items = amPm, + initialValue = if ((value?.hour ?: 0) < 12) "AM" else "PM", + onSelectedValueChange = { candidateAmPm = it }, + visibleItemsCount = 9, + ring = false, + userScrollEnabled = userScrollEnabled + ) + } + } + } + } + Box(Modifier.height(1.dp).fillMaxWidth().background(FluentTheme.colors.stroke.divider.default)) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + SubtleButton( + modifier = Modifier.padding(4.dp).height(36.dp).weight(1f), + onClick = { + if (is12hour) { + onValueChange( + LocalTime( + hour12to24(candidateHour, candidateAmPm == "AM"), + candidateMinutes, + candidateSeconds + ) + ) + } else { + onValueChange(LocalTime(candidateHour, candidateMinutes, candidateSeconds)) + } + open = false + }) { + FontIcon( + type = FontIconPrimitive.Accept, + contentDescription = "Accept" + ) + } + SubtleButton( + modifier = Modifier.padding(4.dp).height(38.dp).weight(1f), + onClick = { + open = false + }) { + FontIcon( + type = FontIconPrimitive.Cancel, + contentDescription = "Cancel" + ) + } + } + } + } + } + ) { + TimePickerButton( + modifier = modifier, + value = value, + is12Hour = is12hour, + disabled = disabled, + onClick = { open = true } + ) + } +} + +@Composable +private fun BoxScope.BaseIndicator(is24Hour: Boolean) { + Box( + Modifier.height(40.dp).fillMaxWidth().align(Alignment.Center) + ) { + Box( + Modifier.fillMaxSize().padding(horizontal = 6.dp).background( + color = FluentTheme.colors.fillAccent.default, + shape = FluentTheme.shapes.control + ) + ) + Row { + Spacer(Modifier.weight(1f)) + Box( + Modifier.width(1.dp).height(40.dp) + .background(FluentTheme.colors.stroke.control.onAccentTertiary) + ) + Spacer(Modifier.weight(1f)) + if (is24Hour) { + Box( + Modifier.width(1.dp).height(40.dp) + .background(FluentTheme.colors.stroke.control.onAccentTertiary) + ) + Spacer(Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun TimePickerButton( + modifier: Modifier, + value: LocalTime?, + is12Hour: Boolean, + disabled: Boolean, + onClick: () -> Unit +) { + Button( + modifier = modifier.width(300.dp), + onClick = onClick, + disabled = disabled + ) { + val labelColor = when { + disabled -> FluentTheme.colors.text.text.tertiary + value == null -> FluentTheme.colors.text.text.secondary + else -> FluentTheme.colors.text.text.primary + } + val hour = value?.let { + if (is12Hour) hour24to12(value.hour) + else value.hour + } + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + text = hour?.toString() ?: "hour", + color = labelColor + ) + Box(Modifier.size(1.dp, 30.dp).background(FluentTheme.colors.stroke.control.default)) + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + text = value?.minute?.let { formatMinute(it) } ?: "minute", + color = labelColor + ) + if (is12Hour) { + val isAm = value == null || value.hour < 12 + + // TODO[i18n](TimePicker) + Box(Modifier.size(1.dp, 30.dp).background(FluentTheme.colors.stroke.control.default)) + Text( + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + text = if (isAm) "AM" else "PM", + color = labelColor + ) + } + } +} + +@Composable +private fun InfiniteWheelPicker( + items: List, + visibleItemsCount: Int, + initialValue: String?, + onSelectedValueChange: (String) -> Unit, + ring: Boolean, + itemHeight: Dp = 40.dp, + userScrollEnabled: Boolean, + modifier: Modifier = Modifier +) { + require(visibleItemsCount % 2 == 1) { "visibleItemsCount must be odd" } + + // Creates a virtual list (big enough to simulate infinite scroll) + val virtualListSize = if (ring) items.size * 100 else items.size + val initialValueIndex = remember(items, initialValue) { + if (initialValue != null) items.indexOf(initialValue) + else 0 + } + val centerOffset = (visibleItemsCount - 1) / 2 + val actualCenterOffset = if (ring) centerOffset else 0 + val contentPadding = if (items.size < visibleItemsCount) itemHeight * (visibleItemsCount / 2) else 0.dp + + // Set the initial position to the center of the list + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = + if (ring) + (virtualListSize / 2 - centerOffset + initialValueIndex).coerceAtLeast(0) + else initialValueIndex + ) + + // 当前选中的值 + val selectedValue by remember { + derivedStateOf { + val centerIndex = listState.firstVisibleItemIndex + actualCenterOffset + items[centerIndex % items.size] + } + } + + LaunchedEffect(selectedValue) { + onSelectedValueChange(selectedValue) + } + + val interactionSource = remember { MutableInteractionSource() } + val hovered by interactionSource.collectIsHoveredAsState() + + Box(modifier.hoverable(interactionSource)) { + val scrollScope = rememberCoroutineScope() + var currentTargetScrollIndex by remember { mutableStateOf(0) } + + fun scroll(offset: Int) { + scrollScope.launch { + val target = if (listState.isScrollInProgress) { + currentTargetScrollIndex + offset + } else { + listState.firstVisibleItemIndex + offset + } + currentTargetScrollIndex = target + listState.animateScrollToItem(target.fastCoerceAtLeast(0)) + } + } + + fun next() { + scroll(1) + } + + fun previous() { + scroll(-1) + } + + fun nextPage() { + scroll(visibleItemsCount) + } + + fun previousPage() { + scroll(-visibleItemsCount) + } + + val focusRequester = remember { FocusRequester() } + + LazyColumn( + state = listState, + flingBehavior = rememberSnapFlingBehavior(listState), // Only use fling behavior for touchpad gesture. + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = PaddingValues(vertical = contentPadding), + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + awaitEachGesture { + val event = awaitPointerEvent() + if (event.type == PointerEventType.Scroll) { + val change = event.changes.first() + // TODO[optimize](time-picker): Detect gesture scroll + if (change.scrollDelta.y > 0F) { + next() + } else if (change.scrollDelta.y < 0F) { + previous() + } + } else if (event.type == PointerEventType.Enter) { // Focus for receiving key input + focusRequester.requestFocus() + } + } + } + .focusable().focusRequester(focusRequester) + .onKeyEvent { + if (it.type == KeyEventType.KeyDown) { + when (it.key) { + Key.DirectionUp -> previous() + Key.DirectionDown -> next() + Key.PageUp -> previousPage() + Key.PageDown -> nextPage() + else -> return@onKeyEvent false + } + return@onKeyEvent true + } + return@onKeyEvent false + }, + userScrollEnabled = userScrollEnabled // TODO[optimize](time-picker): Detect gesture scroll + ) { + items(virtualListSize) { index -> + val actualIndex = index % items.size + val itemValue = items[actualIndex] + // Hide to leave space for caret buttons + val hide = false + /*val hide = if (hovered) { + if (listState.isScrollInProgress) { + index <= listState.firstVisibleItemScrollOffset || index >= currentTargetScrollIndex + visibleItemsCount - 1 + } else { + index <= listState.firstVisibleItemIndex || index >= listState.firstVisibleItemIndex + visibleItemsCount - 1 + } + } else { + false + }*/ + + SubtleButton( + modifier = Modifier.height(itemHeight).fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp) + .graphicsLayer { + alpha = if (hide) 0f else 1f + }, + onClick = { + // Select this item + scrollScope.launch { + listState.animateScrollToItem(index - actualCenterOffset) + } + } + ) { + // TODO[optimize](time-picker): Use brush to implement color inversion? + + // Inverse color after scrolling half of the item + val firstOffset = with(LocalDensity.current) { + listState.firstVisibleItemScrollOffset.toDp() + } + val offset = if (firstOffset >= itemHeight / 2) 1 else 0 + val isCentered = (listState.firstVisibleItemIndex + actualCenterOffset + offset) == index + + val textColor = if (isCentered) FluentTheme.colors.text.onAccent.primary + else FluentTheme.colors.text.text.primary + + Text( + text = itemValue, + style = FluentTheme.typography.body, + color = textColor, + textAlign = TextAlign.Center + ) + } + } + } + if (hovered) { + CaretButton( + type = FontIconPrimitive.CaretUp, + contentDescription = "Up", + onClick = { previous() }, + modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth() + ) + + CaretButton( + type = FontIconPrimitive.CaretDown, + contentDescription = "Down", + onClick = { next() }, + modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth() + ) + } + } +} + +@Composable +private fun CaretButton( + type: FontIconPrimitive, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = modifier.height(40.dp) + .background(FluentTheme.colors.background.acrylic.default) + .clickable(onClick = onClick, interactionSource = interactionSource, indication = null), + contentAlignment = Alignment.Center + ) { + val pressed by interactionSource.collectIsPressedAsState() + val hovered by interactionSource.collectIsHoveredAsState() + + val size by animateFloatAsState( + if (pressed) 7f else 8f, + tween(FluentDuration.QuickDuration, easing = FluentEasing.FastInvokeEasing) + ) + val color = if (hovered) FluentTheme.colors.text.text.secondary + else FluentTheme.colors.controlStrong.default + + FontIcon(type, contentDescription, size = FontIconSize(size), tint = color) + } +} + +private fun hour24to12(value: Int): Int = when { + value == 0 -> 12 // 0 -> 12AM + value <= 12 -> value + else -> value - 12 +} + +private fun hour12to24(value: Int, isAm: Boolean): Int { + val value = when { + value == 12 -> 0 + else -> value + } + return if (isAm) value else value + 12 +} + +private val hours24 = (0..23).map { it.toString() } +private val hours12 = (1..12).map { it.toString() } + +private val minutes = (0..59).map(::formatMinute) +private val amPm = listOf("AM", "PM") + +private fun formatMinute(value: Int): String = + if (value < 10) "0$value" + else value.toString() + diff --git a/fluent/src/desktopMain/kotlin/io/github/composefluent/component/TimePicker.desktop.kt b/fluent/src/desktopMain/kotlin/io/github/composefluent/component/TimePicker.desktop.kt new file mode 100644 index 00000000..641380d8 --- /dev/null +++ b/fluent/src/desktopMain/kotlin/io/github/composefluent/component/TimePicker.desktop.kt @@ -0,0 +1,23 @@ +package io.github.composefluent.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.datetime.LocalTime + +@Composable +actual fun TimePicker( + value: LocalTime?, + onValueChange: (LocalTime?) -> Unit, + modifier: Modifier, + is12hour: Boolean, + disabled: Boolean +) { + TimePickerImpl( + value = value, + onValueChange = onValueChange, + modifier = modifier, + is12hour = is12hour, + disabled = disabled, + userScrollEnabled = false + ) +} \ No newline at end of file diff --git a/fluent/src/iosMain/kotlin/io/github/composefluent/component/TimePicker.ios.kt b/fluent/src/iosMain/kotlin/io/github/composefluent/component/TimePicker.ios.kt new file mode 100644 index 00000000..dc7c408c --- /dev/null +++ b/fluent/src/iosMain/kotlin/io/github/composefluent/component/TimePicker.ios.kt @@ -0,0 +1,21 @@ +package io.github.composefluent.component + +import androidx.compose.runtime.Composable + +@Composable +actual fun TimePicker( + value: kotlinx.datetime.LocalTime?, + onValueChange: (kotlinx.datetime.LocalTime?) -> Unit, + modifier: androidx.compose.ui.Modifier, + is12hour: Boolean, + disabled: Boolean +) { + TimePickerImpl( + value = value, + onValueChange = onValueChange, + modifier = modifier, + is12hour = is12hour, + disabled = disabled, + userScrollEnabled = true + ) +} \ No newline at end of file diff --git a/fluent/src/jsAndWasmMain/kotlin/io/github/composefluent/component/TimePicker.jsAndWasm.kt b/fluent/src/jsAndWasmMain/kotlin/io/github/composefluent/component/TimePicker.jsAndWasm.kt new file mode 100644 index 00000000..641380d8 --- /dev/null +++ b/fluent/src/jsAndWasmMain/kotlin/io/github/composefluent/component/TimePicker.jsAndWasm.kt @@ -0,0 +1,23 @@ +package io.github.composefluent.component + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import kotlinx.datetime.LocalTime + +@Composable +actual fun TimePicker( + value: LocalTime?, + onValueChange: (LocalTime?) -> Unit, + modifier: Modifier, + is12hour: Boolean, + disabled: Boolean +) { + TimePickerImpl( + value = value, + onValueChange = onValueChange, + modifier = modifier, + is12hour = is12hour, + disabled = disabled, + userScrollEnabled = false + ) +} \ No newline at end of file diff --git a/gallery/build.gradle.kts b/gallery/build.gradle.kts index 698e0364..b354b93b 100644 --- a/gallery/build.gradle.kts +++ b/gallery/build.gradle.kts @@ -40,6 +40,7 @@ kotlin { implementation(project(":fluent-icons-extended")) implementation(compose.uiUtil) implementation(libs.highlights) + implementation(libs.kotlinx.datetime) implementation(project(":source-generated")) } kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") diff --git a/gallery/src/commonMain/kotlin/io/github/composefluent/gallery/screen/datetime/TimePickerScreen.kt b/gallery/src/commonMain/kotlin/io/github/composefluent/gallery/screen/datetime/TimePickerScreen.kt new file mode 100644 index 00000000..24323b21 --- /dev/null +++ b/gallery/src/commonMain/kotlin/io/github/composefluent/gallery/screen/datetime/TimePickerScreen.kt @@ -0,0 +1,67 @@ +@file:OptIn(ExperimentalFluentApi::class) + +package io.github.composefluent.gallery.screen.datetime + +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 io.github.composefluent.ExperimentalFluentApi +import io.github.composefluent.component.TimePicker +import io.github.composefluent.gallery.annotation.Component +import io.github.composefluent.gallery.annotation.Sample +import io.github.composefluent.gallery.component.ComponentPagePath +import io.github.composefluent.gallery.component.GalleryPage +import io.github.composefluent.source.generated.FluentSourceFile +import kotlinx.datetime.LocalTime + +@Component( + description = "A control that lets a user pick a time value." +) +@Composable +fun TimePickerScreen() { + GalleryPage( + title = "TimePicker", + description = "Use a TimePicker to let users set a time in your app, for example to set a reminder. The TimePicker displays three controls for hour, minute, and AM/PM. These controls are easy to use with touch or mouse.", + componentPath = FluentSourceFile.TimePicker, + galleryPath = ComponentPagePath.TimePickerScreen + ) { + + Section( + title = "A TimePicker using a 12-hour clock", + sourceCode = sourceCodeOfTimePicker12Sample + ) { + TimePicker12Sample() + } + Section( + title = "A TimePicker using a 24-hour clock", + sourceCode = sourceCodeOfTimePicker24Sample + ) { + TimePicker24Sample() + } + + } +} + +@Sample +@Composable +private fun TimePicker12Sample() { + var value by remember { mutableStateOf(null) } + TimePicker( + value = value, + onValueChange = { value = it }, + is12hour = true + ) +} + +@Sample +@Composable +private fun TimePicker24Sample() { + var value by remember { mutableStateOf(null) } + TimePicker( + value = value, + onValueChange = { value = it }, + is12hour = false + ) +} \ No newline at end of file