From d3da2638d16db39325301a5d97772486da59d7d1 Mon Sep 17 00:00:00 2001 From: oxy Date: Mon, 18 Nov 2024 02:05:39 +0800 Subject: [PATCH] 1.14.1/overscroll (#210) * feat: overscroll effect. * fix: remove unused import directive. --- .../java/com/m3u/androidApp/ui/Scaffold.kt | 149 ++++++++++-------- .../foryou/components/HeadlineBackground.kt | 2 + .../material/overscroll/OverScrollEffect.kt | 95 +++++++++++ .../material/overscroll/OverScrollModifier.kt | 128 +++++++++++++++ .../OverScrollNestedScrollConnection.kt | 63 ++++++++ .../OverscrollGraphicsLayerModifier.kt | 103 ++++++++++++ ui/src/main/java/com/m3u/ui/Toolkit.kt | 1 - 7 files changed, 473 insertions(+), 68 deletions(-) create mode 100644 material/src/main/java/com/m3u/material/overscroll/OverScrollEffect.kt create mode 100644 material/src/main/java/com/m3u/material/overscroll/OverScrollModifier.kt create mode 100644 material/src/main/java/com/m3u/material/overscroll/OverScrollNestedScrollConnection.kt create mode 100644 material/src/main/java/com/m3u/material/overscroll/OverscrollGraphicsLayerModifier.kt diff --git a/androidApp/src/main/java/com/m3u/androidApp/ui/Scaffold.kt b/androidApp/src/main/java/com/m3u/androidApp/ui/Scaffold.kt index b2e785e3..778fc29e 100644 --- a/androidApp/src/main/java/com/m3u/androidApp/ui/Scaffold.kt +++ b/androidApp/src/main/java/com/m3u/androidApp/ui/Scaffold.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -40,9 +41,9 @@ import androidx.compose.ui.unit.offset import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastMap import androidx.compose.ui.util.fastMaxOfOrNull -import com.m3u.androidApp.ui.internal.TvScaffoldImpl import com.m3u.androidApp.ui.internal.SmartphoneScaffoldImpl import com.m3u.androidApp.ui.internal.TabletScaffoldImpl +import com.m3u.androidApp.ui.internal.TvScaffoldImpl import com.m3u.material.components.Background import com.m3u.material.components.Icon import com.m3u.material.components.IconButton @@ -50,6 +51,9 @@ import com.m3u.material.effects.currentBackStackEntry import com.m3u.material.ktx.tv import com.m3u.material.model.LocalHazeState import com.m3u.material.model.LocalSpacing +import com.m3u.material.overscroll.OverScroll +import com.m3u.material.overscroll.overScrollAlpha +import com.m3u.material.overscroll.overScrollHeader import com.m3u.ui.Destination import com.m3u.ui.FontFamilies import com.m3u.ui.helper.Fob @@ -135,6 +139,7 @@ internal fun MainContent( val tv = tv() val spacing = LocalSpacing.current val hazeState = LocalHazeState.current + val density = LocalDensity.current val title = Metadata.title val subtitle = Metadata.subtitle @@ -142,82 +147,92 @@ internal fun MainContent( val backStackEntry by currentBackStackEntry() - Scaffold( - topBar = { - if (!tv) { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors(Color.Transparent), - windowInsets = windowInsets, - title = { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.defaultMinSize(minHeight = 56.dp) - ) { - Column( - modifier = Modifier - .padding(horizontal = spacing.medium) - .weight(1f) + OverScroll { + Scaffold( + topBar = { + if (!tv) { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors(Color.Transparent), + windowInsets = windowInsets, + title = { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.defaultMinSize(minHeight = 56.dp) ) { - Text( - text = title, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontFamily = FontFamilies.LexendExa - ) - AnimatedVisibility(subtitle.text.isNotEmpty()) { + Column( + modifier = Modifier + .padding(horizontal = spacing.medium) + .weight(1f) + ) { Text( - text = subtitle, - style = MaterialTheme.typography.titleMedium, + text = title, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + fontFamily = FontFamilies.LexendExa ) + AnimatedVisibility(subtitle.text.isNotEmpty()) { + Text( + text = subtitle, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } - } - Row { - actions.forEach { action -> + Row { + actions.forEach { action -> + IconButton( + icon = action.icon, + contentDescription = action.contentDescription, + onClick = action.onClick, + enabled = action.enabled + ) + } + } + + Spacer(modifier = Modifier.width(spacing.medium)) + } + }, + navigationIcon = { + AnimatedContent( + targetState = onBackPressed, + label = "app-scaffold-icon" + ) { onBackPressed -> + if (onBackPressed != null) { IconButton( - icon = action.icon, - contentDescription = action.contentDescription, - onClick = action.onClick, - enabled = action.enabled + icon = backStackEntry?.navigationIcon + ?: Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = null, + onClick = onBackPressed, + modifier = Modifier.wrapContentSize() ) } } - - Spacer(modifier = Modifier.width(spacing.medium)) - } - }, - navigationIcon = { - AnimatedContent( - targetState = onBackPressed, - label = "app-scaffold-icon" - ) { onBackPressed -> - if (onBackPressed != null) { - IconButton( - icon = backStackEntry?.navigationIcon - ?: Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = null, - onClick = onBackPressed, - modifier = Modifier.wrapContentSize() - ) - } - } - }, - modifier = Modifier - .hazeChild(hazeState, style = HazeStyle(blurRadius = 6.dp)) - .fillMaxWidth() - ) - } - }, - contentWindowInsets = windowInsets, - containerColor = Color.Transparent - ) { padding -> - Background { - Box { - StarBackground() - content(padding) + }, + modifier = Modifier + .hazeChild(hazeState, style = HazeStyle(blurRadius = 6.dp)) + .fillMaxWidth() + .overScrollHeader( + headerHeight = with(density) { 82.dp.toPx() } + ) + .overScrollAlpha( + finalAlpha = 0.35f + ) + ) + } + }, + contentWindowInsets = windowInsets, + containerColor = Color.Transparent + ) { padding -> + Background { + Box { + StarBackground( + modifier = Modifier.overScrollAlpha() + ) + content(padding) + } } } } diff --git a/feature/foryou/src/main/java/com/m3u/feature/foryou/components/HeadlineBackground.kt b/feature/foryou/src/main/java/com/m3u/feature/foryou/components/HeadlineBackground.kt index 886ffb9c..a77f5d64 100644 --- a/feature/foryou/src/main/java/com/m3u/feature/foryou/components/HeadlineBackground.kt +++ b/feature/foryou/src/main/java/com/m3u/feature/foryou/components/HeadlineBackground.kt @@ -26,6 +26,7 @@ import coil.compose.AsyncImage import coil.request.CachePolicy import coil.request.ImageRequest import com.m3u.core.architecture.preferences.hiltPreferences +import com.m3u.material.overscroll.overScrollScaleCenter import com.m3u.material.transformation.BlurTransformation import com.m3u.ui.helper.LocalHelper import com.m3u.ui.helper.Metadata @@ -77,6 +78,7 @@ internal fun HeadlineBackground(modifier: Modifier = Modifier) { modifier = modifier .fillMaxWidth() .background(MaterialTheme.colorScheme.background) + .overScrollScaleCenter() .offset { IntOffset( x = 0, diff --git a/material/src/main/java/com/m3u/material/overscroll/OverScrollEffect.kt b/material/src/main/java/com/m3u/material/overscroll/OverScrollEffect.kt new file mode 100644 index 00000000..3877deba --- /dev/null +++ b/material/src/main/java/com/m3u/material/overscroll/OverScrollEffect.kt @@ -0,0 +1,95 @@ +package com.m3u.material.overscroll + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.SpringSpec +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.LocalOverscrollConfiguration +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.MutatorMutex +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll + +enum class OverScrollEffect { + Scale, + ScaleCenter, + ParallaxVertical, + ParallaxHorizontal, + Alpha, + RotationCenter, + RotationVertical, + RotationHorizontal, + Header, +} + +@Stable +class OverScrollState( + val maxOffset: Float = 400f, + private val animationSpec: AnimationSpec = SpringSpec(), +) { + + private val _overScrollOffset = Animatable(0f) + private val mutatorMutex = MutatorMutex() + + val offSet: Float get() = _overScrollOffset.value + + val progress: Float get() = offSet / maxOffset + + var inOverScroll: Boolean by mutableStateOf(false) + internal set + + internal suspend fun animateOffsetTo(offset: Float) { + mutatorMutex.mutate { + _overScrollOffset.animateTo(offset, animationSpec) + } + } + + internal suspend fun dispatchOverScrollDelta(delta: Float) { + mutatorMutex.mutate(MutatePriority.UserInput) { + _overScrollOffset.snapTo(_overScrollOffset.value + delta) + } + } +} + +val LocalOverScrollState = staticCompositionLocalOf { error("Please provide OverScrollState") } + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun OverScroll( + modifier: Modifier = Modifier, + maxOffset: Float = 400f, + animationSpec: AnimationSpec = SpringSpec(), + content: @Composable () -> Unit, +) { + val state: OverScrollState = remember { + OverScrollState(maxOffset, animationSpec) + } + val coroutineScope = rememberCoroutineScope() + val nestedScrollConnection = OverScrollNestedScrollConnection(state, coroutineScope) + LaunchedEffect(state.inOverScroll) { + if (!state.inOverScroll) { + // If there's not a swipe in progress, rest the indicator at 0f + state.animateOffsetTo(0f) + } + } + + CompositionLocalProvider( + LocalOverscrollConfiguration provides null, + LocalOverScrollState provides state + ) { + Column(modifier.nestedScroll(connection = nestedScrollConnection)) { + content() + } + } +} \ No newline at end of file diff --git a/material/src/main/java/com/m3u/material/overscroll/OverScrollModifier.kt b/material/src/main/java/com/m3u/material/overscroll/OverScrollModifier.kt new file mode 100644 index 00000000..72c75b28 --- /dev/null +++ b/material/src/main/java/com/m3u/material/overscroll/OverScrollModifier.kt @@ -0,0 +1,128 @@ +package com.m3u.material.overscroll + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +fun Modifier.overScrollScale( + overScrollState: OverScrollState = LocalOverScrollState.current, + maxOffset: Float = overScrollState.maxOffset, + maxScaleMultiple: Float = 1.5f +) = this.then( + OverscrollGraphicsLayerModifier( + overScrollState = overScrollState, + maxOffset = maxOffset, + maxScaleMultiple = maxScaleMultiple, + effect = OverScrollEffect.Scale + ) +) + +@Composable +fun Modifier.overScrollScaleCenter( + overScrollState: OverScrollState = LocalOverScrollState.current, + maxOffset: Float = overScrollState.maxOffset, + maxScaleMultiple: Float = 1.5f +) = this.then( + OverscrollGraphicsLayerModifier( + overScrollState = overScrollState, + maxOffset = maxOffset, + maxScaleMultiple = maxScaleMultiple, + effect = OverScrollEffect.ScaleCenter + ) +) + +@Composable +fun Modifier.overScrollParallaxVertical( + overScrollState: OverScrollState = LocalOverScrollState.current, + maxOffset: Float = overScrollState.maxOffset, + maxParallaxOffset: Float = 100f +) = this.then( + OverscrollGraphicsLayerModifier( + overScrollState = overScrollState, + maxOffset = maxOffset, + maxParallaxOffset = maxParallaxOffset, + effect = OverScrollEffect.ParallaxVertical + ) +) + +@Composable +fun Modifier.overScrollParallaxHorizontal( + overScrollState: OverScrollState = LocalOverScrollState.current, + maxOffset: Float = overScrollState.maxOffset, + maxParallaxOffset: Float = 100f +) = this.then( + OverscrollGraphicsLayerModifier( + overScrollState = overScrollState, + maxOffset = maxOffset, + maxParallaxOffset = maxParallaxOffset, + effect = OverScrollEffect.ParallaxHorizontal + ) +) + +@Composable +fun Modifier.overScrollAlpha( + overScrollState: OverScrollState = LocalOverScrollState.current, + maxOffset: Float = overScrollState.maxOffset, + finalAlpha: Float = 0f +) = this.then( + OverscrollGraphicsLayerModifier( + overScrollState = overScrollState, + maxOffset = maxOffset, + finalAlpha = finalAlpha, + effect = OverScrollEffect.Alpha + ) +) + +@Composable +fun Modifier.overScrollRotationCenter( + overScrollState: OverScrollState = LocalOverScrollState.current, + maxOffset: Float = overScrollState.maxOffset, + rotationMultiple: Float = 1f +) = this.then( + OverscrollGraphicsLayerModifier( + overScrollState = overScrollState, + maxOffset = maxOffset, + rotationMultiple = rotationMultiple, + effect = OverScrollEffect.RotationCenter + ) +) + +@Composable +fun Modifier.overScrollRotationVertical( + overScrollState: OverScrollState = LocalOverScrollState.current, + maxOffset: Float = overScrollState.maxOffset, + rotationMultiple: Float = 1f +) = this.then( + OverscrollGraphicsLayerModifier( + overScrollState = overScrollState, + maxOffset = maxOffset, + rotationMultiple = rotationMultiple, + effect = OverScrollEffect.RotationVertical + ) +) + +@Composable +fun Modifier.overScrollRotationHorizontal( + overScrollState: OverScrollState = LocalOverScrollState.current, + maxOffset: Float = overScrollState.maxOffset, + rotationMultiple: Float = 1f +) = this.then( + OverscrollGraphicsLayerModifier( + overScrollState = overScrollState, + maxOffset = maxOffset, + rotationMultiple = rotationMultiple, + effect = OverScrollEffect.RotationHorizontal + ) +) + +@Composable +fun Modifier.overScrollHeader( + overScrollState: OverScrollState = LocalOverScrollState.current, + headerHeight: Float = DefaultHeaderHeight, +) = this.then( + OverscrollGraphicsLayerModifier( + overScrollState = overScrollState, + headerHeight = headerHeight, + effect = OverScrollEffect.Header + ) +) \ No newline at end of file diff --git a/material/src/main/java/com/m3u/material/overscroll/OverScrollNestedScrollConnection.kt b/material/src/main/java/com/m3u/material/overscroll/OverScrollNestedScrollConnection.kt new file mode 100644 index 00000000..7b68ac18 --- /dev/null +++ b/material/src/main/java/com/m3u/material/overscroll/OverScrollNestedScrollConnection.kt @@ -0,0 +1,63 @@ +package com.m3u.material.overscroll + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.unit.Velocity +import com.google.android.material.math.MathUtils.lerp +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue + +internal class OverScrollNestedScrollConnection( + private val state: OverScrollState, + private val coroutineScope: CoroutineScope, + private val scrollMultiplier: Float = 0.5f, +) : NestedScrollConnection { + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + return when { + available.y < 0 -> onScroll(available) + else -> Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + return when { + available.y > 0 -> onScroll(available) + else -> Offset.Zero + } + } + + private fun onScroll(available: Offset): Offset { + state.inOverScroll = true + val currentScrollMultiplier = + lerp(scrollMultiplier, 0.01f, (available.y + state.offSet) / state.maxOffset) + val newOffset = (available.y * currentScrollMultiplier + state.offSet).coerceAtLeast(0f) + val consumed = newOffset - state.offSet + return if (consumed.absoluteValue >= 0f && newOffset <= state.maxOffset) { + coroutineScope.launch { + state.dispatchOverScrollDelta(consumed) + } + // Return the consumed Y + Offset(x = 0f, y = consumed / currentScrollMultiplier) + } else { + Offset.Zero + } + } + + + override suspend fun onPreFling(available: Velocity): Velocity { + state.inOverScroll = false + return Velocity.Zero + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + state.inOverScroll = false + return Velocity.Zero + } +} \ No newline at end of file diff --git a/material/src/main/java/com/m3u/material/overscroll/OverscrollGraphicsLayerModifier.kt b/material/src/main/java/com/m3u/material/overscroll/OverscrollGraphicsLayerModifier.kt new file mode 100644 index 00000000..8ffaca78 --- /dev/null +++ b/material/src/main/java/com/m3u/material/overscroll/OverscrollGraphicsLayerModifier.kt @@ -0,0 +1,103 @@ +package com.m3u.material.overscroll + +import androidx.compose.ui.graphics.GraphicsLayerScope +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.unit.Constraints +import com.google.android.material.math.MathUtils.lerp + +internal const val DefaultHeaderHeight = 800f + +internal class OverscrollGraphicsLayerModifier( + private val overScrollState: OverScrollState, + private val headerHeight: Float = DefaultHeaderHeight, + private val maxOffset: Float = overScrollState.maxOffset, + private val maxScaleMultiple: Float = 1.0f, + private val maxParallaxOffset: Float = 100f, + private val finalAlpha: Float = 1.0f, + private val rotationMultiple: Float = 0f, + private val effect: OverScrollEffect = OverScrollEffect.Scale +) : LayoutModifier { + + override fun MeasureScope.measure( + measurable: Measurable, + constraints: Constraints + ): MeasureResult { + val offset = overScrollState.offSet + val process = offset / maxOffset + + var newTranslationY = 0f + var newTranslationX = 0f + var newAlpha = 1.0f + var newRotationZ = 0f + var newRotationX = 0f + var newRotationY = 0f + + val newHeaderHeight = headerHeight + offset + val placeable = if (effect == OverScrollEffect.Header) { + measurable.measure( + constraints.copy( + minHeight = newHeaderHeight.toInt(), + maxHeight = newHeaderHeight.toInt() + ) + ) + } else { + measurable.measure(constraints) + } + + val scaleFactor = lerp(1.0f, maxScaleMultiple, process) + val newHeight = scaleFactor * placeable.measuredHeight + + when (effect) { + OverScrollEffect.Scale -> { + newTranslationX = (placeable.width * (scaleFactor - 1)) / 2 + newTranslationY = (placeable.height * (scaleFactor - 1)) / 2 + } + + OverScrollEffect.ScaleCenter -> { + } + + OverScrollEffect.ParallaxVertical -> { + newTranslationY = lerp(0f, maxParallaxOffset, process) + } + + OverScrollEffect.ParallaxHorizontal -> { + newTranslationX = lerp(0f, maxParallaxOffset, process) + } + + OverScrollEffect.Header -> { + } + + OverScrollEffect.Alpha -> { + newAlpha = lerp(1.0f, finalAlpha, process) + } + + OverScrollEffect.RotationCenter -> { + newRotationZ = lerp(0f, rotationMultiple * 360, process) + } + + OverScrollEffect.RotationHorizontal -> { + newRotationY = lerp(0f, rotationMultiple * 360, process) + } + + OverScrollEffect.RotationVertical -> { + newRotationX = lerp(0f, rotationMultiple * 360, process) + } + } + val layerBlock: GraphicsLayerScope.() -> Unit = { + scaleX = scaleFactor + scaleY = scaleFactor + translationY = newTranslationY + translationX = newTranslationX + alpha = newAlpha + rotationZ = newRotationZ + rotationX = newRotationX + rotationY = newRotationY + } + return layout(placeable.width, newHeight.toInt()) { + placeable.placeWithLayer(0, 0, layerBlock = layerBlock) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/com/m3u/ui/Toolkit.kt b/ui/src/main/java/com/m3u/ui/Toolkit.kt index 9ee2a533..ff190141 100644 --- a/ui/src/main/java/com/m3u/ui/Toolkit.kt +++ b/ui/src/main/java/com/m3u/ui/Toolkit.kt @@ -68,7 +68,6 @@ fun Toolkit( val spacing = if (preferences.compactDimension) Spacing.COMPACT else Spacing.REGULAR - CompositionLocalProvider( LocalHelper provides helper, LocalM3UHapticFeedback provides createM3UHapticFeedback(),