Updated on 2026-08-14
This commit is contained in:
parent
f587974fc2
commit
43fb47e267
8 changed files with 368 additions and 1 deletions
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
package com.tangem.core.ui.components.progressbar
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -15,10 +17,12 @@ import androidx.compose.ui.layout.layout
|
|||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.progressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.offset
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
|
|
@ -123,4 +127,18 @@ private fun DrawScope.drawLinearIndicator(
|
|||
}
|
||||
|
||||
private fun DrawScope.drawLinearIndicatorBackground(color: Color, strokeWidth: Float, strokeCap: StrokeCap) =
|
||||
drawLinearIndicator(0f, 1f, color, strokeWidth, strokeCap)
|
||||
drawLinearIndicator(0f, 1f, color, strokeWidth, strokeCap)
|
||||
|
||||
// region Preview
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun LinearProgressIndicator_Preview() {
|
||||
TangemThemePreview {
|
||||
LinearProgressIndicator(
|
||||
progress = { 0.5f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
package com.tangem.core.ui.components.stories
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.tangem.core.ui.components.stories.inner.StoriesClickableArea
|
||||
import com.tangem.core.ui.components.stories.inner.StoriesProgressBar
|
||||
import com.tangem.core.ui.components.stories.inner.StoriesStepStateMachine
|
||||
import com.tangem.core.ui.components.stories.model.StoriesContentConfig
|
||||
import com.tangem.core.ui.components.stories.model.StoryConfig
|
||||
import com.tangem.core.ui.res.TangemColorPalette
|
||||
import com.tangem.core.ui.res.TangemThemePreview
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* Stories container component.
|
||||
* Implements base logic for switching story pages and handling clicks
|
||||
*
|
||||
* @param config stories configuration
|
||||
* @param modifier composable modifier
|
||||
* @param isPauseStories pause stories progression externally
|
||||
* @param currentStoryContent content of current displaying story
|
||||
*/
|
||||
@Composable
|
||||
inline fun <reified T : StoryConfig> StoriesContainer(
|
||||
config: StoriesContentConfig<T>,
|
||||
modifier: Modifier = Modifier,
|
||||
isPauseStories: Boolean = false,
|
||||
crossinline currentStoryContent: @Composable BoxScope.(T, Boolean) -> Unit,
|
||||
) {
|
||||
var isPressed by remember { mutableStateOf(value = false) }
|
||||
val storyState by remember(config) {
|
||||
mutableStateOf(
|
||||
StoriesStepStateMachine(
|
||||
stories = config.stories,
|
||||
isRepeatable = config.isRestartable,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val isPaused = isPressed || isPauseStories
|
||||
|
||||
Box(modifier = modifier) {
|
||||
StoriesClickableArea(
|
||||
onPress = { isPressed = it },
|
||||
onPreviousStory = storyState::prevStory,
|
||||
onNextStory = storyState::nextStory,
|
||||
)
|
||||
|
||||
StoriesProgressBar(
|
||||
steps = storyState.steps,
|
||||
currentStep = storyState.currentIndex.intValue,
|
||||
stepDuration = storyState.currentStory.duration,
|
||||
paused = isPaused,
|
||||
onStepFinish = storyState::nextStory,
|
||||
)
|
||||
|
||||
currentStoryContent(storyState.currentStory, isPaused)
|
||||
}
|
||||
}
|
||||
|
||||
// region Preview
|
||||
@Preview(showBackground = true, widthDp = 360)
|
||||
@Preview(showBackground = true, widthDp = 360, uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
private fun StoriesContainer_Preview() {
|
||||
TangemThemePreview {
|
||||
StoriesContainer(
|
||||
config = StoryContainerPreviewProviderData(),
|
||||
modifier = Modifier.background(Color(0xFF010101)),
|
||||
isPauseStories = false,
|
||||
currentStoryContent = { pageConfig, _ ->
|
||||
Text(
|
||||
text = pageConfig.title,
|
||||
color = TangemColorPalette.White,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class StoryContainerPreviewProviderData(
|
||||
override val stories: ImmutableList<StoryConfigPreviewProviderData> = persistentListOf(
|
||||
StoryConfigPreviewProviderData(title = "Tangem"),
|
||||
StoryConfigPreviewProviderData(title = "Cold"),
|
||||
StoryConfigPreviewProviderData(title = "Wallet"),
|
||||
),
|
||||
override val isRestartable: Boolean = true,
|
||||
) : StoriesContentConfig<StoryConfigPreviewProviderData>
|
||||
|
||||
private data class StoryConfigPreviewProviderData(
|
||||
override val duration: Int = 3_000,
|
||||
val title: String,
|
||||
) : StoryConfig
|
||||
// endregion
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.tangem.core.ui.components.stories.inner
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
|
||||
private const val STORY_HOLD_DELAY = 200
|
||||
|
||||
@Composable
|
||||
fun StoriesClickableArea(onPress: (Boolean) -> Unit, onPreviousStory: () -> Unit, onNextStory: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
val pressStartTime = System.currentTimeMillis()
|
||||
onPress(true)
|
||||
this.tryAwaitRelease()
|
||||
val pressEndTime = System.currentTimeMillis()
|
||||
val totalPressTime = pressEndTime - pressStartTime
|
||||
if (totalPressTime < STORY_HOLD_DELAY) onPreviousStory()
|
||||
onPress(false)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
val pressStartTime = System.currentTimeMillis()
|
||||
onPress(true)
|
||||
this.tryAwaitRelease()
|
||||
val pressEndTime = System.currentTimeMillis()
|
||||
val totalPressTime = pressEndTime - pressStartTime
|
||||
if (totalPressTime < STORY_HOLD_DELAY) onNextStory()
|
||||
onPress(false)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
package com.tangem.core.ui.components.stories.inner
|
||||
|
||||
import android.provider.Settings
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.AnimationVector1D
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.tangem.core.ui.components.SpacerW4
|
||||
import com.tangem.core.ui.res.TangemColorPalette
|
||||
import com.tangem.core.ui.res.TangemTheme
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private const val STORIES_ANIMATION_SPEED_ZERO_DURATION = 3000L
|
||||
const val STORY_DURATION = 8_000
|
||||
|
||||
private suspend fun animationProgress(
|
||||
paused: Boolean,
|
||||
animatorSpeed: Float,
|
||||
stepDuration: Int,
|
||||
progress: Animatable<Float, AnimationVector1D>,
|
||||
onStepFinish: () -> Unit,
|
||||
) {
|
||||
if (paused) {
|
||||
progress.stop()
|
||||
} else {
|
||||
if (animatorSpeed == 0f) {
|
||||
progress.snapTo(1f)
|
||||
delay(STORIES_ANIMATION_SPEED_ZERO_DURATION)
|
||||
} else {
|
||||
progress.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(
|
||||
durationMillis = (stepDuration * (1f - progress.value)).toInt(),
|
||||
easing = LinearEasing,
|
||||
),
|
||||
)
|
||||
progress.snapTo(0f)
|
||||
}
|
||||
onStepFinish()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StoriesProgressBar(
|
||||
steps: Int,
|
||||
currentStep: Int,
|
||||
paused: Boolean = false,
|
||||
stepDuration: Int = STORY_DURATION,
|
||||
onStepFinish: () -> Unit = {},
|
||||
) {
|
||||
val progress = remember(currentStep) { Animatable(initialValue = 0f) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val animatorSpeed = Settings.Global.getFloat(
|
||||
context.contentResolver,
|
||||
Settings.Global.ANIMATOR_DURATION_SCALE,
|
||||
1f,
|
||||
)
|
||||
|
||||
LaunchedEffect(paused, currentStep, animatorSpeed) {
|
||||
animationProgress(
|
||||
paused = paused,
|
||||
animatorSpeed = animatorSpeed,
|
||||
stepDuration = stepDuration,
|
||||
progress = progress,
|
||||
onStepFinish = onStepFinish,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 16.dp,
|
||||
),
|
||||
) {
|
||||
for (index in 0..steps) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(2.dp)
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(TangemColorPalette.White.copy(alpha = .2f)),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(TangemColorPalette.White)
|
||||
.fillMaxHeight()
|
||||
.let {
|
||||
when (index) {
|
||||
currentStep -> it.fillMaxWidth(progress.value)
|
||||
in 0..currentStep -> it.fillMaxWidth(fraction = 1f)
|
||||
else -> it
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
SpacerW4()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun StoriesProgressBarPreview() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.wrapContentSize()
|
||||
.background(TangemColorPalette.Black)
|
||||
.padding(vertical = TangemTheme.dimens.spacing16),
|
||||
) {
|
||||
StoriesProgressBar(steps = 5, currentStep = 3, paused = false)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.tangem.core.ui.components.stories.inner
|
||||
|
||||
import androidx.compose.runtime.IntState
|
||||
import androidx.compose.runtime.asIntState
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
class StoriesStepStateMachine<T>(
|
||||
private val stories: ImmutableList<T>,
|
||||
private val isRepeatable: Boolean,
|
||||
) {
|
||||
|
||||
private var _currentIndex = mutableIntStateOf(FIRST_INDEX)
|
||||
|
||||
val steps = stories.lastIndex
|
||||
val currentIndex: IntState
|
||||
get() = _currentIndex.asIntState()
|
||||
|
||||
val currentStory
|
||||
get() = stories[currentIndex.intValue.coerceIn(FIRST_INDEX, steps)]
|
||||
|
||||
fun nextStory(): Boolean {
|
||||
_currentIndex.intValue = if (currentIndex.intValue == steps && isRepeatable) {
|
||||
FIRST_INDEX
|
||||
} else {
|
||||
currentIndex.intValue.inc().coerceAtMost(stories.size)
|
||||
}
|
||||
|
||||
return currentIndex.intValue > steps
|
||||
}
|
||||
|
||||
fun prevStory() {
|
||||
_currentIndex.intValue = currentIndex.intValue.dec().coerceAtLeast(FIRST_INDEX)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FIRST_INDEX = 0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.tangem.core.ui.components.stories.model
|
||||
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
/**
|
||||
* Config for stories component
|
||||
*
|
||||
* @property stories configuration list
|
||||
* @property isRestartable indicates than stories progressions starts
|
||||
*/
|
||||
interface StoriesContentConfig<T : StoryConfig> {
|
||||
val stories: ImmutableList<T>
|
||||
val isRestartable: Boolean
|
||||
}
|
||||
|
||||
interface StoryConfig {
|
||||
val duration: Int
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue