Updated on 2026-08-14

This commit is contained in:
Tangem 2026-05-27 12:17:53 +03:00
parent 2604f4c41b
commit e9afc268d3
6 changed files with 476 additions and 0 deletions

View file

@ -0,0 +1,166 @@
@file:Suppress("MagicNumber")
package com.tangem.core.ui.ds2.fade
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.haze.hazeEffectTangem
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.core.ui.res.TangemThemePreviewRedesign
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeTint
private const val SOLID_RATIO = 40f / 96f
private const val HARD_ALPHA = 0.95f
private const val SOFT_ALPHA = 0.6f
private val FADE_HEIGHT = 96.dp
private val BLUR_RADIUS = 20.dp
/**
* Design-system fade overlay used at the top or bottom edge of scrollable content.
*
* Renders a 96dp-tall gradient (and optional solid block in the [TangemFade.Variant.Hard]
* variant) tinted with `colors3.bg.primary`, so it blends with the page background and masks
* content as it scrolls under the edge. Place it inside a [Box] and align with
* [androidx.compose.ui.Alignment.TopCenter] / [androidx.compose.ui.Alignment.BottomCenter]
* depending on [position].
*
* @param position which edge the fade is anchored to controls the gradient direction.
* @param variant [TangemFade.Variant.Hard] adds an opaque solid block next to the edge for a
* harder cut-off, [TangemFade.Variant.Soft] is gradient-only and gentler.
* @param blur when `true`, the content under the fade is blurred via Haze (radius 20dp,
* progressive intensity matching [position]).
* @param backgroundColor base color of the fade gradient defaults to `colors3.bg.primary` so
* the fade blends with the standard page background.
* @param modifier modifier applied to the fade's root.
*/
@Composable
fun TangemFade(
position: TangemFade.Position,
modifier: Modifier = Modifier,
variant: TangemFade.Variant = TangemFade.Variant.Soft,
blur: Boolean = false,
backgroundColor: Color = TangemTheme.colors3.bg.primary,
) {
val hazeState = LocalHazeState.current
val brush = buildBrush(color = backgroundColor, position = position, variant = variant)
val blurModifier = if (blur) {
Modifier.hazeEffectTangem(state = hazeState) {
blurRadius = BLUR_RADIUS
fallbackTint = HazeTint(Color.Transparent)
progressive = HazeProgressive.verticalGradient(
startIntensity = if (position == TangemFade.Position.Top) 1f else 0f,
endIntensity = if (position == TangemFade.Position.Top) 0f else 1f,
preferPerformance = true,
)
}
} else {
Modifier
}
Box(
modifier = modifier
.fillMaxWidth()
.height(FADE_HEIGHT)
.then(blurModifier)
.background(brush),
)
}
private fun buildBrush(color: Color, position: TangemFade.Position, variant: TangemFade.Variant): Brush {
val opaque = color.copy(alpha = HARD_ALPHA)
val soft = color.copy(alpha = SOFT_ALPHA)
val transparent = Color.Transparent
return when (variant) {
TangemFade.Variant.Hard -> when (position) {
TangemFade.Position.Top -> Brush.verticalGradient(
colorStops = arrayOf(
0f to opaque,
SOLID_RATIO to opaque,
1f to transparent,
),
)
TangemFade.Position.Bottom -> Brush.verticalGradient(
colorStops = arrayOf(
0f to transparent,
1f - SOLID_RATIO to opaque,
1f to opaque,
),
)
}
TangemFade.Variant.Soft -> when (position) {
TangemFade.Position.Top -> Brush.verticalGradient(colors = listOf(soft, transparent))
TangemFade.Position.Bottom -> Brush.verticalGradient(colors = listOf(transparent, soft))
}
}
}
object TangemFade {
enum class Position { Top, Bottom }
enum class Variant { Hard, Soft }
}
@Composable
@Preview(showBackground = true)
@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
private fun TangemFade_Preview() {
TangemThemePreviewRedesign {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.background(TangemTheme.colors3.bg.secondary)
.padding(12.dp),
) {
TangemFade.Variant.entries.forEach { variant ->
TangemFade.Position.entries.forEach { position ->
FadePreviewRow(variant = variant, position = position)
}
}
}
}
}
@Composable
private fun FadePreviewRow(variant: TangemFade.Variant, position: TangemFade.Position) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = "${variant.name} / ${position.name}",
style = TangemTheme.typography.caption2,
color = TangemTheme.colors3.text.primary,
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.background(TangemTheme.colors3.bg.accent.blue),
) {
TangemFade(
position = position,
variant = variant,
modifier = Modifier.align(
when (position) {
TangemFade.Position.Top -> Alignment.TopCenter
TangemFade.Position.Bottom -> Alignment.BottomCenter
},
),
)
}
}
}

View file

@ -7,6 +7,7 @@ import com.tangem.core.ui.ds.message.TangemMessageEffect
import com.tangem.core.ui.ds.topbar.TangemTopBarType
import com.tangem.core.ui.ds2.badge.TangemBadge
import com.tangem.core.ui.ds2.button.TangemButton
import com.tangem.core.ui.ds2.fade.TangemFade
import com.tangem.core.ui.ds2.loader.TangemLoaderSize
import com.tangem.core.ui.ds2.shimmers.TextShimmerStyle
@ -176,6 +177,15 @@ internal data class TangemButtonStory(
}
}
internal data class TangemFadeStory(
val variant: TangemFade.Variant,
val position: TangemFade.Position,
val isBlur: Boolean,
val onVariantChange: (TangemFade.Variant) -> Unit,
val onPositionChange: (TangemFade.Position) -> Unit,
val onBlurToggle: () -> Unit,
) : DsStoryBookPage
internal data class TangemBadgeV2Story(
val variant: TangemBadge.Variant,
val status: TangemBadge.Status,

View file

@ -17,6 +17,7 @@ import com.tangem.feature.tester.presentation.storybook.entity.DsComponentsListS
import com.tangem.feature.tester.presentation.storybook.entity.StoryPageFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.badge.tangemBadgeV2StoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.button.tangemButtonStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.fade.tangemFadeStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.loader.tangemLoaderStoryFactory
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.tangemShimmerStoryFactory
@ -27,6 +28,7 @@ private fun buildDsStories() = listOf(
DsStoryItem(title = "🔘 TangemButton", factory = tangemButtonStoryFactory),
DsStoryItem(title = "🏷️ TangemBadge", factory = tangemBadgeV2StoryFactory),
DsStoryItem(title = "✨ TangemShimmer", factory = tangemShimmerStoryFactory),
DsStoryItem(title = "🌫️ TangemFade", factory = tangemFadeStoryFactory),
)
@Composable

View file

@ -0,0 +1,26 @@
package com.tangem.feature.tester.presentation.storybook.page.ds.fade
import com.tangem.core.ui.ds2.fade.TangemFade
import com.tangem.feature.tester.presentation.storybook.entity.TangemFadeStory
import com.tangem.feature.tester.presentation.storybook.viewmodel.StateUpdater
import com.tangem.feature.tester.presentation.storybook.viewmodel.storyPageFactory
internal fun StateUpdater<TangemFadeStory>.build(): TangemFadeStory {
return TangemFadeStory(
variant = TangemFade.Variant.Hard,
position = TangemFade.Position.Top,
isBlur = false,
onVariantChange = { variant ->
updateStory { it.copy(variant = variant) }
},
onPositionChange = { position ->
updateStory { it.copy(position = position) }
},
onBlurToggle = {
updateStory { it.copy(isBlur = !it.isBlur) }
},
)
}
internal val tangemFadeStoryFactory
get() = storyPageFactory(StateUpdater<TangemFadeStory>::build)

View file

@ -0,0 +1,245 @@
@file:Suppress("MagicNumber")
package com.tangem.feature.tester.presentation.storybook.page.ds.fade
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.tangem.core.ui.components.haze.hazeSourceTangem
import com.tangem.core.ui.ds2.fade.TangemFade
import com.tangem.core.ui.res.LocalHazeState
import com.tangem.core.ui.res.TangemTheme
import com.tangem.feature.tester.presentation.storybook.entity.TangemFadeStory
@Composable
internal fun TangemFadeStory(state: TangemFadeStory, modifier: Modifier = Modifier) {
val hazeState = LocalHazeState.current
DisposableEffect(state.isBlur) {
val wasBlurEnabled = hazeState.blurEnabled
hazeState.blurEnabled = state.isBlur
onDispose { hazeState.blurEnabled = wasBlurEnabled }
}
Column(
modifier = modifier
.fillMaxSize()
.background(TangemTheme.colors.background.primary)
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ComponentPreview(state = state)
VariantSelector(selected = state.variant, onSelect = state.onVariantChange)
PositionSelector(selected = state.position, onSelect = state.onPositionChange)
Toggles(state = state)
}
}
@Composable
private fun ComponentPreview(state: TangemFadeStory) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(240.dp)
.clip(RoundedCornerShape(16.dp)),
) {
Backdrop(
modifier = Modifier
.fillMaxSize()
.hazeSourceTangem(zIndex = 0f),
)
TangemFade(
position = state.position,
variant = state.variant,
blur = state.isBlur,
modifier = Modifier.align(
when (state.position) {
TangemFade.Position.Top -> Alignment.TopCenter
TangemFade.Position.Bottom -> Alignment.BottomCenter
},
),
)
}
}
@Composable
private fun Backdrop(modifier: Modifier = Modifier) {
val bands = remember {
listOf(
Color(0xFFFF1744),
Color(0xFFFF9100),
Color(0xFFFFEA00),
Color(0xFF00E676),
Color(0xFF00B8D4),
Color(0xFF2962FF),
Color(0xFFD500F9),
)
}
val stops = remember(bands) {
buildList {
bands.forEachIndexed { index, color ->
val start = index.toFloat() / bands.size
val end = (index + 1).toFloat() / bands.size
add(start to color)
add(end to color)
}
}.toTypedArray()
}
val tilePx = with(LocalDensity.current) { 160.dp.toPx() }
Box(
modifier = modifier.background(
brush = Brush.linearGradient(
colorStops = stops,
start = Offset(0f, 0f),
end = Offset(tilePx, tilePx),
tileMode = TileMode.Repeated,
),
),
)
}
@Composable
private fun VariantSelector(selected: TangemFade.Variant, onSelect: (TangemFade.Variant) -> Unit) {
Section(label = "Variant") {
ChipGrid(
items = TangemFade.Variant.entries,
label = { it.name },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun PositionSelector(selected: TangemFade.Position, onSelect: (TangemFade.Position) -> Unit) {
Section(label = "Position") {
ChipGrid(
items = TangemFade.Position.entries,
label = { it.name },
isSelected = { it == selected },
onSelect = onSelect,
)
}
}
@Composable
private fun Toggles(state: TangemFadeStory) {
Section(label = "Flags") {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ToggleRow(label = "blur", checked = state.isBlur, onToggle = state.onBlurToggle)
}
}
}
@Composable
private fun Section(label: String, content: @Composable () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
text = label,
style = TangemTheme.typography.subtitle1,
color = TangemTheme.colors.text.primary1,
)
content()
}
}
@Composable
private fun <T> ChipGrid(items: List<T>, label: (T) -> String, isSelected: (T) -> Boolean, onSelect: (T) -> Unit) {
val shape = RoundedCornerShape(50)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.clip(shape)
.background(TangemTheme.colors2.surface.level2)
.border(
width = 1.dp,
color = TangemTheme.colors2.border.neutral.secondary,
shape = shape,
)
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
items.forEach { item ->
Chip(
label = label(item),
selected = isSelected(item),
onClick = { onSelect(item) },
modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val chipShape = RoundedCornerShape(50)
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.clip(chipShape)
.background(
if (selected) TangemTheme.colors2.surface.level3 else TangemTheme.colors2.surface.level2,
)
.clickable(onClick = onClick)
.padding(vertical = 8.dp, horizontal = 4.dp),
) {
Text(
text = label,
style = TangemTheme.typography.caption2,
color = if (selected) TangemTheme.colors.text.primary1 else TangemTheme.colors.text.secondary,
)
}
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onToggle: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(TangemTheme.colors2.surface.level2)
.clickable(onClick = onToggle)
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = label,
style = TangemTheme.typography.body1,
color = TangemTheme.colors.text.primary1,
)
Text(
text = if (checked) "ON" else "OFF",
style = TangemTheme.typography.caption2,
color = if (checked) TangemTheme.colors.text.accent else TangemTheme.colors.text.secondary,
)
}
}

View file

@ -4,6 +4,31 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.tangem.feature.tester.presentation.storybook.entity.ButtonsStory
import com.tangem.feature.tester.presentation.storybook.entity.DeviceIconStory
import com.tangem.feature.tester.presentation.storybook.entity.DsComponentsListStory
import com.tangem.feature.tester.presentation.storybook.entity.NorthernLightsStory
import com.tangem.feature.tester.presentation.storybook.entity.OpportunitiesBGStory
import com.tangem.feature.tester.presentation.storybook.entity.PlaceholderStory
import com.tangem.feature.tester.presentation.storybook.entity.ProgressIndicatorStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemBadgeStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemBadgeV2Story
import com.tangem.feature.tester.presentation.storybook.entity.TangemButtonStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemFadeStory
import com.tangem.feature.tester.presentation.storybook.entity.StoryBookUM
import com.tangem.feature.tester.presentation.storybook.entity.StoryList
import com.tangem.feature.tester.presentation.storybook.entity.TangemCheckboxStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemHeaderRowStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemLoaderStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemContextMenuStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemMessageStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemPagerIndicatorStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemSearchFieldStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemSegmentedPickerStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemTabStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemTokenRowStory
import com.tangem.feature.tester.presentation.storybook.entity.TangemTopBarStory
import com.tangem.feature.tester.presentation.storybook.entity.TypographyStory
import com.tangem.feature.tester.presentation.storybook.entity.*
import com.tangem.feature.tester.presentation.storybook.page.background.NorthernLightsStory
import com.tangem.feature.tester.presentation.storybook.page.badge.TangemBadgeStory
@ -14,6 +39,7 @@ import com.tangem.feature.tester.presentation.storybook.page.deviceicon.DeviceIc
import com.tangem.feature.tester.presentation.storybook.page.ds.DsComponentsListStory
import com.tangem.feature.tester.presentation.storybook.page.ds.badge.TangemBadgeV2Story
import com.tangem.feature.tester.presentation.storybook.page.ds.button.TangemButtonStory
import com.tangem.feature.tester.presentation.storybook.page.ds.fade.TangemFadeStory
import com.tangem.feature.tester.presentation.storybook.page.ds.loader.TangemLoaderStory
import com.tangem.feature.tester.presentation.storybook.page.ds.shimmer.TangemShimmerStory
import com.tangem.feature.tester.presentation.storybook.page.headerrow.TangemHeaderRowStory
@ -64,6 +90,7 @@ internal fun StoryBookScreen(state: StoryBookUM, modifier: Modifier = Modifier)
is TangemButtonStory -> TangemButtonStory(state = storyState)
is TangemBadgeV2Story -> TangemBadgeV2Story(state = storyState)
is TangemShimmerStory -> TangemShimmerStory(state = storyState)
is TangemFadeStory -> TangemFadeStory(state = storyState)
}
}
}