Skip to content

Commit

Permalink
1.14.1/overscroll (#210)
Browse files Browse the repository at this point in the history
* feat: overscroll effect.

* fix: remove unused import directive.
  • Loading branch information
oxyroid authored Nov 17, 2024
1 parent ca92e34 commit d3da263
Show file tree
Hide file tree
Showing 7 changed files with 473 additions and 68 deletions.
149 changes: 82 additions & 67 deletions androidApp/src/main/java/com/m3u/androidApp/ui/Scaffold.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -40,16 +41,19 @@ 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
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
Expand Down Expand Up @@ -135,89 +139,100 @@ 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
val actions = Metadata.actions

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)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,6 +78,7 @@ internal fun HeadlineBackground(modifier: Modifier = Modifier) {
modifier = modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.overScrollScaleCenter()
.offset {
IntOffset(
x = 0,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Float> = 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<OverScrollState> { error("Please provide OverScrollState") }

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun OverScroll(
modifier: Modifier = Modifier,
maxOffset: Float = 400f,
animationSpec: AnimationSpec<Float> = 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()
}
}
}
Loading

0 comments on commit d3da263

Please sign in to comment.