diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/background/Layer.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/background/Layer.kt index 99d036f9..afc9acfe 100644 --- a/fluent/src/commonMain/kotlin/com/konyaco/fluent/background/Layer.kt +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/background/Layer.kt @@ -172,18 +172,26 @@ private fun Modifier.layer( @Immutable @JvmInline internal value class BackgroundPaddingShape(private val borderShape: CornerBasedShape) : Shape { - override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline { return with(density) { - createInnerOutline(size, density, layoutDirection, borderShape.calculateBorderPadding(density)) + LayerShapeHelper.createInnerOutline(borderShape, size, density, layoutDirection, borderShape.calculateBorderPadding(density)) } } +} + +internal object LayerShapeHelper { /** * Fork from [CornerBasedShape.createOutline], add padding to corner size and outline rect size. */ - private fun createInnerOutline(size: Size, density: Density, layoutDirection: LayoutDirection, paddingPx: Float) = - borderShape.run { + fun createInnerOutline( + outsideShape: CornerBasedShape, + size: Size, + density: Density, + layoutDirection: LayoutDirection, + paddingPx: Float, + ): Outline { + return outsideShape.run { val cornerPaddingPx = if (this is CutCornerShape) { /** padding for cut corner shape */ (paddingPx / sqrt(2f)).toInt().toFloat() @@ -192,10 +200,10 @@ internal value class BackgroundPaddingShape(private val borderShape: CornerBased } val innerSize = Size(size.width - 2 * paddingPx, size.height - 2 * paddingPx) /** add padding to corner size */ - var topStart = (borderShape.topStart.toPx(size, density) - cornerPaddingPx).coerceAtLeast(0f) - var topEnd = (borderShape.topEnd.toPx(size, density) - cornerPaddingPx).coerceAtLeast(0f) - var bottomEnd = (borderShape.bottomEnd.toPx(size, density) - cornerPaddingPx).coerceAtLeast(0f) - var bottomStart = (borderShape.bottomStart.toPx(size, density) - cornerPaddingPx).coerceAtLeast(0f) + var topStart = (outsideShape.topStart.toPx(size, density) - cornerPaddingPx).coerceAtLeast(0f) + var topEnd = (outsideShape.topEnd.toPx(size, density) - cornerPaddingPx).coerceAtLeast(0f) + var bottomEnd = (outsideShape.bottomEnd.toPx(size, density) - cornerPaddingPx).coerceAtLeast(0f) + var bottomStart = (outsideShape.bottomStart.toPx(size, density) - cornerPaddingPx).coerceAtLeast(0f) val minDimension = innerSize.minDimension if (topStart + bottomStart > minDimension) { val scale = minDimension / (topStart + bottomStart) @@ -227,6 +235,7 @@ internal value class BackgroundPaddingShape(private val borderShape: CornerBased is Outline.Generic -> Outline.Generic(oldOutline.path.apply { translate(Offset(paddingPx, paddingPx)) }) } } + } } /** @@ -234,33 +243,33 @@ internal value class BackgroundPaddingShape(private val borderShape: CornerBased * when density is not integer or `(density % 1) < 0.5` */ @Stable -private fun calcPadding(density: Density): Dp { +private fun calcPadding(density: Density, borderSize: Dp): Dp { val remainder = density.density % 1f return with(density) { when { - remainder == 0f -> 1.dp - else -> (1.dp.toPx() - remainder + 1).toDp() + remainder == 0f -> borderSize + else -> (borderSize.toPx() - remainder + 1).toDp() } } } @Stable -private fun calcCircularPadding(density: Density): Dp { +private fun calcCircularPadding(density: Density, borderSize: Dp): Dp { val remainder = density.density % 1f return with(density) { - if (remainder == 0f) 1.dp - else (1.dp.toPx() - remainder + 1).toDp() + if (remainder == 0f) borderSize + else (borderSize.toPx() - remainder + 1).toDp() } } -internal fun Shape.calculateBorderPadding(density: Density): Float { +internal fun Shape.calculateBorderPadding(density: Density, borderSize: Dp = 1.dp): Float { val circular = this == CircleShape return with(density) { when { - circular -> calcCircularPadding(density) - else -> calcPadding(density) + circular -> calcCircularPadding(density, borderSize) + else -> calcPadding(density, borderSize) }.toPx() } } \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/FlipView.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/FlipView.kt new file mode 100644 index 00000000..c6d95302 --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/FlipView.kt @@ -0,0 +1,410 @@ +package com.konyaco.fluent.component + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.TargetedFlingBehavior +import androidx.compose.foundation.gestures.snapping.SnapPosition +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.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.PagerDefaults +import androidx.compose.foundation.pager.PagerScope +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.VerticalPager +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.konyaco.fluent.ExperimentalFluentApi +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.LocalContentAlpha +import com.konyaco.fluent.LocalContentColor +import com.konyaco.fluent.LocalTextStyle +import com.konyaco.fluent.animation.FluentDuration +import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.background.Material +import com.konyaco.fluent.background.MaterialContainer +import com.konyaco.fluent.background.MaterialContainerScope +import com.konyaco.fluent.background.MaterialDefaults +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.filled.CaretDown +import com.konyaco.fluent.icons.filled.CaretLeft +import com.konyaco.fluent.icons.filled.CaretRight +import com.konyaco.fluent.icons.filled.CaretUp +import com.konyaco.fluent.scheme.PentaVisualScheme +import com.konyaco.fluent.scheme.VisualStateScheme +import com.konyaco.fluent.scheme.collectVisualState +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFluentApi::class) +@Composable +fun HorizontalFlipView( + state: PagerState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + pageButtonColors: VisualStateScheme<PageButtonColor> = FlipViewDefaults.pageButtonColors(), + contentPadding: PaddingValues = PaddingValues(0.dp), + pageSize: PageSize = PageSize.Fill, + beyondViewportPageCount: Int = PagerDefaults.BeyondViewportPageCount, + pageSpacing: Dp = 0.dp, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + flingBehavior: TargetedFlingBehavior = PagerDefaults.flingBehavior(state = state), + userScrollEnabled: Boolean = true, + reverseLayout: Boolean = false, + key: ((index: Int) -> Any)? = null, + pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection( + state, + Orientation.Horizontal + ), + snapPosition: SnapPosition = SnapPosition.Start, + pageContent: @Composable PagerScope.(index: Int) -> Unit +) { + val scope = rememberCoroutineScope() + HorizontalFlipViewContainer( + nextPageVisible = remember(state) { derivedStateOf { state.currentPage < state.pageCount - 1 } }.value, + previousPageVisible = remember(state) { derivedStateOf { state.currentPage > 0 } }.value, + onNextPageClick = { + scope.launch { + state.animateScrollToPage( + page = state.currentPage + 1, + animationSpec = FlipViewDefaults.scrollAnimationSpec() + ) + } + }, + onPreviousPageClick = { + scope.launch { + state.animateScrollToPage( + page = state.currentPage - 1, + animationSpec = FlipViewDefaults.scrollAnimationSpec() + ) + } + }, + modifier = modifier, + enabled = enabled, + pageButtonColors = pageButtonColors + ) { + HorizontalPager( + state = state, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + pageSize = pageSize, + beyondViewportPageCount = beyondViewportPageCount, + pageSpacing = pageSpacing, + verticalAlignment = verticalAlignment, + flingBehavior = flingBehavior, + userScrollEnabled = userScrollEnabled, + reverseLayout = reverseLayout, + key = key, + pageNestedScrollConnection = pageNestedScrollConnection, + snapPosition = snapPosition, + pageContent = pageContent + ) + } +} + +@OptIn(ExperimentalFluentApi::class) +@Composable +fun VerticalFlipView( + state: PagerState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + pageButtonColors: VisualStateScheme<PageButtonColor> = FlipViewDefaults.pageButtonColors(), + contentPadding: PaddingValues = PaddingValues(0.dp), + pageSize: PageSize = PageSize.Fill, + beyondViewportPageCount: Int = PagerDefaults.BeyondViewportPageCount, + pageSpacing: Dp = FlipViewDefaults.verticalPageSpacing, + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + flingBehavior: TargetedFlingBehavior = PagerDefaults.flingBehavior(state = state), + userScrollEnabled: Boolean = true, + reverseLayout: Boolean = false, + key: ((index: Int) -> Any)? = null, + pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection( + state, + Orientation.Horizontal + ), + snapPosition: SnapPosition = SnapPosition.Start, + pageContent: @Composable PagerScope.(index: Int) -> Unit +) { + val scope = rememberCoroutineScope() + VerticalFlipViewContainer( + nextPageVisible = remember(state) { derivedStateOf { state.currentPage < state.pageCount - 1 } }.value, + previousPageVisible = remember(state) { derivedStateOf { state.currentPage > 0 } }.value, + onNextPageClick = { + scope.launch { + state.animateScrollToPage( + page = state.currentPage + 1, + animationSpec = FlipViewDefaults.scrollAnimationSpec() + ) + } + }, + onPreviousPageClick = { + scope.launch { + state.animateScrollToPage( + page = state.currentPage - 1, + animationSpec = FlipViewDefaults.scrollAnimationSpec() + ) + } + }, + modifier = modifier, + enabled = enabled, + pageButtonColors = pageButtonColors + ) { + VerticalPager( + state = state, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + pageSize = pageSize, + beyondViewportPageCount = beyondViewportPageCount, + pageSpacing = pageSpacing, + horizontalAlignment = horizontalAlignment, + flingBehavior = flingBehavior, + userScrollEnabled = userScrollEnabled, + reverseLayout = reverseLayout, + key = key, + pageNestedScrollConnection = pageNestedScrollConnection, + snapPosition = snapPosition, + pageContent = pageContent + ) + } +} + +@ExperimentalFluentApi +@Composable +fun VerticalFlipViewContainer( + onNextPageClick: () -> Unit, + onPreviousPageClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + nextPageVisible: Boolean = true, + previousPageVisible: Boolean = true, + pageButtonColors: VisualStateScheme<PageButtonColor> = FlipViewDefaults.pageButtonColors(), + content: @Composable () -> Unit +) { + FlipViewContainer( + onNextPageClick = onNextPageClick, + onPreviousPageClick = onPreviousPageClick, + modifier = modifier, + isVertical = true, + enabled = enabled, + nextPageVisible = nextPageVisible, + previousPageVisible = previousPageVisible, + pageButtonColors = pageButtonColors, + content = content + ) +} + +@ExperimentalFluentApi +@Composable +fun HorizontalFlipViewContainer( + onNextPageClick: () -> Unit, + onPreviousPageClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + nextPageVisible: Boolean = true, + previousPageVisible: Boolean = true, + pageButtonColors: VisualStateScheme<PageButtonColor> = FlipViewDefaults.pageButtonColors(), + content: @Composable () -> Unit +) { + FlipViewContainer( + onNextPageClick = onNextPageClick, + onPreviousPageClick = onPreviousPageClick, + modifier = modifier, + isVertical = false, + enabled = enabled, + pageButtonColors = pageButtonColors, + nextPageVisible = nextPageVisible, + previousPageVisible = previousPageVisible, + content = content + ) +} + +@ExperimentalFluentApi +@Composable +private fun FlipViewContainer( + onNextPageClick: () -> Unit, + onPreviousPageClick: () -> Unit, + nextPageVisible: Boolean, + previousPageVisible: Boolean, + modifier: Modifier, + isVertical: Boolean, + enabled: Boolean, + pageButtonColors: VisualStateScheme<PageButtonColor>, + content: @Composable () -> Unit +) { + MaterialContainer(modifier = modifier.clip(FluentTheme.shapes.overlay)) { + val interactionSource = remember { MutableInteractionSource() } + val isHovered = interactionSource.collectIsHoveredAsState() + Box( + propagateMinConstraints = true, + modifier = Modifier.behindMaterial() + .hoverable(interactionSource, enabled = enabled) + ) { + content() + } + if (isVertical) { + PageButton( + colors = pageButtonColors, + isVertical = true, + vector = Icons.Filled.CaretDown, + glyph = '\uEDDC', + onClick = onNextPageClick, + visible = nextPageVisible && isHovered.value, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 1.dp) + .hoverable(interactionSource), + ) + PageButton( + colors = pageButtonColors, + isVertical = true, + vector = Icons.Filled.CaretUp, + glyph = '\uEDDB', + onClick = onPreviousPageClick, + visible = previousPageVisible && isHovered.value, + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 1.dp) + .hoverable(interactionSource), + ) + } else { + PageButton( + colors = pageButtonColors, + isVertical = false, + vector = Icons.Filled.CaretRight, + glyph = '\uEDDA', + onClick = onNextPageClick, + visible = nextPageVisible && isHovered.value, + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 1.dp) + .hoverable(interactionSource), + ) + + PageButton( + colors = pageButtonColors, + isVertical = false, + vector = Icons.Filled.CaretLeft, + glyph = '\uEDD9', + onClick = onPreviousPageClick, + visible = previousPageVisible && isHovered.value, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 1.dp) + .hoverable(interactionSource), + ) + } + } +} + +@OptIn(ExperimentalFluentApi::class) +@Composable +private fun MaterialContainerScope.PageButton( + colors: VisualStateScheme<PageButtonColor>, + modifier: Modifier = Modifier, + isVertical: Boolean, + visible: Boolean, + onClick: () -> Unit, + glyph: Char = '\uEDDA', + vector: ImageVector = Icons.Filled.CaretRight, +) { + if (visible) { + val interactionSource = remember { MutableInteractionSource() } + val currentColor = colors.schemeFor(interactionSource.collectVisualState(false)) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(FluentTheme.shapes.control) + .materialOverlay(currentColor.background) + .size(if (isVertical) VerticalButtonSize else HorizontalButtonSize) + .clickable( + indication = null, + interactionSource = interactionSource, + onClick = onClick + ) + ) { + CompositionLocalProvider( + LocalContentColor provides currentColor.contentColor, + LocalContentAlpha provides currentColor.contentColor.alpha, + LocalTextStyle provides LocalTextStyle.current.copy(color = currentColor.contentColor) + ) { + val isPressed = interactionSource.collectIsPressedAsState() + val scale = animateFloatAsState( + targetValue = if (isPressed.value) { 7/8f } else 1f, + animationSpec = tween(FluentDuration.ShortDuration, easing = FluentEasing.FastInvokeEasing) + ) + FontIcon( + glyph = glyph, + vector = vector, + contentDescription = null, + iconSize = 8.sp, + vectorSize = 14.dp, + modifier = Modifier + .graphicsLayer { + scaleX = scale.value + scaleY = scale.value + transformOrigin = TransformOrigin(0.5f, 0.5f) + } + ) + } + } + } +} + +data class PageButtonColor( + val background: Material, + val contentColor: Color +) + +object FlipViewDefaults { + + val verticalPageSpacing = 4.dp + + @Composable + @Stable + fun pageButtonColors( + default: PageButtonColor = PageButtonColor( + background = MaterialDefaults.acrylicDefault(), + contentColor = FluentTheme.colors.controlStrong.default + ), + hovered: PageButtonColor = PageButtonColor( + background = MaterialDefaults.acrylicDefault(), + contentColor = FluentTheme.colors.text.text.secondary + ), + pressed: PageButtonColor = hovered, + ) = PentaVisualScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = default + ) + + fun scrollAnimationSpec() = + tween<Float>(FluentDuration.LongDuration, easing = FluentEasing.FastInvokeEasing) +} + +private val VerticalButtonSize = DpSize(38.dp, 16.dp) +private val HorizontalButtonSize = DpSize(16.dp, 38.dp) \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/GridViewItem.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/GridViewItem.kt new file mode 100644 index 00000000..e58c7537 --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/GridViewItem.kt @@ -0,0 +1,255 @@ +package com.konyaco.fluent.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.background.LayerShapeHelper +import com.konyaco.fluent.background.calculateBorderPadding +import com.konyaco.fluent.scheme.PentaVisualScheme +import com.konyaco.fluent.scheme.VisualStateScheme +import com.konyaco.fluent.scheme.collectVisualState +import kotlin.jvm.JvmInline + +@Composable +fun MultiSelectGridViewItem( + selected: Boolean, + onSelectedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: VisualStateScheme<GridViewItemColor> = if (selected) { + GridViewItemDefaults.selectedColors() + } else { + GridViewItemDefaults.defaultColors() + }, + checkBoxColorScheme: VisualStateScheme<CheckBoxColor> = if (selected) { + GridViewItemDefaults.selectedCheckBoxColors() + } else { + GridViewItemDefaults.defaultCheckBoxColors() + }, + interactionSource: MutableInteractionSource? = null, + content: @Composable () -> Unit +) { + GridViewItem( + selected = selected, + onSelectedChange = onSelectedChange, + modifier = modifier, + enabled = enabled, + interactionSource = interactionSource, + colors = colors, + content = { content() }, + overlay = { + CheckBox( + checked = selected, + onCheckStateChange = onSelectedChange, + colors = checkBoxColorScheme, + enabled = enabled, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 4.dp, end = 4.dp) + ) + } + ) +} + +@Composable +fun GridViewItem( + selected: Boolean, + onSelectedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + interactionSource: MutableInteractionSource? = null, + colors: VisualStateScheme<GridViewItemColor> = if (selected) { + GridViewItemDefaults.selectedColors() + } else { + GridViewItemDefaults.defaultColors() + }, + content: @Composable () -> Unit +) { + GridViewItem( + selected = selected, + onSelectedChange = onSelectedChange, + modifier = modifier, + enabled = enabled, + interactionSource = interactionSource, + colors = colors, + content = { content() }, + overlay = null + ) +} + +@Stable +data class GridViewItemColor( + val borderColor: Color, + val backgroundColor: Color +) + +typealias GridViewItemColorScheme = PentaVisualScheme<GridViewItemColor> + +object GridViewItemDefaults { + + val spacing = 4.dp + + @Stable + @Composable + fun defaultColors( + default: GridViewItemColor = GridViewItemColor( + borderColor = Color.Transparent, + backgroundColor = FluentTheme.colors.subtleFill.transparent + ), + hovered: GridViewItemColor = GridViewItemColor( + borderColor = FluentTheme.colors.stroke.control.onAccentTertiary, + backgroundColor = FluentTheme.colors.subtleFill.secondary + ), + pressed: GridViewItemColor = GridViewItemColor( + borderColor = Color.Transparent, + backgroundColor = FluentTheme.colors.subtleFill.tertiary + ), + disabled: GridViewItemColor = default, + ): GridViewItemColorScheme = PentaVisualScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) + + @Stable + @Composable + fun selectedColors( + default: GridViewItemColor = GridViewItemColor( + borderColor = FluentTheme.colors.fillAccent.default, + backgroundColor = FluentTheme.colors.subtleFill.tertiary + ), + hovered: GridViewItemColor = GridViewItemColor( + borderColor = FluentTheme.colors.fillAccent.secondary, + backgroundColor = FluentTheme.colors.subtleFill.secondary + ), + pressed: GridViewItemColor = GridViewItemColor( + borderColor = FluentTheme.colors.fillAccent.tertiary, + backgroundColor = FluentTheme.colors.subtleFill.tertiary + ), + disabled: GridViewItemColor = GridViewItemColor( + borderColor = FluentTheme.colors.fillAccent.disabled, + backgroundColor = FluentTheme.colors.subtleFill.secondary + ), + ): GridViewItemColorScheme = PentaVisualScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) + + //TODO ColorOnImage + @Stable + @Composable + fun defaultCheckBoxColors() = CheckBoxDefaults.defaultCheckBoxColors() + + //TODO ColorOnImage + @Stable + @Composable + fun selectedCheckBoxColors() = CheckBoxDefaults.selectedCheckBoxColors() +} + +@Composable +private fun GridViewItem( + selected: Boolean, + onSelectedChange: (Boolean) -> Unit, + modifier: Modifier, + enabled: Boolean, + colors: VisualStateScheme<GridViewItemColor>, + interactionSource: MutableInteractionSource?, + overlay: (@Composable BoxScope.() -> Unit)?, + content: @Composable BoxScope.() -> Unit +) { + val itemShape = FluentTheme.shapes.control + val targetInteractionSource = interactionSource ?: remember { MutableInteractionSource() } + val currentColor = colors.schemeFor(targetInteractionSource.collectVisualState(!enabled)) + Box( + propagateMinConstraints = true, + modifier = modifier + .clip(itemShape) + .selectable( + enabled = enabled, + selected = selected, + onClick = { onSelectedChange(!selected) }, + indication = null, + interactionSource = targetInteractionSource + ) + .border(SelectedItemBorderSize, currentColor.borderColor, shape = itemShape) + ) { + Box( + propagateMinConstraints = true, + modifier = if (selected) { + val innerShape = remember(itemShape) { + if (itemShape is CornerBasedShape) { + GridViewItemInnerShape(itemShape) + } else { + itemShape + } + } + Modifier + .clip(innerShape) + .background( + color = currentColor.backgroundColor, + shape = innerShape + ) + } else { + Modifier.background( + color = currentColor.backgroundColor, + shape = itemShape + ) + } + ) { + content() + } + if (overlay != null) { + Box( + content = overlay, + modifier = Modifier + .matchParentSize() + ) + } + + } +} + +@Stable +@Immutable +@JvmInline +private value class GridViewItemInnerShape(private val itemShape: CornerBasedShape): Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + return LayerShapeHelper.createInnerOutline( + outsideShape = itemShape, + size = size, + density = density, + layoutDirection = layoutDirection, + paddingPx = itemShape.calculateBorderPadding(density, InnerPaddingSize) + ) + } +} + +private val SelectedItemBorderSize = 2.dp +private val InnerPaddingSize = 3.dp \ No newline at end of file diff --git a/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/PipsPager.kt b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/PipsPager.kt new file mode 100644 index 00000000..d574d7c8 --- /dev/null +++ b/fluent/src/commonMain/kotlin/com/konyaco/fluent/component/PipsPager.kt @@ -0,0 +1,441 @@ +package com.konyaco.fluent.component + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy +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.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.konyaco.fluent.ExperimentalFluentApi +import com.konyaco.fluent.FluentTheme +import com.konyaco.fluent.LocalContentAlpha +import com.konyaco.fluent.LocalContentColor +import com.konyaco.fluent.LocalTextStyle +import com.konyaco.fluent.animation.FluentDuration +import com.konyaco.fluent.animation.FluentEasing +import com.konyaco.fluent.icons.Icons +import com.konyaco.fluent.icons.filled.CaretDown +import com.konyaco.fluent.icons.filled.CaretLeft +import com.konyaco.fluent.icons.filled.CaretRight +import com.konyaco.fluent.icons.filled.CaretUp +import com.konyaco.fluent.scheme.PentaVisualScheme +import com.konyaco.fluent.scheme.VisualState +import com.konyaco.fluent.scheme.VisualStateScheme +import com.konyaco.fluent.scheme.collectVisualState +import kotlinx.coroutines.launch +import kotlin.math.abs + +@Composable +fun HorizontalPipsPager( + state: PagerState, + modifier: Modifier = Modifier, + scrollAnimationSpec: AnimationSpec<Float> = FlipViewDefaults.scrollAnimationSpec(), + visibleCount: Int = 5, + enabled: Boolean = true, + pageButtonVisibleStrategy: PageButtonVisibleStrategy = PageButtonVisibleStrategy.Never, + pipsColors: VisualStateScheme<Color> = PipsPagerDefaults.pipsColors(), +) { + val scope = rememberCoroutineScope() + PipsPager( + selectedIndex = state.currentPage, + onSelectedIndexChange = { + scope.launch { + if (abs(state.currentPage - it) == 1) { + state.animateScrollToPage(it, animationSpec = scrollAnimationSpec) + } else { + state.scrollToPage(it) + } + } + }, + count = state.pageCount, + isVertical = false, + pipsColors = pipsColors, + visibleCount = visibleCount, + pageButtonVisibleStrategy = pageButtonVisibleStrategy, + enabled = enabled, + modifier = modifier + ) +} + +@Composable +fun VerticalPipsPager( + state: PagerState, + modifier: Modifier = Modifier, + scrollAnimationSpec: AnimationSpec<Float> = FlipViewDefaults.scrollAnimationSpec(), + visibleCount: Int = 5, + enabled: Boolean = true, + pageButtonVisibleStrategy: PageButtonVisibleStrategy = PageButtonVisibleStrategy.Never, + pipsColors: VisualStateScheme<Color> = PipsPagerDefaults.pipsColors(), +) { + val scope = rememberCoroutineScope() + PipsPager( + selectedIndex = state.currentPage, + onSelectedIndexChange = { + scope.launch { + if (abs(state.currentPage - it) == 1) { + state.animateScrollToPage(it, animationSpec = scrollAnimationSpec) + } else { + state.scrollToPage(it) + } + } + }, + count = state.pageCount, + isVertical = true, + pipsColors = pipsColors, + visibleCount = visibleCount, + pageButtonVisibleStrategy = pageButtonVisibleStrategy, + enabled = enabled, + modifier = modifier + ) +} + +@Composable +fun HorizontalPipsPager( + selectedIndex: Int, + onSelectedIndexChange: (Int) -> Unit, + count: Int, + modifier: Modifier = Modifier, + visibleCount: Int = 5, + enabled: Boolean = true, + pageButtonVisibleStrategy: PageButtonVisibleStrategy = PageButtonVisibleStrategy.Never, + pipsColors: VisualStateScheme<Color> = PipsPagerDefaults.pipsColors(), +) { + PipsPager( + selectedIndex = selectedIndex, + onSelectedIndexChange = onSelectedIndexChange, + count = count, + isVertical = false, + pipsColors = pipsColors, + visibleCount = visibleCount, + pageButtonVisibleStrategy = pageButtonVisibleStrategy, + enabled = enabled, + modifier = modifier + ) +} + +@Composable +fun VerticalPipsPager( + selectedIndex: Int, + onSelectedIndexChange: (Int) -> Unit, + count: Int, + modifier: Modifier = Modifier, + enabled: Boolean = true, + pageButtonVisibleStrategy: PageButtonVisibleStrategy = PageButtonVisibleStrategy.Never, + visibleCount: Int = 5, + pipsColors: VisualStateScheme<Color> = PipsPagerDefaults.pipsColors(), +) { + PipsPager( + selectedIndex = selectedIndex, + onSelectedIndexChange = onSelectedIndexChange, + count = count, + isVertical = true, + pipsColors = pipsColors, + visibleCount = visibleCount, + pageButtonVisibleStrategy = pageButtonVisibleStrategy, + enabled = enabled, + modifier = modifier + ) +} + +@Composable +private fun PipsPager( + selectedIndex: Int, + onSelectedIndexChange: (Int) -> Unit, + count: Int, + isVertical: Boolean, + pipsColors: VisualStateScheme<Color>, + pageButtonVisibleStrategy: PageButtonVisibleStrategy, + enabled: Boolean, + visibleCount: Int, + modifier: Modifier, +) { + val size = PipsItemWidth * minOf(visibleCount, count) + val listState = rememberLazyListState() + val interactionSource = remember { MutableInteractionSource() } + val isHovered = interactionSource.collectIsHoveredAsState() + val pageButtonVisible = when (pageButtonVisibleStrategy) { + PageButtonVisibleStrategy.Always -> true + PageButtonVisibleStrategy.VisibleOnHovered -> isHovered.value + PageButtonVisibleStrategy.Never -> false + } + if (isVertical) { + Column(modifier = modifier.width(PipsPagerContainerHeight).hoverable(interactionSource)) { + + PageButton( + colors = pipsColors, + vector = Icons.Filled.CaretUp, + glyph = '\uEDDB', + onClick = { onSelectedIndexChange(selectedIndex - 1) }, + enabled = enabled && selectedIndex > 0, + visible = pageButtonVisible, + modifier = Modifier.hoverable(interactionSource) + ) + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxWidth() + .height(size) + ) { + items(count) { index -> + Pips( + selected = index == selectedIndex, + onSelectedChange = { onSelectedIndexChange(index) }, + isVertical = isVertical, + colors = pipsColors, + enabled = enabled, + modifier = Modifier + ) + } + } + PageButton( + colors = pipsColors, + vector = Icons.Filled.CaretDown, + glyph = '\uEDDC', + onClick = { onSelectedIndexChange(selectedIndex + 1) }, + enabled = enabled && selectedIndex < count - 1, + visible = pageButtonVisible, + modifier = Modifier + ) + } + } else { + Row(modifier = modifier.height(PipsPagerContainerHeight).hoverable(interactionSource)) { + + PageButton( + colors = pipsColors, + vector = Icons.Filled.CaretLeft, + glyph = '\uEDD9', + onClick = { onSelectedIndexChange(selectedIndex - 1) }, + enabled = enabled && selectedIndex > 0, + visible = pageButtonVisible, + modifier = Modifier.hoverable(interactionSource) + ) + + LazyRow( + state = listState, + modifier = Modifier + .fillMaxHeight() + .width(size) + ) { + items(count) { index -> + Pips( + selected = index == selectedIndex, + onSelectedChange = { onSelectedIndexChange(index) }, + isVertical = isVertical, + colors = pipsColors, + enabled = enabled, + modifier = Modifier + ) + } + } + + PageButton( + colors = pipsColors, + vector = Icons.Filled.CaretRight, + glyph = '\uEDDA', + onClick = { onSelectedIndexChange(selectedIndex + 1) }, + enabled = enabled && selectedIndex < count - 1, + visible = pageButtonVisible, + modifier = Modifier.hoverable(interactionSource) + ) + } + } + LaunchedEffect(selectedIndex, listState.layoutInfo.visibleItemsInfo) { + val item = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.index == selectedIndex } + if (item == null) { + listState.animateScrollToItem(selectedIndex) + } else { + val itemSize = item.size + val viewportSize = if (isVertical) { + listState.layoutInfo.viewportSize.height + } else { + listState.layoutInfo.viewportSize.width + } + val centerOffset = (viewportSize - itemSize) / 2f + val scrollOffset = item.offset - centerOffset + listState.animateScrollBy( + scrollOffset, + animationSpec = FlipViewDefaults.scrollAnimationSpec() + ) + } + } +} + +typealias PipsColorScheme = PentaVisualScheme<Color> + +object PipsPagerDefaults { + + @Stable + @Composable + fun pipsColors( + default: Color = FluentTheme.colors.controlStrong.default, + hovered: Color = FluentTheme.colors.text.text.secondary, + pressed: Color = FluentTheme.colors.text.text.secondary, + disabled: Color = FluentTheme.colors.controlStrong.disabled + ) = PipsColorScheme( + default = default, + hovered = hovered, + pressed = pressed, + disabled = disabled + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Pips( + selected: Boolean, + onSelectedChange: (Boolean) -> Unit, + isVertical: Boolean, + colors: VisualStateScheme<Color>, + enabled: Boolean, + modifier: Modifier +) { + val interactionSource = remember { MutableInteractionSource() } + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val visualState = interactionSource.collectVisualState(!enabled) + val currentColor = colors.schemeFor(visualState) + val size = animateDpAsState( + targetValue = when (visualState) { + VisualState.Hovered -> 5.dp + VisualState.Pressed -> 3.dp + else -> 4.dp + } + if (selected) { + 2.dp + } else { + 0.dp + } + ) + LaunchedEffect(selected) { + if (selected) { + bringIntoViewRequester.bringIntoView() + } + } + Box( + modifier = modifier + .then( + if (isVertical) { + Modifier.fillMaxWidth() + .height(PipsItemWidth) + } else { + Modifier.fillMaxHeight() + .width(PipsItemWidth) + } + ) + .bringIntoViewRequester(bringIntoViewRequester) + .selectable( + selected = selected, + indication = null, + interactionSource = interactionSource, + enabled = enabled, + onClick = { onSelectedChange(!selected) } + ) + .wrapContentSize(Alignment.Center) + .size(size.value) + .background(currentColor, shape = CircleShape) + ) +} + +enum class PageButtonVisibleStrategy { + Always, + VisibleOnHovered, + Never +} + +@OptIn(ExperimentalFluentApi::class) +@Composable +private fun PageButton( + colors: VisualStateScheme<Color>, + modifier: Modifier = Modifier, + enabled: Boolean, + visible: Boolean = true, + onClick: () -> Unit, + glyph: Char = '\uEDDA', + vector: ImageVector = Icons.Filled.CaretRight, +) { + if (visible) { + val interactionSource = remember { MutableInteractionSource() } + val currentColor = colors.schemeFor(interactionSource.collectVisualState(!enabled)) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(PipsPagerContainerHeight) + .clickable( + indication = null, + interactionSource = interactionSource, + onClick = onClick, + enabled = enabled + ) + ) { + CompositionLocalProvider( + LocalContentColor provides currentColor, + LocalContentAlpha provides currentColor.alpha, + LocalTextStyle provides LocalTextStyle.current.copy(color = currentColor) + ) { + val isPressed = interactionSource.collectIsPressedAsState() + val scale = animateFloatAsState( + targetValue = if (isPressed.value) { + 7 / 8f + } else 1f, + animationSpec = tween( + FluentDuration.ShortDuration, + easing = FluentEasing.FastInvokeEasing + ) + ) + FontIcon( + glyph = glyph, + vector = vector, + contentDescription = null, + iconSize = 8.sp, + vectorSize = 14.dp, + modifier = Modifier + .graphicsLayer { + scaleX = scale.value + scaleY = scale.value + transformOrigin = TransformOrigin(0.5f, 0.5f) + } + ) + } + } + } else { + Box(modifier = modifier.size(PipsPagerContainerHeight)) + } +} + +private val PipsPagerContainerHeight = 20.dp +private val PipsItemWidth = 12.dp \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentGroupInfo.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentGroupInfo.kt index 0eb1284b..ab73993e 100644 --- a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentGroupInfo.kt +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/component/ComponentGroupInfo.kt @@ -41,7 +41,7 @@ object ComponentGroupInfo { @ComponentGroup("Navigation", index = 10, packageMap = "$screenPackage.navigation") const val Navigation = "Navigation" - @ComponentGroup("ArrowSort", index = 11) + @ComponentGroup("ArrowSort", index = 11, packageMap = "$screenPackage.scrolling") const val Scrolling = "Scrolling" @ComponentGroup("ChatMultiple", index = 12, packageMap = "$screenPackage.status") diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/collections/FlipViewScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/collections/FlipViewScreen.kt new file mode 100644 index 00000000..003b72e3 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/collections/FlipViewScreen.kt @@ -0,0 +1,72 @@ +package com.konyaco.fluent.gallery.screen.collections + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.component.HorizontalFlipView +import com.konyaco.fluent.component.VerticalFlipView +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component(description = "Presents a collection of items that the user can flip through, one item at a time.") +@Composable +fun FlipViewScreen() { + GalleryPage( + title = "FlipView", + description = "The FlipView lets you flip through a collection of items, one at a time. " + + "It's great for displaying images from a gallery, pages of a magazine, or similar items.", + componentPath = FluentSourceFile.FlipView, + galleryPath = ComponentPagePath.FlipViewScreen + ) { + Section( + title = "HorizontalFlipView sample", + sourceCode = sourceCodeOfHorizontalFlipViewSample, + content = { HorizontalFlipViewSample() } + ) + + Section( + title = "VerticalFlipView sample", + sourceCode = sourceCodeOfVerticalFlipViewSample, + content = { VerticalFlipViewSample() } + ) + } +} + +@Sample +@Composable +private fun HorizontalFlipViewSample() { + val items = randomBrushItems() + HorizontalFlipView( + state = rememberPagerState { items.size }, + modifier = Modifier + .widthIn(max = 400.dp) + .height(180.dp) + ) { + Box(modifier = Modifier.fillMaxSize().background(items[it])) + } +} + +@Sample +@Composable +private fun VerticalFlipViewSample() { + val items = randomBrushItems() + VerticalFlipView( + state = rememberPagerState { items.size }, + modifier = Modifier + .widthIn(max = 400.dp) + .height(180.dp) + ) { + Box(modifier = Modifier.fillMaxSize().background(items[it])) + } +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/collections/GridViewItemScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/collections/GridViewItemScreen.kt new file mode 100644 index 00000000..a14cfeb1 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/collections/GridViewItemScreen.kt @@ -0,0 +1,138 @@ +package com.konyaco.fluent.gallery.screen.collections + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.component.GridViewItem +import com.konyaco.fluent.component.GridViewItemDefaults +import com.konyaco.fluent.component.MultiSelectGridViewItem +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.source.generated.FluentSourceFile +import kotlin.random.Random +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Component(description = "Selectable item template in grids.") +@Composable +fun GridViewItemScreen() { + GalleryPage( + title = "GridViewItem", + description = "Selectable item template in grids.", + componentPath = FluentSourceFile.GridViewItem, + galleryPath = ComponentPagePath.GridViewItemScreen + ) { + Section( + title = "Basic GridViewItem", + sourceCode = sourceCodeOfBasicGridViewItemSample, + content = { BasicGridViewItemSample() } + ) + + Section( + title = "MultiSelect GridViewItem", + sourceCode = sourceCodeOfMultiSelectGridViewItemSample, + content = { MultiSelectGridViewItemSample() } + ) + } +} + +@Sample +@Composable +private fun BasicGridViewItemSample() { + val items = randomBrushItems() + var selectedIndex by remember { mutableStateOf(-1) } + LazyVerticalGrid( + columns = GridCells.Adaptive(112.dp), + horizontalArrangement = Arrangement.spacedBy(GridViewItemDefaults.spacing), + verticalArrangement = Arrangement.spacedBy(GridViewItemDefaults.spacing), + contentPadding = PaddingValues(GridViewItemDefaults.spacing), + modifier = Modifier.height(300.dp) + ) { + itemsIndexed(items) { index, item -> + GridViewItem( + selected = index == selectedIndex, + onSelectedChange = { + selectedIndex = if (it) index else -1 + }, + content = { + Box(modifier = Modifier.background(item)) + }, + modifier = Modifier.fillMaxWidth() + .aspectRatio(1f) + ) + } + } +} + +@Sample +@Composable +private fun MultiSelectGridViewItemSample() { + val items = randomBrushItems() + val selectedIndices = remember { mutableStateListOf<Int>() } + LazyVerticalGrid( + columns = GridCells.Adaptive(112.dp), + horizontalArrangement = Arrangement.spacedBy(GridViewItemDefaults.spacing), + verticalArrangement = Arrangement.spacedBy(GridViewItemDefaults.spacing), + contentPadding = PaddingValues(GridViewItemDefaults.spacing), + modifier = Modifier.height(300.dp) + ) { + itemsIndexed(items) { index, item -> + MultiSelectGridViewItem( + selected = selectedIndices.contains(index), + onSelectedChange = { + if (it) { + selectedIndices.add(index) + } else { + selectedIndices.remove(index) + } + }, + content = { + Box(modifier = Modifier.background(item)) + }, + modifier = Modifier.fillMaxWidth() + .aspectRatio(1f) + ) + } + } +} + +@OptIn(ExperimentalUuidApi::class) +@Composable +internal fun randomBrushItems(): List<Brush> { + val random = remember { Random(Uuid.random().toLongs { a, b -> a + b }) } + return List(12) { + Brush.linearGradient( + colors = randomColor(random) + ) + } +} + +private fun randomColor(random: Random): List<Color> { + val count = random.nextInt(2, 5) + return List(count) { + Color( + red = random.nextInt(0, 255), + green = random.nextInt(0, 255), + blue = random.nextInt(0, 255) + ) + } +} \ No newline at end of file diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/design/IconsScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/design/IconsScreen.kt index 370c1a0a..2a5740f8 100644 --- a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/design/IconsScreen.kt +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/design/IconsScreen.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow @@ -45,6 +44,9 @@ import com.konyaco.fluent.FluentTheme import com.konyaco.fluent.FluentThemeConfiguration import com.konyaco.fluent.background.BackgroundSizing import com.konyaco.fluent.background.Layer +import com.konyaco.fluent.component.GridViewItem +import com.konyaco.fluent.component.GridViewItemColor +import com.konyaco.fluent.component.GridViewItemDefaults import com.konyaco.fluent.component.Icon import com.konyaco.fluent.component.RadioButton import com.konyaco.fluent.component.ScrollbarContainer @@ -59,8 +61,6 @@ import com.konyaco.fluent.icons.Icons import com.konyaco.fluent.source.generated.FluentSourceFile import com.konyaco.fluent.source.generated.fluentIconCoreItems import com.konyaco.fluent.source.generated.fluentIconExtendedItems -import com.konyaco.fluent.surface.Card -import com.konyaco.fluent.surface.CardDefaults import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce @@ -144,21 +144,20 @@ fun IconsScreen() { adapter = adapter, modifier = Modifier.weight(1f).fillMaxHeight() ) { - val defaultColors = CardDefaults.cardColors() - val cardColors = defaultColors.copy( - default = defaultColors.default.copy( - borderBrush = SolidColor(Color.Transparent) - ), - hovered = defaultColors.hovered.copy( - borderBrush = SolidColor(Color.Transparent) - ), - pressed = defaultColors.pressed.copy( - borderBrush = SolidColor(Color.Transparent) - ), - focused = defaultColors.focused.copy( - borderBrush = defaultColors.default.borderBrush + + val selectedColors = GridViewItemDefaults.selectedColors( + default = GridViewItemColor( + borderColor = FluentTheme.colors.fillAccent.default, + backgroundColor = FluentTheme.colors.subtleFill.transparent ) ) + val defaultColors = GridViewItemDefaults.defaultColors( + hovered= GridViewItemColor( + borderColor = Color.Transparent, + backgroundColor = FluentTheme.colors.subtleFill.secondary + ), + ) + LazyVerticalGrid( state = listState, columns = GridCells.Adaptive(96.dp), @@ -173,20 +172,23 @@ fun IconsScreen() { key = { (name, _) -> name } ) { item -> val (name, icon) = item - val interactionSource = - remember { MutableInteractionSource() } - Card( - onClick = { - selectedItem.value = item - }, - cardColors = if (selectedItem.value == item) { - defaultColors + val interactionSource = remember { MutableInteractionSource() } + GridViewItem( + selected = selectedItem.value == item, + onSelectedChange = { selectedItem.value = item }, + interactionSource = interactionSource, + colors = if (selectedItem.value == item) { + selectedColors } else { - cardColors + defaultColors }, - interactionSource = interactionSource, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() .aspectRatio(1f) + .background( + color = FluentTheme.colors.background.card.default, + shape = FluentTheme.shapes.control + ) ) { val isHovered by interactionSource.collectIsHoveredAsState() Box( @@ -216,7 +218,6 @@ fun IconsScreen() { ) ) } - } } } diff --git a/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/scrolling/PipsPagerScreen.kt b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/scrolling/PipsPagerScreen.kt new file mode 100644 index 00000000..bc679774 --- /dev/null +++ b/gallery/src/commonMain/kotlin/com/konyaco/fluent/gallery/screen/scrolling/PipsPagerScreen.kt @@ -0,0 +1,159 @@ +package com.konyaco.fluent.gallery.screen.scrolling + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.konyaco.fluent.component.DropDownButton +import com.konyaco.fluent.component.HorizontalFlipView +import com.konyaco.fluent.component.HorizontalPipsPager +import com.konyaco.fluent.component.MenuFlyoutContainer +import com.konyaco.fluent.component.MenuFlyoutItem +import com.konyaco.fluent.component.PageButtonVisibleStrategy +import com.konyaco.fluent.component.Text +import com.konyaco.fluent.component.VerticalPipsPager +import com.konyaco.fluent.gallery.annotation.Component +import com.konyaco.fluent.gallery.annotation.Sample +import com.konyaco.fluent.gallery.component.ComponentPagePath +import com.konyaco.fluent.gallery.component.GalleryPage +import com.konyaco.fluent.gallery.screen.collections.randomBrushItems +import com.konyaco.fluent.source.generated.FluentSourceFile + +@Component(description = "A control to let the user navigate through a paginated collection when the page numbers do not need to be visually known.") +@Composable +fun PipsPagerScreen() { + GalleryPage( + title = "PipsPager", + description = "A PipsPager allows the user to navigate through a paginated collection and is independent of the content shown. " + + "Use this control when the content in the layout is not explicitly ordered by relevancy or you desire a glyph-based representation of numbered pages. " + + "PipsPagers are commonly used in photo viewers, app lists, carousels, and when display space is limited.", + componentPath = FluentSourceFile.PipsPager, + galleryPath = ComponentPagePath.PipsPagerScreen + ) { + Section( + title = "PipsPager integrated with FlipView sample", + sourceCode = sourceCodeOfPipsPagerIntegratedWithFlipViewSample, + content = { PipsPagerIntegratedWithFlipViewSample() } + ) + + var isVertical = remember { mutableStateOf(false) } + var pageButtonVisibleStrategy = remember { mutableStateOf(PageButtonVisibleStrategy.Never) } + Section( + title = "PipsPager with options", + sourceCode = sourceCodeOfPipsPagerWithOptions, + content = { + PipsPagerWithOptions( + isVertical = isVertical.value, + pageButtonVisibleStrategy = pageButtonVisibleStrategy.value + ) + }, + options = { + Text("Orientation") + MenuFlyoutContainer( + flyout = { + MenuFlyoutItem( + selected = isVertical.value, + onSelectedChanged = { + isVertical.value = true + isFlyoutVisible = false + }, + text = { Text("Vertical") }, + ) + MenuFlyoutItem( + selected = !isVertical.value, + onSelectedChanged = { + isVertical.value = false + isFlyoutVisible = false + }, + text = { Text("Horizontal") } + ) + }, + content = { + DropDownButton( + onClick = { isFlyoutVisible = true }, + content = { Text(if (isVertical.value) "Vertical" else "Horizontal") } + ) + } + ) + Text("Page button visibility") + MenuFlyoutContainer( + flyout = { + PageButtonVisibleStrategy.entries.forEach { item -> + MenuFlyoutItem( + selected = pageButtonVisibleStrategy.value == item, + onSelectedChanged = { + pageButtonVisibleStrategy.value = item + isFlyoutVisible = false + }, + text = { Text(item.name) } + ) + } + }, + content = { + DropDownButton( + onClick = { isFlyoutVisible = true }, + content = { Text(pageButtonVisibleStrategy.value.name) } + ) + } + ) + } + ) + } +} + +@Sample +@Composable +private fun PipsPagerIntegratedWithFlipViewSample() { + val items = randomBrushItems() + val pagerState = rememberPagerState { items.size } + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + HorizontalFlipView( + state = pagerState, + modifier = Modifier + .widthIn(max = 400.dp) + .height(180.dp) + ) { + Box(modifier = Modifier.fillMaxSize().background(items[it])) + } + HorizontalPipsPager(state = pagerState) + } +} + +@Sample +@Composable +private fun PipsPagerWithOptions( + isVertical: Boolean, + pageButtonVisibleStrategy: PageButtonVisibleStrategy +) { + val count = 7 + var selectedIndex by remember { mutableIntStateOf(0) } + if (isVertical) { + VerticalPipsPager( + selectedIndex = selectedIndex, + onSelectedIndexChange = { selectedIndex = it }, + count = count, + pageButtonVisibleStrategy = pageButtonVisibleStrategy + ) + } else { + HorizontalPipsPager( + selectedIndex = selectedIndex, + onSelectedIndexChange = { selectedIndex = it }, + count = count, + pageButtonVisibleStrategy = pageButtonVisibleStrategy + ) + } +} \ No newline at end of file