-
Notifications
You must be signed in to change notification settings - Fork 52
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #4034 from tangem/feature/AND-9602-Stories-Impleme…
…nted-core-stories-component AND-9602 Stories: Implemented core stories component
- Loading branch information
Showing
8 changed files
with
367 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
106 changes: 106 additions & 0 deletions
106
core/ui/src/main/java/com/tangem/core/ui/components/stories/StoriesContainer.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
56 changes: 56 additions & 0 deletions
56
core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesClickableArea.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
}, | ||
) | ||
}, | ||
) | ||
} | ||
} |
127 changes: 127 additions & 0 deletions
127
core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesProgressBar.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
} |
39 changes: 39 additions & 0 deletions
39
core/ui/src/main/java/com/tangem/core/ui/components/stories/inner/StoriesStepStateMachine.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} | ||
} |
Oops, something went wrong.