Last active
October 1, 2024 16:19
-
-
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
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
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