diff --git a/modules/app_ui_kit/build.gradle.kts b/modules/app_ui_kit/build.gradle.kts index f9f3eb4b..3a3fe037 100644 --- a/modules/app_ui_kit/build.gradle.kts +++ b/modules/app_ui_kit/build.gradle.kts @@ -26,6 +26,7 @@ android { dependencies { implementation(project(":ui_theme")) implementation(project(":ui_icons")) + implementation(project(":ui_shimmer")) implementation(project(":ui_kit_lists")) implementation(project(":ui_kit_topappbar")) implementation(project(":ui_kit_navigationbar")) diff --git a/modules/app_ui_kit/src/main/kotlin/kekmech/ru/mpeiapp/demo/screens/main/MainScreen.kt b/modules/app_ui_kit/src/main/kotlin/kekmech/ru/mpeiapp/demo/screens/main/MainScreen.kt index bb1adb3b..d34736bb 100644 --- a/modules/app_ui_kit/src/main/kotlin/kekmech/ru/mpeiapp/demo/screens/main/MainScreen.kt +++ b/modules/app_ui_kit/src/main/kotlin/kekmech/ru/mpeiapp/demo/screens/main/MainScreen.kt @@ -19,6 +19,7 @@ import kekmech.ru.lib_navigation_compose.LocalBackStackNavigator import kekmech.ru.mpeiapp.demo.screens.colors.ColorsScreenNavTarget import kekmech.ru.mpeiapp.demo.screens.components.ComponentsScreenNavTarget import kekmech.ru.mpeiapp.demo.screens.elmslie.ElmDemoScreenNavTarget +import kekmech.ru.mpeiapp.demo.screens.shimmers.ShimmersScreenNavTarget import kekmech.ru.mpeiapp.demo.screens.typography.TypographyScreenNavTarget import kekmech.ru.mpeiapp.demo.ui.SectionItem import kekmech.ru.mpeiapp.demo.ui.ToggleThemeActionButton @@ -94,6 +95,15 @@ private fun MainScreen(greetings: String) { modifier = Modifier.fillMaxWidth() ) } + item("shimmers") { + SectionItem( + onClick = { + navigator.navigate(ShimmersScreenNavTarget()) + }, + name = "Shimmers", + modifier = Modifier.fillMaxWidth() + ) + } } } } diff --git a/modules/app_ui_kit/src/main/kotlin/kekmech/ru/mpeiapp/demo/screens/shimmers/ShimmersScreen.kt b/modules/app_ui_kit/src/main/kotlin/kekmech/ru/mpeiapp/demo/screens/shimmers/ShimmersScreen.kt new file mode 100644 index 00000000..046c78cd --- /dev/null +++ b/modules/app_ui_kit/src/main/kotlin/kekmech/ru/mpeiapp/demo/screens/shimmers/ShimmersScreen.kt @@ -0,0 +1,78 @@ +package kekmech.ru.mpeiapp.demo.screens.shimmers + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.node +import kekmech.ru.lib_navigation_api.NavTarget +import kekmech.ru.mpeiapp.demo.ui.UiKitScreen +import kekmech.ru.ui_shimmer.shimmer +import kekmech.ru.ui_theme.theme.MpeixTheme +import kotlinx.parcelize.Parcelize + +@Parcelize +internal class ShimmersScreenNavTarget : NavTarget { + + override fun resolve(buildContext: BuildContext): Node = + node(buildContext) { ShimmersScreen() } +} + +@Composable +private fun ShimmersScreen() { + UiKitScreen(title = "Shimmers") { innerPadding -> + Column(Modifier.padding(innerPadding)) { + ShimmerItem() + ShimmerItem() + ShimmerItem() + ShimmerItem() + ShimmerItem() + } + } +} + +@Composable +private fun ShimmerItem() { + val color = MpeixTheme.palette.surfacePlus3 + val radius = 4.dp + Row( + Modifier + .padding(bottom=8.dp) + .padding(horizontal = 16.dp) + .shimmer() + ) { + Box( + Modifier + .padding(end = 8.dp) + .size(64.dp) + .background(color, RoundedCornerShape(radius)) + ) + Column { + Box( + Modifier + .fillMaxWidth() + .padding(bottom = 4.dp) + .height(18.dp) + .background(color, RoundedCornerShape(radius)) + ) + Box( + Modifier + .fillMaxWidth(fraction = 0.75f) + .height(14.dp) + .background(color, RoundedCornerShape(radius)) + ) + } + } +} diff --git a/modules/ui/kit/dialogs/src/main/kotlin/kekmech/ru/ui_kit_dialogs/AlertDialog.kt b/modules/ui/kit/dialogs/src/main/kotlin/kekmech/ru/ui_kit_dialogs/AlertDialog.kt index 70b5347f..b83caa16 100644 --- a/modules/ui/kit/dialogs/src/main/kotlin/kekmech/ru/ui_kit_dialogs/AlertDialog.kt +++ b/modules/ui/kit/dialogs/src/main/kotlin/kekmech/ru/ui_kit_dialogs/AlertDialog.kt @@ -3,13 +3,17 @@ package kekmech.ru.ui_kit_dialogs import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues 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.material.icons.Icons import androidx.compose.material.icons.outlined.ShoppingCart +import androidx.compose.material3.Divider import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -58,6 +62,21 @@ fun rememberAlertDialogState(dialogVisible: Boolean = false): AlertDialogState = * defines. If required, these constraints can be overwritten by providing a `width` or `height` * [Modifier]s. * + * Usage: + * + * ```kotlin + * val myAlert = rememberAlertDialogState() + * + * Button( + * onClick = { myAlert.showDialog() }, + * ) { ... } + * + * AlertDialog( + * onDismissRequest = { myAlert.hideDialog() }, + * state = myAlert, + * ) { ... } + * ``` + * * @param onDismissRequest called when the user tries to dismiss the Dialog by clicking outside * or pressing the back button. This is not called when the dismiss button is clicked. * @param state the state by which the showing and hiding of the dialog is controlled. See @@ -109,6 +128,23 @@ fun AlertDialog( * By default it will try to place them horizontally next to each other and fallback to horizontal * placement if not enough space is available. * + * Usage: + * + * ```kotlin + * val myAlert = rememberAlertDialogState() + * + * Button( + * onClick = { myAlert.showDialog() }, + * ) { ... } + * + * AlertDialog( + * onDismissRequest = { myAlert.hideDialog() }, + * confirmButton = { ... }, + * state = myAlert, + * ... + * ) + * ``` + * * @param onDismissRequest called when the user tries to dismiss the Dialog by clicking outside * or pressing the back button. This is not called when the dismiss button is clicked. * @param confirmButton button which is meant to confirm a proposed action, thus resolving what @@ -138,6 +174,7 @@ fun AlertDialog( icon: @Composable (() -> Unit)? = null, title: String? = null, text: String? = null, + content: @Composable (() -> Unit)? = null, properties: DialogProperties = DialogProperties(), ) { AnimatedVisibility( @@ -162,15 +199,34 @@ fun AlertDialog( ) } } else null, - text = if (text != null) { - @Composable { - Text( - text = text, - style = MpeixTheme.typography.paragraphNormal, - color = MpeixTheme.palette.contentVariant, - ) + text = when { + text != null && content != null -> { + { + Column { + Text( + text = text, + style = MpeixTheme.typography.paragraphNormal, + color = MpeixTheme.palette.contentVariant, + modifier = Modifier.padding(bottom = 16.dp), + ) + Divider() + content.invoke() + Divider() + } + } } - } else null, + text != null && content == null -> { + { + Text( + text = text, + style = MpeixTheme.typography.paragraphNormal, + color = MpeixTheme.palette.contentVariant, + ) + } + } + text == null && content != null -> content + else -> null + }, shape = RoundedCornerShape(28.dp), containerColor = MpeixTheme.palette.surface, iconContentColor = MpeixTheme.palette.contentVariant, @@ -228,7 +284,18 @@ private fun LayoutAlertDialogPreview() { Icon(Icons.Outlined.ShoppingCart, contentDescription = null) }, title = "Are you sure?", - text = "Are you really sure what you do? Just go out and touch the grass!", + text = "Are you really sure what you do? Just go out and touch the grass:", + content = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background(MpeixTheme.palette.surfacePlus1), + contentAlignment = Alignment.Center, + ) { + Text(text = "Some grass to be touched") + } + } ) } } diff --git a/modules/ui/shimmer/build.gradle.kts b/modules/ui/shimmer/build.gradle.kts new file mode 100644 index 00000000..23f02c93 --- /dev/null +++ b/modules/ui/shimmer/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id("mpeix.android.ui") +} + +dependencies { + implementation(project(":ui_theme")) +} diff --git a/modules/ui/shimmer/src/main/kotlin/kekmech/ru/ui_shimmer/ShimmerArea.kt b/modules/ui/shimmer/src/main/kotlin/kekmech/ru/ui_shimmer/ShimmerArea.kt new file mode 100644 index 00000000..4aae0144 --- /dev/null +++ b/modules/ui/shimmer/src/main/kotlin/kekmech/ru/ui_shimmer/ShimmerArea.kt @@ -0,0 +1,96 @@ +package kekmech.ru.ui_shimmer + + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import kotlin.math.acos +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sqrt + +/** + * Describes the area in which the shimmer effect will be drawn. + */ +internal class ShimmerArea( + private val shimmerWidthPx: Float, + internal val shimmerBounds: Rect, +) { + + private val reducedRotation = ShimmerTokens.Rotation.toRadian() + + private var shimmerSize: Size = Size.Zero + + var translationDistance = 0f + private set + + var pivotPoint = Offset.Unspecified + private set + + var viewBounds = Rect.Zero + set(value) { + if (value == field) return + field = value + computeShimmerBounds() + } + + private fun computeShimmerBounds() { + if (viewBounds.isEmpty) return + + // Pivot point in the view's frame of reference + pivotPoint = -viewBounds.topLeft + shimmerBounds.center + + val newShimmerSize = shimmerBounds.size + if (shimmerSize != newShimmerSize) { + shimmerSize = newShimmerSize + computeTranslationDistance() + } + } + + /** + * Rotating the shimmer results in an effect that will first be visible in one of the corners. + * It will afterwards travel across the view / display until the last visible part of it will + * disappear in the opposite corner. + * + * A simple shimmer going across the device's screen from left to right has to travel until + * it reaches the center of the screen and then the same distance again. Without taking the + * shimmer's own width into account. + * + * If the shimmer is now tilted slightly clockwise around the center of the display, a new + * distance has to be calculated. The required distance is the length of a line, which extends + * from the top left of the display to the rotated shimmer (or center line), hitting it at a + * 90 degree angle. As the height and width of the display (or view) are known, the length of + * the line can be calculated by using basic trigonometric functions. + */ + private fun computeTranslationDistance() { + val width = shimmerSize.width / 2 + val height = shimmerSize.height / 2 + + val distanceCornerToCenter = sqrt(width.pow(2) + height.pow(2)) + val beta = acos(width / distanceCornerToCenter) + val alpha = beta - reducedRotation + + val distanceCornerToRotatedCenterLine = cos(alpha) * distanceCornerToCenter + translationDistance = distanceCornerToRotatedCenterLine * 2 + shimmerWidthPx + } + + private fun Float.toRadian(): Float = this / 180 * Math.PI.toFloat() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ShimmerArea + + if (shimmerWidthPx != other.shimmerWidthPx) return false + if (reducedRotation != other.reducedRotation) return false + + return true + } + + override fun hashCode(): Int { + var result = shimmerWidthPx.hashCode() + result = 31 * result + reducedRotation.hashCode() + return result + } +} diff --git a/modules/ui/shimmer/src/main/kotlin/kekmech/ru/ui_shimmer/ShimmerBounds.kt b/modules/ui/shimmer/src/main/kotlin/kekmech/ru/ui_shimmer/ShimmerBounds.kt new file mode 100644 index 00000000..ab7e88d3 --- /dev/null +++ b/modules/ui/shimmer/src/main/kotlin/kekmech/ru/ui_shimmer/ShimmerBounds.kt @@ -0,0 +1,21 @@ +package kekmech.ru.ui_shimmer + + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.LocalContext + +@Composable +internal fun rememberShimmerBounds(): Rect { + val displayMetrics = LocalContext.current.resources.displayMetrics + return remember(displayMetrics) { + Rect( + left = 0f, + top = 0f, + right = displayMetrics.widthPixels.toFloat(), + bottom = displayMetrics.heightPixels.toFloat() + ) + } +} + diff --git a/modules/ui/shimmer/src/main/kotlin/kekmech/ru/ui_shimmer/ShimmerEffect.kt b/modules/ui/shimmer/src/main/kotlin/kekmech/ru/ui_shimmer/ShimmerEffect.kt new file mode 100644 index 00000000..272a67b6 --- /dev/null +++ b/modules/ui/shimmer/src/main/kotlin/kekmech/ru/ui_shimmer/ShimmerEffect.kt @@ -0,0 +1,82 @@ +package kekmech.ru.ui_shimmer + + +import android.graphics.Matrix +import androidx.compose.animation.core.Animatable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.LinearGradientShader +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.PaintingStyle +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.withSaveLayer + +internal class ShimmerEffect( + shimmerWidthPx: Float, +) { + + private val animatedState = Animatable(0f) + + private val transformationMatrix = Matrix() + + private val shader = LinearGradientShader( + from = Offset(-shimmerWidthPx / 2, 0f), + to = Offset(shimmerWidthPx / 2, 0f), + colors = listOf( + Color.Unspecified.copy(alpha = 0.5f), + Color.Unspecified.copy(alpha = 1.0f), + Color.Unspecified.copy(alpha = 0.5f), + ), + colorStops = listOf( + 0.0f, + 0.5f, + 1.0f, + ), + ) + + private val paint = Paint().apply { + isAntiAlias = true + style = PaintingStyle.Fill + blendMode = BlendMode.DstIn + shader = this@ShimmerEffect.shader + } + + internal suspend fun startAnimation() { + animatedState.animateTo( + targetValue = 1f, + animationSpec = ShimmerTokens.AnimationSpec, + ) + } + + private val emptyPaint = Paint() + + fun ContentDrawScope.draw(shimmerArea: ShimmerArea) = with(shimmerArea) { + if (shimmerBounds.isEmpty || viewBounds.isEmpty) return + + val progress = animatedState.value + val traversal = -translationDistance / 2 + translationDistance * progress + pivotPoint.x + + transformationMatrix.apply { + reset() + postTranslate(traversal, 0f) + postRotate(ShimmerTokens.Rotation, pivotPoint.x, pivotPoint.y) + } + shader.setLocalMatrix(transformationMatrix) + + val drawArea = size.toRect() + drawIntoCanvas { canvas -> + canvas.withSaveLayer( + bounds = drawArea, + paint = emptyPaint + ) { + drawContent() + canvas.drawRect(drawArea, paint) + } + } + } +} diff --git a/modules/ui/shimmer/src/main/kotlin/kekmech/ru/ui_shimmer/ShimmerModifier.kt b/modules/ui/shimmer/src/main/kotlin/kekmech/ru/ui_shimmer/ShimmerModifier.kt new file mode 100644 index 00000000..bdc45e44 --- /dev/null +++ b/modules/ui/shimmer/src/main/kotlin/kekmech/ru/ui_shimmer/ShimmerModifier.kt @@ -0,0 +1,58 @@ +package kekmech.ru.ui_shimmer + + +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.DrawModifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.OnGloballyPositionedModifier +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.debugInspectorInfo + +fun Modifier.shimmer(): Modifier = composed( + factory = { + val bounds = rememberShimmerBounds() + val shimmerWidthPx = with(LocalDensity.current) { ShimmerTokens.Width.toPx() } + val effect = remember(shimmerWidthPx) { ShimmerEffect(shimmerWidthPx) } + val area = remember(shimmerWidthPx, bounds) { ShimmerArea(shimmerWidthPx, bounds) } + + LaunchedEffect(area) { + effect.startAnimation() + } + + remember(area) { ShimmerModifier(area, effect) } + }, + inspectorInfo = debugInspectorInfo { + name = "shimmer" + } +) + +internal class ShimmerModifier( + private val area: ShimmerArea, + private val effect: ShimmerEffect, +) : DrawModifier, OnGloballyPositionedModifier { + + override fun ContentDrawScope.draw() { + with(effect) { draw(area) } + } + + override fun onGloballyPositioned(coordinates: LayoutCoordinates) { + val viewBounds = coordinates.asRect() + area.viewBounds = viewBounds + } + + private fun LayoutCoordinates.asRect(): Rect { + val positionInWindow = positionInWindow() + return Rect( + left = positionInWindow.x, + top = positionInWindow.y, + right = positionInWindow.x + size.width, + bottom = positionInWindow.y + size.height, + ) + } +} diff --git a/modules/ui/shimmer/src/main/kotlin/kekmech/ru/ui_shimmer/ShimmerTokens.kt b/modules/ui/shimmer/src/main/kotlin/kekmech/ru/ui_shimmer/ShimmerTokens.kt new file mode 100644 index 00000000..fd68ed5e --- /dev/null +++ b/modules/ui/shimmer/src/main/kotlin/kekmech/ru/ui_shimmer/ShimmerTokens.kt @@ -0,0 +1,23 @@ +package kekmech.ru.ui_shimmer + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +internal object ShimmerTokens { + + const val Rotation: Float = 15f // deg + val Width: Dp = 400.dp + val AnimationSpec: AnimationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 500, + easing = LinearEasing, + delayMillis = 500, + ), + repeatMode = RepeatMode.Restart, + ) +} \ No newline at end of file