| /* |
| * Copyright 2022 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| @file:OptIn(ExperimentalFoundationApi::class) |
| |
| package androidx.compose.foundation |
| |
| import androidx.compose.animation.core.Animatable |
| import androidx.compose.animation.core.AnimationSpec |
| import androidx.compose.animation.core.LinearEasing |
| import androidx.compose.animation.core.StartOffset |
| import androidx.compose.animation.core.TweenSpec |
| import androidx.compose.animation.core.infiniteRepeatable |
| import androidx.compose.animation.core.repeatable |
| import androidx.compose.animation.core.tween |
| import androidx.compose.foundation.MarqueeAnimationMode.Companion.Immediately |
| import androidx.compose.foundation.MarqueeAnimationMode.Companion.WhileFocused |
| import androidx.compose.runtime.LaunchedEffect |
| import androidx.compose.runtime.Stable |
| import androidx.compose.runtime.derivedStateOf |
| import androidx.compose.runtime.getValue |
| import androidx.compose.runtime.mutableStateOf |
| import androidx.compose.runtime.remember |
| import androidx.compose.runtime.setValue |
| import androidx.compose.runtime.snapshotFlow |
| import androidx.compose.ui.Modifier |
| import androidx.compose.ui.composed |
| import androidx.compose.ui.draw.DrawModifier |
| import androidx.compose.ui.focus.FocusState |
| import androidx.compose.ui.graphics.drawscope.ContentDrawScope |
| import androidx.compose.ui.graphics.drawscope.clipRect |
| import androidx.compose.ui.graphics.drawscope.translate |
| import androidx.compose.ui.layout.LayoutCoordinates |
| import androidx.compose.ui.layout.LayoutModifier |
| import androidx.compose.ui.layout.Measurable |
| import androidx.compose.ui.layout.MeasureResult |
| import androidx.compose.ui.layout.MeasureScope |
| import androidx.compose.ui.platform.LocalDensity |
| import androidx.compose.ui.platform.LocalLayoutDirection |
| import androidx.compose.ui.platform.debugInspectorInfo |
| import androidx.compose.ui.unit.Constraints |
| import androidx.compose.ui.unit.Density |
| import androidx.compose.ui.unit.Dp |
| import androidx.compose.ui.unit.LayoutDirection.Ltr |
| import androidx.compose.ui.unit.constrainWidth |
| import androidx.compose.ui.unit.dp |
| import kotlin.math.absoluteValue |
| import kotlin.math.ceil |
| import kotlin.math.roundToInt |
| import kotlin.math.sign |
| import kotlinx.coroutines.flow.collectLatest |
| |
| // From https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/TextView.java;l=736;drc=6d97d6d7215fef247d1a90e05545cac3676f9212 |
| @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") |
| @ExperimentalFoundationApi |
| @get:ExperimentalFoundationApi |
| val DefaultMarqueeIterations: Int = 3 |
| |
| // From https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/TextView.java;l=13979;drc=6d97d6d7215fef247d1a90e05545cac3676f9212 |
| @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") |
| @ExperimentalFoundationApi |
| @get:ExperimentalFoundationApi |
| val DefaultMarqueeDelayMillis: Int = 1_200 |
| |
| // From https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/TextView.java;l=14088;drc=6d97d6d7215fef247d1a90e05545cac3676f9212 |
| @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") |
| @ExperimentalFoundationApi |
| @get:ExperimentalFoundationApi |
| val DefaultMarqueeSpacing: MarqueeSpacing = MarqueeSpacing.fractionOfContainer(1f / 3f) |
| |
| // From https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/widget/TextView.java;l=13980;drc=6d97d6d7215fef247d1a90e05545cac3676f9212 |
| @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") |
| @ExperimentalFoundationApi |
| @get:ExperimentalFoundationApi |
| val DefaultMarqueeVelocity: Dp = 30.dp |
| |
| /** |
| * Applies an animated marquee effect to the modified content if it's too wide to fit in the |
| * available space. This modifier has no effect if the content fits in the max constraints. The |
| * content will be measured with unbounded width. |
| * |
| * When the animation is running, it will restart from the initial state any time: |
| * - any of the parameters to this modifier change, or |
| * - the content or container size change. |
| * |
| * The animation only affects the drawing of the content, not its position. The offset returned by |
| * the [LayoutCoordinates] of anything inside the marquee is undefined relative to anything outside |
| * the marquee, and may not match its drawn position on screen. This modifier also does not |
| * currently support content that accepts position-based input such as pointer events. |
| * |
| * @sample androidx.compose.foundation.samples.BasicMarqueeSample |
| * |
| * To only animate when the composable is focused, specify [animationMode] and make the composable |
| * focusable. |
| * @sample androidx.compose.foundation.samples.BasicFocusableMarqueeSample |
| * |
| * This modifier does not add any visual effects aside from scrolling, but you can add your own by |
| * placing modifiers before this one. |
| * @sample androidx.compose.foundation.samples.BasicMarqueeWithFadedEdgesSample |
| * |
| * @param iterations The number of times to repeat the animation. `Int.MAX_VALUE` will repeat |
| * forever, and 0 will disable animation. |
| * @param animationMode Whether the marquee should start animating [Immediately] or only |
| * [WhileFocused]. In [WhileFocused] mode, the modified node or the content must be made |
| * [focusable]. Note that the [initialDelayMillis] is part of the animation, so this parameter |
| * determines when that initial delay starts counting down, not when the content starts to actually |
| * scroll. |
| * @param delayMillis The duration to wait before starting each subsequent iteration, in millis. |
| * @param initialDelayMillis The duration to wait before starting the first iteration of the |
| * animation, in millis. By default, there will be no initial delay if [animationMode] is |
| * [WhileFocused], otherwise the initial delay will be [delayMillis]. |
| * @param spacing A [MarqueeSpacing] that specifies how much space to leave at the end of the |
| * content before showing the beginning again. |
| * @param velocity The speed of the animation in dps / second. |
| */ |
| @ExperimentalFoundationApi |
| fun Modifier.basicMarquee( |
| iterations: Int = DefaultMarqueeIterations, |
| animationMode: MarqueeAnimationMode = Immediately, |
| // TODO(aosp/2339066) Consider taking an AnimationSpec instead of specific configuration params. |
| delayMillis: Int = DefaultMarqueeDelayMillis, |
| initialDelayMillis: Int = if (animationMode == Immediately) delayMillis else 0, |
| spacing: MarqueeSpacing = DefaultMarqueeSpacing, |
| velocity: Dp = DefaultMarqueeVelocity |
| ): Modifier = composed( |
| inspectorInfo = debugInspectorInfo { |
| name = "basicMarquee" |
| properties["iterations"] = iterations |
| properties["animationMode"] = animationMode |
| properties["delayMillis"] = delayMillis |
| properties["initialDelayMillis"] = initialDelayMillis |
| properties["spacing"] = spacing |
| properties["velocity"] = velocity |
| } |
| ) { |
| val density = LocalDensity.current |
| val layoutDirection = LocalLayoutDirection.current |
| val modifier = remember( |
| iterations, |
| delayMillis, |
| initialDelayMillis, |
| velocity, |
| density, |
| layoutDirection, |
| ) { |
| MarqueeModifier( |
| iterations = iterations, |
| delayMillis = delayMillis, |
| initialDelayMillis = initialDelayMillis, |
| velocity = velocity * if (layoutDirection == Ltr) 1f else -1f, |
| density = density |
| ) |
| } |
| modifier.spacing = spacing |
| modifier.animationMode = animationMode |
| |
| LaunchedEffect(modifier) { |
| modifier.runAnimation() |
| } |
| |
| return@composed modifier |
| } |
| |
| private class MarqueeModifier( |
| private val iterations: Int, |
| private val delayMillis: Int, |
| private val initialDelayMillis: Int, |
| private val velocity: Dp, |
| private val density: Density, |
| ) : Modifier.Element, |
| LayoutModifier, |
| DrawModifier, |
| @Suppress("DEPRECATION") androidx.compose.ui.focus.FocusEventModifier { |
| |
| private var contentWidth by mutableStateOf(0) |
| private var containerWidth by mutableStateOf(0) |
| private var hasFocus by mutableStateOf(false) |
| var spacing: MarqueeSpacing by mutableStateOf(DefaultMarqueeSpacing) |
| var animationMode: MarqueeAnimationMode by mutableStateOf(Immediately) |
| |
| private val offset = Animatable(0f) |
| private val direction = sign(velocity.value) |
| private val spacingPx by derivedStateOf { |
| with(spacing) { |
| density.calculateSpacing(contentWidth, containerWidth) |
| } |
| } |
| |
| 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 fun ContentDrawScope.draw() { |
| val clipOffset = offset.value * direction |
| val firstCopyVisible = when (direction) { |
| 1f -> offset.value < contentWidth |
| else -> offset.value < containerWidth |
| } |
| val secondCopyVisible = when (direction) { |
| 1f -> offset.value > (contentWidth + spacingPx) - containerWidth |
| else -> offset.value > spacingPx |
| } |
| val secondCopyOffset = when (direction) { |
| 1f -> contentWidth + spacingPx |
| else -> -contentWidth - spacingPx |
| }.toFloat() |
| |
| clipRect(left = clipOffset, right = clipOffset + containerWidth) { |
| // TODO(b/262284225) When both copies are visible, we call drawContent twice. This is |
| // generally a bad practice, however currently the only alternative is to compose the |
| // content twice, which can't be done with a modifier. In the future we might get the |
| // ability to create intrinsic layers in draw scopes, which we should use here to avoid |
| // invalidating the contents' draw scopes. |
| if (firstCopyVisible) { |
| this@draw.drawContent() |
| } |
| if (secondCopyVisible) { |
| translate(left = secondCopyOffset) { |
| this@draw.drawContent() |
| } |
| } |
| } |
| } |
| |
| override fun onFocusEvent(focusState: FocusState) { |
| hasFocus = focusState.hasFocus |
| } |
| |
| suspend fun runAnimation() { |
| if (iterations <= 0) { |
| // No animation. |
| return |
| } |
| |
| snapshotFlow { |
| // Don't animate if content fits. (Because coroutines, the int will get boxed anyway.) |
| if (contentWidth <= containerWidth) return@snapshotFlow null |
| if (animationMode == WhileFocused && !hasFocus) return@snapshotFlow null |
| (contentWidth + spacingPx).toFloat() |
| }.collectLatest { contentWithSpacingWidth -> |
| // Don't animate when the content fits. |
| if (contentWithSpacingWidth == null) return@collectLatest |
| |
| val spec = createMarqueeAnimationSpec( |
| iterations, |
| contentWithSpacingWidth, |
| initialDelayMillis, |
| delayMillis, |
| velocity, |
| density |
| ) |
| |
| offset.snapTo(0f) |
| try { |
| offset.animateTo(contentWithSpacingWidth, spec) |
| } finally { |
| offset.snapTo(0f) |
| } |
| } |
| } |
| } |
| |
| private fun createMarqueeAnimationSpec( |
| iterations: Int, |
| targetValue: Float, |
| initialDelayMillis: Int, |
| delayMillis: Int, |
| velocity: Dp, |
| density: Density |
| ): AnimationSpec<Float> { |
| val pxPerSec = with(density) { velocity.toPx() } |
| val singleSpec = velocityBasedTween( |
| velocity = pxPerSec.absoluteValue, |
| targetValue = targetValue, |
| delayMillis = delayMillis |
| ) |
| // Need to cancel out the non-initial delay. |
| val startOffset = StartOffset(-delayMillis + initialDelayMillis) |
| return if (iterations == Int.MAX_VALUE) { |
| infiniteRepeatable(singleSpec, initialStartOffset = startOffset) |
| } else { |
| repeatable(iterations, singleSpec, initialStartOffset = startOffset) |
| } |
| } |
| |
| /** |
| * 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 |
| ) |
| } |
| |
| /** Specifies when the [basicMarquee] animation runs. */ |
| @ExperimentalFoundationApi |
| @JvmInline |
| value class MarqueeAnimationMode private constructor(private val value: Int) { |
| |
| override fun toString(): String = when (this) { |
| Immediately -> "Immediately" |
| WhileFocused -> "WhileFocused" |
| else -> error("invalid value: $value") |
| } |
| |
| companion object { |
| /** |
| * Starts animating immediately (accounting for any initial delay), irrespective of focus |
| * state. |
| */ |
| @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") |
| @ExperimentalFoundationApi |
| @get:ExperimentalFoundationApi |
| val Immediately = MarqueeAnimationMode(0) |
| |
| /** |
| * Only animates while the marquee has focus or a node in the marquee's content has focus. |
| */ |
| @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") |
| @ExperimentalFoundationApi |
| @get:ExperimentalFoundationApi |
| val WhileFocused = MarqueeAnimationMode(1) |
| } |
| } |
| |
| /** |
| * A [MarqueeSpacing] with a fixed size. |
| */ |
| @ExperimentalFoundationApi |
| fun MarqueeSpacing(spacing: Dp): MarqueeSpacing = MarqueeSpacing { _, _ -> spacing.roundToPx() } |
| |
| /** |
| * Defines a [calculateSpacing] method that determines the space after the end of [basicMarquee] |
| * content before drawing the content again. |
| */ |
| @ExperimentalFoundationApi |
| @Stable |
| fun interface MarqueeSpacing { |
| /** |
| * Calculates the space after the end of [basicMarquee] content before drawing the content |
| * again. |
| * |
| * This is a restartable method: any state used to calculate the result will cause the spacing |
| * to be re-calculated when it changes. |
| * |
| * @param contentWidth The width of the content inside the marquee, in pixels. Will always be |
| * larger than [containerWidth]. |
| * @param containerWidth The width of the marquee itself, in pixels. Will always be smaller than |
| * [contentWidth]. |
| * @return The space in pixels between the end of the content and the beginning of the content |
| * when wrapping. |
| */ |
| @ExperimentalFoundationApi |
| fun Density.calculateSpacing( |
| contentWidth: Int, |
| containerWidth: Int |
| ): Int |
| |
| companion object { |
| /** |
| * A [MarqueeSpacing] that is a fraction of the container's width. |
| */ |
| @ExperimentalFoundationApi |
| fun fractionOfContainer(fraction: Float): MarqueeSpacing = MarqueeSpacing { _, width -> |
| (fraction * width).roundToInt() |
| } |
| } |
| } |