diff --git a/app/src/main/java/me/onebone/toolbar/ParallaxActivity.kt b/app/src/main/java/me/onebone/toolbar/ParallaxActivity.kt index a6dcdd9..c7cf2ba 100644 --- a/app/src/main/java/me/onebone/toolbar/ParallaxActivity.kt +++ b/app/src/main/java/me/onebone/toolbar/ParallaxActivity.kt @@ -27,11 +27,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.MaterialTheme @@ -66,6 +62,7 @@ fun ParallaxEffect() { modifier = Modifier.fillMaxSize(), state = state, scrollStrategy = ScrollStrategy.EnterAlwaysCollapsed, + snapConfig = SnapConfig(), toolbarModifier = Modifier.background(MaterialTheme.colors.primary), toolbar = { // Collapsing toolbar collapses its size as small as the that of diff --git a/lib/src/main/java/me/onebone/toolbar/AppBarContainer.kt b/lib/src/main/java/me/onebone/toolbar/AppBarContainer.kt index 9c2f30f..bba413f 100644 --- a/lib/src/main/java/me/onebone/toolbar/AppBarContainer.kt +++ b/lib/src/main/java/me/onebone/toolbar/AppBarContainer.kt @@ -24,23 +24,14 @@ package me.onebone.toolbar import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.Measurable -import androidx.compose.ui.layout.MeasurePolicy -import androidx.compose.ui.layout.MeasureResult -import androidx.compose.ui.layout.MeasureScope -import androidx.compose.ui.layout.ParentDataModifier -import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.* import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import kotlin.math.max -import kotlin.math.roundToInt @Deprecated( "Use AppBarContainer for naming consistency", @@ -53,13 +44,13 @@ import kotlin.math.roundToInt fun AppbarContainer( modifier: Modifier = Modifier, scrollStrategy: ScrollStrategy, - collapsingToolbarState: CollapsingToolbarState, + collapsingToolbarScaffoldState: CollapsingToolbarScaffoldState, content: @Composable AppbarContainerScope.() -> Unit ) { AppBarContainer( modifier = modifier, scrollStrategy = scrollStrategy, - collapsingToolbarState = collapsingToolbarState, + collapsingToolbarScaffoldState = collapsingToolbarScaffoldState, content = content ) } @@ -76,15 +67,15 @@ fun AppBarContainer( modifier: Modifier = Modifier, scrollStrategy: ScrollStrategy, /** The state of a connected collapsing toolbar */ - collapsingToolbarState: CollapsingToolbarState, + collapsingToolbarScaffoldState: CollapsingToolbarScaffoldState, content: @Composable AppbarContainerScope.() -> Unit ) { - val offsetY = remember { mutableStateOf(0) } val flingBehavior = ScrollableDefaults.flingBehavior() + val snapStrategy = null - val (scope, measurePolicy) = remember(scrollStrategy, collapsingToolbarState) { - AppbarContainerScopeImpl(scrollStrategy.create(offsetY, collapsingToolbarState, flingBehavior)) to - AppbarMeasurePolicy(scrollStrategy, collapsingToolbarState, offsetY) + val (scope, measurePolicy) = remember(scrollStrategy, collapsingToolbarScaffoldState) { + AppbarContainerScopeImpl(scrollStrategy.create(collapsingToolbarScaffoldState, flingBehavior, snapStrategy)) to + AppbarMeasurePolicy(scrollStrategy, collapsingToolbarScaffoldState) } Layout( @@ -118,8 +109,7 @@ private object AppBarBodyMarker private class AppbarMeasurePolicy( private val scrollStrategy: ScrollStrategy, - private val toolbarState: CollapsingToolbarState, - private val offsetY: State + private val collapsingToolbarScaffoldState: CollapsingToolbarScaffoldState ): MeasurePolicy { override fun MeasureScope.measure( measurables: List, @@ -151,6 +141,7 @@ private class AppbarMeasurePolicy( } } + val toolbarState = collapsingToolbarScaffoldState.toolbarState val placeables = nonToolbars.map { measurable -> val childConstraints = if(scrollStrategy == ScrollStrategy.ExitUntilCollapsed) { constraints.copy( @@ -175,16 +166,17 @@ private class AppbarMeasurePolicy( height += (toolbarPlaceable?.height ?: 0) + val offsetY = collapsingToolbarScaffoldState.offsetY return layout( width.coerceIn(constraints.minWidth, constraints.maxWidth), height.coerceIn(constraints.minHeight, constraints.maxHeight) ) { - toolbarPlaceable?.place(x = 0, y = offsetY.value) + toolbarPlaceable?.place(x = 0, y = offsetY) placeables.forEach { placeable -> placeable.place( x = 0, - y = offsetY.value + (toolbarPlaceable?.height ?: 0) + y = offsetY + (toolbarPlaceable?.height ?: 0) ) } } diff --git a/lib/src/main/java/me/onebone/toolbar/CollapsingToolbar.kt b/lib/src/main/java/me/onebone/toolbar/CollapsingToolbar.kt index a9958ee..8407358 100644 --- a/lib/src/main/java/me/onebone/toolbar/CollapsingToolbar.kt +++ b/lib/src/main/java/me/onebone/toolbar/CollapsingToolbar.kt @@ -30,22 +30,11 @@ import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.ScrollScope import androidx.compose.foundation.gestures.ScrollableState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.Measurable -import androidx.compose.ui.layout.MeasurePolicy -import androidx.compose.ui.layout.MeasureResult -import androidx.compose.ui.layout.MeasureScope -import androidx.compose.ui.layout.ParentDataModifier -import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.* import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize @@ -57,7 +46,7 @@ import kotlin.math.roundToInt @Stable class CollapsingToolbarState( - initial: Int = Int.MAX_VALUE + initial: Int = CollapsingToolbarDefaults.InitialHeight ): ScrollableState { /** * [height] indicates current height of the toolbar. @@ -136,8 +125,23 @@ class CollapsingToolbarState( ) fun feedScroll(value: Float): Float = dispatchRawDelta(value) + /** + * @return Remaining velocity after fling + */ + suspend fun fling(flingBehavior: FlingBehavior, velocity: Float): Float { + var left = velocity + scroll { + with(flingBehavior) { + left = performFling(left) + } + } + + return left + } + + // TODO: A strange jump in snap speed is often observed @ExperimentalToolbarApi - suspend fun expand(duration: Int = 200) { + suspend fun expand(duration: Int = CollapsingToolbarDefaults.ExpandDuration) { val anim = AnimationState(height.toFloat()) scroll { @@ -149,8 +153,9 @@ class CollapsingToolbarState( } } + // TODO: A strange jump in snap speed is often observed @ExperimentalToolbarApi - suspend fun collapse(duration: Int = 200) { + suspend fun collapse(duration: Int = CollapsingToolbarDefaults.CollapseDuration) { val anim = AnimationState(height.toFloat()) scroll { @@ -162,20 +167,6 @@ class CollapsingToolbarState( } } - /** - * @return Remaining velocity after fling - */ - suspend fun fling(flingBehavior: FlingBehavior, velocity: Float): Float { - var left = velocity - scroll { - with(flingBehavior) { - left = performFling(left) - } - } - - return left - } - override val isScrollInProgress: Boolean get() = scrollableState.isScrollInProgress @@ -189,7 +180,7 @@ class CollapsingToolbarState( @Composable fun rememberCollapsingToolbarState( - initial: Int = Int.MAX_VALUE + initial: Int = CollapsingToolbarDefaults.InitialHeight ): CollapsingToolbarState { return remember { CollapsingToolbarState( @@ -216,6 +207,13 @@ fun CollapsingToolbar( ) } +object CollapsingToolbarDefaults { + const val InitialHeight = Int.MAX_VALUE + const val Edge = 0.5f + const val ExpandDuration = 200 + const val CollapseDuration = 200 +} + private class CollapsingToolbarMeasurePolicy( private val collapsingToolbarState: CollapsingToolbarState ): MeasurePolicy { diff --git a/lib/src/main/java/me/onebone/toolbar/CollapsingToolbarScaffold.kt b/lib/src/main/java/me/onebone/toolbar/CollapsingToolbarScaffold.kt index 8a06714..51a00b8 100644 --- a/lib/src/main/java/me/onebone/toolbar/CollapsingToolbarScaffold.kt +++ b/lib/src/main/java/me/onebone/toolbar/CollapsingToolbarScaffold.kt @@ -23,6 +23,9 @@ package me.onebone.toolbar import android.os.Bundle +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @@ -45,6 +48,24 @@ class CollapsingToolbarScaffoldState( get() = offsetYState.value internal val offsetYState = mutableStateOf(initialOffsetY) + + @ExperimentalToolbarApi + suspend fun expandOffset(duration: Int = CollapsingToolbarDefaults.ExpandDuration) { + val anim = AnimationState(offsetY.toFloat()) + + anim.animateTo(0f, tween(duration)) { + offsetYState.value = value.toInt() + } + } + + @ExperimentalToolbarApi + suspend fun collapseOffset(duration: Int = CollapsingToolbarDefaults.CollapseDuration) { + val anim = AnimationState(offsetY.toFloat()) + + anim.animateTo(-toolbarState.minHeight.toFloat(), tween(duration)) { + offsetYState.value = value.toInt() + } + } } private class CollapsingToolbarScaffoldStateSaver: Saver { @@ -75,6 +96,7 @@ fun CollapsingToolbarScaffold( modifier: Modifier, state: CollapsingToolbarScaffoldState, scrollStrategy: ScrollStrategy, + snapConfig: SnapConfig? = null, toolbarModifier: Modifier = Modifier, toolbar: @Composable CollapsingToolbarScope.() -> Unit, body: @Composable () -> Unit @@ -82,7 +104,7 @@ fun CollapsingToolbarScaffold( val flingBehavior = ScrollableDefaults.flingBehavior() val nestedScrollConnection = remember(scrollStrategy, state) { - scrollStrategy.create(state.offsetYState, state.toolbarState, flingBehavior) + scrollStrategy.create(state, flingBehavior, snapConfig) } val toolbarState = state.toolbarState diff --git a/lib/src/main/java/me/onebone/toolbar/ScrollStrategy.kt b/lib/src/main/java/me/onebone/toolbar/ScrollStrategy.kt index 0e0262b..63c8dee 100644 --- a/lib/src/main/java/me/onebone/toolbar/ScrollStrategy.kt +++ b/lib/src/main/java/me/onebone/toolbar/ScrollStrategy.kt @@ -28,37 +28,38 @@ 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 kotlin.math.absoluteValue enum class ScrollStrategy { EnterAlways { override fun create( - offsetY: MutableState, - toolbarState: CollapsingToolbarState, - flingBehavior: FlingBehavior + scaffoldState: CollapsingToolbarScaffoldState, + flingBehavior: FlingBehavior, + snapConfig: SnapConfig?, ): NestedScrollConnection = - EnterAlwaysNestedScrollConnection(offsetY, toolbarState, flingBehavior) + EnterAlwaysNestedScrollConnection(scaffoldState, flingBehavior, snapConfig) }, EnterAlwaysCollapsed { override fun create( - offsetY: MutableState, - toolbarState: CollapsingToolbarState, - flingBehavior: FlingBehavior + scaffoldState: CollapsingToolbarScaffoldState, + flingBehavior: FlingBehavior, + snapConfig: SnapConfig?, ): NestedScrollConnection = - EnterAlwaysCollapsedNestedScrollConnection(offsetY, toolbarState, flingBehavior) + EnterAlwaysCollapsedNestedScrollConnection(scaffoldState, flingBehavior, snapConfig) }, ExitUntilCollapsed { override fun create( - offsetY: MutableState, - toolbarState: CollapsingToolbarState, - flingBehavior: FlingBehavior + scaffoldState: CollapsingToolbarScaffoldState, + flingBehavior: FlingBehavior, + snapConfig: SnapConfig?, ): NestedScrollConnection = - ExitUntilCollapsedNestedScrollConnection(toolbarState, flingBehavior) + ExitUntilCollapsedNestedScrollConnection(scaffoldState, flingBehavior, snapConfig) }; internal abstract fun create( - offsetY: MutableState, - toolbarState: CollapsingToolbarState, - flingBehavior: FlingBehavior + scaffoldState: CollapsingToolbarScaffoldState, + flingBehavior: FlingBehavior, + snapConfig: SnapConfig?, ): NestedScrollConnection } @@ -78,19 +79,20 @@ private class ScrollDelegate( } internal class EnterAlwaysNestedScrollConnection( - private val offsetY: MutableState, - private val toolbarState: CollapsingToolbarState, - private val flingBehavior: FlingBehavior + private val scaffoldState: CollapsingToolbarScaffoldState, + private val flingBehavior: FlingBehavior, + private val snapConfig: SnapConfig? ): NestedScrollConnection { - private val scrollDelegate = ScrollDelegate(offsetY) + private val scrollDelegate = ScrollDelegate(scaffoldState.offsetYState) private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl()) + private val toolbarState = scaffoldState.toolbarState override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val dy = available.y tracker.delta(dy) val toolbar = toolbarState.height.toFloat() - val offset = offsetY.value.toFloat() + val offset = scaffoldState.offsetY.toFloat() // -toolbarHeight <= offsetY + dy <= 0 val consume = if(dy < 0) { @@ -126,28 +128,47 @@ internal class EnterAlwaysNestedScrollConnection( return available.copy(y = available.y - left) } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + // TODO: Cancel expand/collapse animation inside onPreScroll + snapConfig?.let { + val isToolbarChangingOffset = scaffoldState.offsetY != 0 + if (isToolbarChangingOffset) { + // When the toolbar is hiding, it does it through changing the offset and does not + // change its height, so we must process not the snap of the toolbar, but the + // snap of its offset. + scaffoldState.performOffsetSnap(it) + } else { + toolbarState.performSnap(it) + } + } + + return super.onPostFling(consumed, available) + } } internal class EnterAlwaysCollapsedNestedScrollConnection( - private val offsetY: MutableState, - private val toolbarState: CollapsingToolbarState, - private val flingBehavior: FlingBehavior + private val scaffoldState: CollapsingToolbarScaffoldState, + private val flingBehavior: FlingBehavior, + private val snapConfig: SnapConfig?, ): NestedScrollConnection { - private val scrollDelegate = ScrollDelegate(offsetY) + private val scrollDelegate = ScrollDelegate(scaffoldState.offsetYState) private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl()) + private val toolbarState = scaffoldState.toolbarState override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val dy = available.y tracker.delta(dy) val consumed = if(dy > 0) { // expanding: offset -> body -> toolbar - val offsetConsumption = dy.coerceAtMost(-offsetY.value.toFloat()) + val offsetConsumption = dy.coerceAtMost(-scaffoldState.offsetY.toFloat()) scrollDelegate.doScroll(offsetConsumption) offsetConsumption }else{ // collapsing: toolbar -> offset -> body val toolbarConsumption = toolbarState.dispatchRawDelta(dy) - val offsetConsumption = (dy - toolbarConsumption).coerceAtLeast(-toolbarState.height.toFloat() - offsetY.value) + val offsetConsumption = (dy - toolbarConsumption) + .coerceAtLeast(-toolbarState.height.toFloat() - scaffoldState.offsetY) scrollDelegate.doScroll(offsetConsumption) @@ -186,15 +207,30 @@ internal class EnterAlwaysCollapsedNestedScrollConnection( dy } + // TODO: Cancel expand/collapse animation inside onPreScroll + snapConfig?.let { + val isToolbarChangingOffset = scaffoldState.offsetY != 0 + if (isToolbarChangingOffset) { + // When the toolbar is hiding, it does it through changing the offset and does not + // change its height, so we must process not the snap of the toolbar, but the + // snap of its offset. + scaffoldState.performOffsetSnap(it) + } else { + toolbarState.performSnap(it) + } + } + return available.copy(y = available.y - left) } } internal class ExitUntilCollapsedNestedScrollConnection( - private val toolbarState: CollapsingToolbarState, - private val flingBehavior: FlingBehavior + private val scaffoldState: CollapsingToolbarScaffoldState, + private val flingBehavior: FlingBehavior, + private val snapConfig: SnapConfig? ): NestedScrollConnection { private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl()) + private val toolbarState = scaffoldState.toolbarState override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val dy = available.y @@ -246,6 +282,32 @@ internal class ExitUntilCollapsedNestedScrollConnection( velocity } + // TODO: Cancel expand/collapse animation inside onPreScroll + snapConfig?.let { scaffoldState.toolbarState.performSnap(it) } + return available.copy(y = available.y - left) } } + +// TODO: Is there a better solution rather OptIn ExperimentalToolbarApi? +@OptIn(ExperimentalToolbarApi::class) +private suspend fun CollapsingToolbarState.performSnap(snapConfig: SnapConfig) { + if (progress > snapConfig.edge && progress < 1f) { + expand(snapConfig.expandDuration) + } else if (progress <= snapConfig.edge && progress > 0f){ + collapse(snapConfig.collapseDuration) + } +} + +// TODO: Is there a better solution rather OptIn ExperimentalToolbarApi? +@OptIn(ExperimentalToolbarApi::class) +private suspend fun CollapsingToolbarScaffoldState.performOffsetSnap(snapConfig: SnapConfig) { + if (toolbarState.minHeight == 0) return + + val offsetProgress = 1f - (offsetY / toolbarState.minHeight).absoluteValue + if (offsetProgress > snapConfig.edge) { + expandOffset(snapConfig.expandDuration) + } else { + collapseOffset(snapConfig.collapseDuration) + } +} diff --git a/lib/src/main/java/me/onebone/toolbar/SnapConfig.kt b/lib/src/main/java/me/onebone/toolbar/SnapConfig.kt new file mode 100644 index 0000000..4c0b89b --- /dev/null +++ b/lib/src/main/java/me/onebone/toolbar/SnapConfig.kt @@ -0,0 +1,11 @@ +package me.onebone.toolbar + +import androidx.annotation.FloatRange +import androidx.compose.runtime.Immutable + +@Immutable +data class SnapConfig( + @FloatRange(from = 0.0, to = 1.0) val edge: Float = CollapsingToolbarDefaults.Edge, + val expandDuration: Int = CollapsingToolbarDefaults.ExpandDuration, + val collapseDuration: Int = CollapsingToolbarDefaults.CollapseDuration +) diff --git a/lib/src/main/java/me/onebone/toolbar/ToolbarWithFabScaffold.kt b/lib/src/main/java/me/onebone/toolbar/ToolbarWithFabScaffold.kt index 2dd449d..c8633de 100644 --- a/lib/src/main/java/me/onebone/toolbar/ToolbarWithFabScaffold.kt +++ b/lib/src/main/java/me/onebone/toolbar/ToolbarWithFabScaffold.kt @@ -12,6 +12,7 @@ fun ToolbarWithFabScaffold( modifier: Modifier, state: CollapsingToolbarScaffoldState, scrollStrategy: ScrollStrategy, + snapConfig: SnapConfig? = null, toolbarModifier: Modifier = Modifier, toolbar: @Composable CollapsingToolbarScope.() -> Unit, fab: @Composable () -> Unit, @@ -33,6 +34,7 @@ fun ToolbarWithFabScaffold( modifier = modifier, state = state, scrollStrategy = scrollStrategy, + snapConfig = snapConfig, toolbarModifier = toolbarModifier, toolbar = toolbar, body = body