Skip to content

Instantly share code, notes, and snippets.

@Tgo1014
Last active October 1, 2024 16:19
Show Gist options
  • Save Tgo1014/b6c9109f44375d0769af0959aaa6b79c to your computer and use it in GitHub Desktop.
Save Tgo1014/b6c9109f44375d0769af0959aaa6b79c to your computer and use it in GitHub Desktop.
Bounce Marquee - A marquee that slides the text from one side to another if the text don't fit the screen
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MarqueeDefaults.Iterations
import androidx.compose.foundation.MarqueeDefaults.RepeatDelayMillis
import androidx.compose.foundation.MarqueeDefaults.Spacing
import androidx.compose.foundation.MarqueeDefaults.Velocity
import androidx.compose.foundation.MarqueeSpacing
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.focus.FocusEventModifierNode
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.layout.IntrinsicMeasurable
import androidx.compose.ui.layout.IntrinsicMeasureScope
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.requireDensity
import androidx.compose.ui.node.requireLayoutDirection
import androidx.compose.ui.platform.InspectorInfo
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.constrainWidth
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.math.absoluteValue
import kotlin.math.ceil
import kotlin.math.roundToInt
import kotlin.math.sign
/**
* Based on androidx.compose.foundation.basicMarquee() Modifier
*/
@Stable
fun Modifier.bounceMarquee(
iterations: Int = Iterations,
repeatDelayMillis: Int = RepeatDelayMillis,
spacing: MarqueeSpacing = Spacing,
velocity: Dp = Velocity
): Modifier = this then MarqueeModifierElement(
iterations = iterations,
delayMillis = repeatDelayMillis,
spacing = spacing,
velocity = velocity,
)
private data class MarqueeModifierElement(
private val iterations: Int,
private val delayMillis: Int,
private val spacing: MarqueeSpacing,
private val velocity: Dp,
) : ModifierNodeElement<MarqueeModifierNode>() {
override fun create(): MarqueeModifierNode =
MarqueeModifierNode(
iterations = iterations,
delayMillis = delayMillis,
spacing = spacing,
velocity = velocity,
)
override fun update(node: MarqueeModifierNode) {
node.update(
iterations = iterations,
delayMillis = delayMillis,
spacing = spacing,
velocity = velocity,
)
}
override fun InspectorInfo.inspectableProperties() {
name = "basicMarquee"
properties["iterations"] = iterations
properties["delayMillis"] = delayMillis
properties["spacing"] = spacing
properties["velocity"] = velocity
}
}
private class MarqueeModifierNode(
private var iterations: Int,
private var delayMillis: Int,
spacing: MarqueeSpacing,
private var velocity: Dp,
) : Modifier.Node(), LayoutModifierNode, DrawModifierNode, FocusEventModifierNode {
private var contentWidth by mutableIntStateOf(0)
private var containerWidth by mutableIntStateOf(0)
private var hasFocus by mutableStateOf(false)
private var animationJob: Job? = null
var spacing: MarqueeSpacing by mutableStateOf(spacing)
private val offset = Animatable(0f)
private val direction
get() = sign(velocity.value) *
when (requireLayoutDirection()) {
LayoutDirection.Ltr -> 1
LayoutDirection.Rtl -> -1
}
override fun onAttach() {
restartAnimation()
}
override fun onDetach() {
animationJob?.cancel()
animationJob = null
}
fun update(
iterations: Int,
delayMillis: Int,
spacing: MarqueeSpacing,
velocity: Dp,
) {
this.spacing = spacing
if (
this.iterations != iterations ||
this.delayMillis != delayMillis ||
this.velocity != velocity
) {
this.iterations = iterations
this.delayMillis = delayMillis
this.velocity = velocity
restartAnimation()
}
}
override fun onFocusEvent(focusState: FocusState) {
hasFocus = focusState.hasFocus
}
override fun MeasureScope.measure(
measurable: Measurable,
constraints: Constraints
): MeasureResult {
val childConstraints = constraints.copy(maxWidth = Constraints.Infinity)
val placeable = measurable.measure(childConstraints)
containerWidth = constraints.constrainWidth(placeable.width)
contentWidth = placeable.width
return layout(containerWidth, placeable.height) {
// Placing the marquee content in a layer means we don't invalidate the parent draw
// scope on every animation frame.
placeable.placeWithLayer(x = (-offset.value * direction).roundToInt(), y = 0)
}
}
// Override intrinsic calculations to avoid setting state (see b/278729564).
/** Always returns zero since the marquee has no minimum width. */
override fun IntrinsicMeasureScope.minIntrinsicWidth(
measurable: IntrinsicMeasurable,
height: Int
): Int = 0
override fun IntrinsicMeasureScope.maxIntrinsicWidth(
measurable: IntrinsicMeasurable,
height: Int
): Int = measurable.maxIntrinsicWidth(height)
/** Ignores width since marquee contents are always measured with infinite width. */
override fun IntrinsicMeasureScope.minIntrinsicHeight(
measurable: IntrinsicMeasurable,
width: Int
): Int = measurable.minIntrinsicHeight(Constraints.Infinity)
/** Ignores width since marquee contents are always measured with infinite width. */
override fun IntrinsicMeasureScope.maxIntrinsicHeight(
measurable: IntrinsicMeasurable,
width: Int
): Int = measurable.maxIntrinsicHeight(Constraints.Infinity)
override fun ContentDrawScope.draw() {
val clipOffset = offset.value * direction
val firstCopyVisible =
when (direction) {
1f -> offset.value < contentWidth
else -> offset.value < containerWidth
}
clipRect(left = clipOffset, right = clipOffset + containerWidth) {
if (firstCopyVisible) {
this@draw.drawContent()
}
}
}
private fun restartAnimation() {
val oldJob = animationJob
oldJob?.cancel()
if (isAttached) {
animationJob =
coroutineScope.launch {
// Wait for the cancellation to finish.
oldJob?.join()
runAnimation()
}
}
}
private suspend fun runAnimation() {
if (iterations <= 0) {
// No animation.
return
}
// Marquee animations should not be affected by motion accessibility settings.
// Wrap the entire flow instead of just the animation calls so kotlin doesn't have to create
// an extra CoroutineContext every time the flow emits.
withContext(FixedMotionDurationScale) {
snapshotFlow {
// Don't animate if content fits. (Because coroutines, the int will get boxed
// anyway.)
if (contentWidth <= containerWidth) return@snapshotFlow null
(contentWidth - containerWidth).toFloat()
}
.collectLatest { contentWithSpacingWidth ->
// Don't animate when the content fits.
if (contentWithSpacingWidth == null) return@collectLatest
val spec = createMarqueeAnimationSpec(
targetValue = contentWithSpacingWidth,
delayMillis = delayMillis,
velocity = velocity,
density = requireDensity()
)
offset.snapTo(0f)
try {
repeat(iterations) {
offset.animateTo(contentWithSpacingWidth, spec)
offset.animateTo(0f, spec)
}
} finally {
// This needs to be in a finally so the offset is reset if the animation is
// cancelled when losing focus in WhileFocused mode.
offset.snapTo(0f)
}
}
}
}
}
private fun createMarqueeAnimationSpec(
targetValue: Float,
delayMillis: Int,
velocity: Dp,
density: Density
): AnimationSpec<Float> {
val pxPerSec = with(density) { velocity.toPx() }
return velocityBasedTween(
velocity = pxPerSec.absoluteValue,
targetValue = targetValue,
delayMillis = delayMillis
)
}
/**
* Calculates a float [TweenSpec] that moves at a constant [velocity] for an animation from 0 to
* [targetValue].
*
* @param velocity Speed of animation in px / sec.
*/
private fun velocityBasedTween(
velocity: Float,
targetValue: Float,
delayMillis: Int
): TweenSpec<Float> {
val pxPerMilli = velocity / 1000f
return tween(
durationMillis = ceil(targetValue / pxPerMilli).toInt(),
easing = LinearEasing,
delayMillis = delayMillis
)
}
/** A [MotionDurationScale] that always reports a [scaleFactor] of 1. */
private object FixedMotionDurationScale : MotionDurationScale {
override val scaleFactor: Float
get() = 1f
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment