diff --git a/README.md b/README.md index 44287d4..3068fc4 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ Note that the `CollapsingToolbarScaffoldState` is stable, which means that a cha val state = rememberCollapsingToolbarScaffoldState() val offsetY = state.offsetY // y offset of the layout val progress = state.toolbarState.progress // how much the toolbar is expanded (0: collapsed, 1: expanded) +val offsetProgress = state.offsetProgress // how much the toolbar offset (EnterAlways, EnterAlwaysCollapsed) is expanded (0: collapsed, 1: expanded) +val totalProgress = state.totalProgress // how much the toolbar height and offset is expanded (0: collapsed, 1: expanded) Text( text = "Hello World", @@ -65,8 +67,19 @@ Text( ) ``` +Also, it is possible to trigger collapse/expansion animations manually: +```kotlin +val state = rememberCollapsingToolbarScaffoldState() +state.toolbarState.expand() // expand toolbar +state.toolbarState.collapse() // collapse toolbar +state.offsetExpand() // expand toolbar offset (EnterAlways, EnterAlwaysCollapsed) +state.offsetCollapse() // collapse toolbar offset (EnterAlways, EnterAlwaysCollapsed) +state.expand() // expand altogether toolbar and offset +state.collapse() // collapse altogether toolbar and offset +``` + ## parallax, pin, road -You can tell children of CollapsingToolbar how to deal with a collapse/expansion. This works almost the same way to the `collapseMode` in the `CollapsingToolbarLayout` except for the `road` modifier. +You can tell children of `CollapsingToolbar` how to deal with a collapse/expansion. This works almost the same way to the `collapseMode` in the `CollapsingToolbarLayout` except for the `road` modifier. ```kotlin CollapsingToolbar(/* ... */) { @@ -76,6 +89,8 @@ CollapsingToolbar(/* ... */) { } ``` +To properly set the `minHeight`/`maxHeight` for `CollapsingToolbar` use `Modifier.pin()` on the child, because `CollapsingToolbar` determines its `minHeight`/`maxHeight` by the smallest/largest child. + ### road modifier The `road()` modifier allows you to place a child relatively to the toolbar. It receives two arguments: `whenCollapsed` and `whenExpanded`. As the name suggests, these describe how to place a child when the toolbar is collapsed or expanded, respectively. This can be used to display a title text on the toolbar which is moving as the scroll is fed. @@ -99,7 +114,7 @@ The above code orders the title `Text` to be placed at the _CenterStart_ positio ## Scroll Strategy -`ScrollStrategy` defines how CollapsingToolbar consumes scroll. You can set your desired behavior by providing `scrollStrategy` to `CollapsingToolbarScaffold`: +`ScrollStrategy` defines how `CollapsingToolbar` consumes scroll. You can set your desired behavior by providing `scrollStrategy` to `CollapsingToolbarScaffold`: ```kotlin CollapsingToolbarScaffold( @@ -119,3 +134,25 @@ CollapsingToolbarScaffold( ### ScrollStrategy.ExitUntilCollapsed ![ExitUntilCollapsed](img/exit-until-collapsed.gif) + +## Snap Config +`SnapConfig` defines how `CollapsingToolbar` snaps to its edges. You can enable snapping by providing `snapConfig` to `CollapsingToolbarScaffold`: + +```kotlin +CollapsingToolbarScaffold( + /* ... */ + snapConfig = SnapConfig() // "collapseThreshold = 0.5" by default +) { + /* ... */ +} +``` + +### Snap for ScrollStrategy.EnterAlways +![Snap for EnterAlways](img/snap-enter-always.gif) + +### Snap for ScrollStrategy.EnterAlwaysCollapsed +![Snap for EnterAlwaysCollapsed](img/snap-enter-always-collapsed.gif) + +### Snap ScrollStrategy.ExitUntilCollapsed +![Snap for ExitUntilCollapsed](img/snap-exit-until-collapsed.gif) + diff --git a/app/src/main/java/me/onebone/toolbar/ParallaxActivity.kt b/app/src/main/java/me/onebone/toolbar/ParallaxActivity.kt index ad4d80a..4053f28 100644 --- a/app/src/main/java/me/onebone/toolbar/ParallaxActivity.kt +++ b/app/src/main/java/me/onebone/toolbar/ParallaxActivity.kt @@ -55,7 +55,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import me.onebone.toolbar.ui.theme.CollapsingToolbarTheme -class ParallaxActivity: ComponentActivity() { +class ParallaxActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { @@ -79,6 +79,7 @@ fun ParallaxEffect() { modifier = Modifier.fillMaxSize(), state = state, scrollStrategy = ScrollStrategy.EnterAlwaysCollapsed, + snapConfig = SnapConfig(), toolbarModifier = Modifier.background(MaterialTheme.colors.primary), enabled = enabled, toolbar = { @@ -127,7 +128,7 @@ fun ParallaxEffect() { modifier = Modifier .padding(16.dp) .align(Alignment.BottomEnd), - onClick = { } + onClick = { } ) { Text(text = "Floating Button!") } diff --git a/img/snap-enter-always-collapsed.gif b/img/snap-enter-always-collapsed.gif new file mode 100644 index 0000000..721df15 Binary files /dev/null and b/img/snap-enter-always-collapsed.gif differ diff --git a/img/snap-enter-always.gif b/img/snap-enter-always.gif new file mode 100644 index 0000000..f149e17 Binary files /dev/null and b/img/snap-enter-always.gif differ diff --git a/img/snap-exit-until-collapsed.gif b/img/snap-exit-until-collapsed.gif new file mode 100644 index 0000000..f694dac Binary files /dev/null and b/img/snap-exit-until-collapsed.gif differ diff --git a/lib/src/main/java/me/onebone/toolbar/AppBarContainer.kt b/lib/src/main/java/me/onebone/toolbar/AppBarContainer.kt index 9c2f30f..4ea2476 100644 --- a/lib/src/main/java/me/onebone/toolbar/AppBarContainer.kt +++ b/lib/src/main/java/me/onebone/toolbar/AppBarContainer.kt @@ -24,8 +24,6 @@ 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 @@ -40,28 +38,22 @@ import androidx.compose.ui.layout.Placeable 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", replaceWith = ReplaceWith( - "AppBarContainer(modifier, scrollStrategy, collapsingToolbarState, content)", + "AppBarContainer(modifier, snapConfig, scrollStrategy, state, content)", "me.onebone.toolbar" ) ) @Composable fun AppbarContainer( modifier: Modifier = Modifier, + snapConfig: SnapConfig? = null, scrollStrategy: ScrollStrategy, - collapsingToolbarState: CollapsingToolbarState, + state: CollapsingToolbarScaffoldState, content: @Composable AppbarContainerScope.() -> Unit ) { - AppBarContainer( - modifier = modifier, - scrollStrategy = scrollStrategy, - collapsingToolbarState = collapsingToolbarState, - content = content - ) } @Deprecated( @@ -74,17 +66,17 @@ fun AppbarContainer( @Composable fun AppBarContainer( modifier: Modifier = Modifier, + snapConfig: SnapConfig? = null, scrollStrategy: ScrollStrategy, /** The state of a connected collapsing toolbar */ - collapsingToolbarState: CollapsingToolbarState, + state: CollapsingToolbarScaffoldState, content: @Composable AppbarContainerScope.() -> Unit ) { - val offsetY = remember { mutableStateOf(0) } val flingBehavior = ScrollableDefaults.flingBehavior() - val (scope, measurePolicy) = remember(scrollStrategy, collapsingToolbarState) { - AppbarContainerScopeImpl(scrollStrategy.create(offsetY, collapsingToolbarState, flingBehavior)) to - AppbarMeasurePolicy(scrollStrategy, collapsingToolbarState, offsetY) + val (scope, measurePolicy) = remember(scrollStrategy, state) { + AppbarContainerScopeImpl(scrollStrategy.create(state, flingBehavior, snapConfig)) to + AppbarMeasurePolicy(scrollStrategy, state) } Layout( @@ -100,7 +92,7 @@ interface AppbarContainerScope { internal class AppbarContainerScopeImpl( private val nestedScrollConnection: NestedScrollConnection -): AppbarContainerScope { +) : AppbarContainerScope { override fun Modifier.appBarBody(): Modifier { return this .then(AppBarBodyMarkerModifier) @@ -108,7 +100,7 @@ internal class AppbarContainerScopeImpl( } } -private object AppBarBodyMarkerModifier: ParentDataModifier { +private object AppBarBodyMarkerModifier : ParentDataModifier { override fun Density.modifyParentData(parentData: Any?): Any { return AppBarBodyMarker } @@ -118,9 +110,8 @@ private object AppBarBodyMarker private class AppbarMeasurePolicy( private val scrollStrategy: ScrollStrategy, - private val toolbarState: CollapsingToolbarState, - private val offsetY: State -): MeasurePolicy { + private val state: CollapsingToolbarScaffoldState, +) : MeasurePolicy { override fun MeasureScope.measure( measurables: List, constraints: Constraints @@ -132,33 +123,35 @@ private class AppbarMeasurePolicy( val nonToolbars = measurables.filter { val data = it.parentData - if(data != AppBarBodyMarker) { - if(toolbarPlaceable != null) + if (data != AppBarBodyMarker) { + if (toolbarPlaceable != null) throw IllegalStateException("There cannot exist multiple toolbars under single parent") - val placeable = it.measure(constraints.copy( - minWidth = 0, - minHeight = 0 - )) + val placeable = it.measure( + constraints.copy( + minWidth = 0, + minHeight = 0 + ) + ) width = max(width, placeable.width) height = max(height, placeable.height) toolbarPlaceable = placeable false - }else{ + } else { true } } val placeables = nonToolbars.map { measurable -> - val childConstraints = if(scrollStrategy == ScrollStrategy.ExitUntilCollapsed) { + val childConstraints = if (scrollStrategy == ScrollStrategy.ExitUntilCollapsed) { constraints.copy( minWidth = 0, minHeight = 0, - maxHeight = max(0, constraints.maxHeight - toolbarState.minHeight) + maxHeight = max(0, constraints.maxHeight - state.toolbarState.minHeight) ) - }else{ + } else { constraints.copy( minWidth = 0, minHeight = 0 @@ -179,12 +172,12 @@ private class AppbarMeasurePolicy( width.coerceIn(constraints.minWidth, constraints.maxWidth), height.coerceIn(constraints.minHeight, constraints.maxHeight) ) { - toolbarPlaceable?.place(x = 0, y = offsetY.value) + toolbarPlaceable?.place(x = 0, y = state.offsetY) placeables.forEach { placeable -> placeable.place( x = 0, - y = offsetY.value + (toolbarPlaceable?.height ?: 0) + y = state.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 a0f29c5..9bc7b26 100644 --- a/lib/src/main/java/me/onebone/toolbar/CollapsingToolbar.kt +++ b/lib/src/main/java/me/onebone/toolbar/CollapsingToolbar.kt @@ -25,6 +25,7 @@ package me.onebone.toolbar import androidx.annotation.FloatRange import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.gestures.FlingBehavior @@ -53,42 +54,43 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt +const val SPRING_BASED_DURATION = -1 + @Stable class CollapsingToolbarState( initial: Int = Int.MAX_VALUE -): ScrollableState { - /** - * [height] indicates current height of the toolbar. - */ +) : ScrollableState { + + /** [height] indicates current height of the toolbar. */ var height: Int by mutableStateOf(initial) private set /** - * [minHeight] indicates the minimum height of the collapsing toolbar. The toolbar - * may collapse its height to [minHeight] but not smaller. This size is determined by - * the smallest child. + * [minHeight] indicates the minimum height of the collapsing toolbar. The + * toolbar may collapse its height to [minHeight] but not smaller. This + * size is determined by the smallest child. */ var minHeight: Int get() = minHeightState internal set(value) { minHeightState = value - if(height < value) { + if (height < value) { height = value } } /** - * [maxHeight] indicates the maximum height of the collapsing toolbar. The toolbar - * may expand its height to [maxHeight] but not larger. This size is determined by - * the largest child. + * [maxHeight] indicates the maximum height of the collapsing toolbar. The + * toolbar may expand its height to [maxHeight] but not larger. This size + * is determined by the largest child. */ var maxHeight: Int get() = maxHeightState internal set(value) { maxHeightState = value - if(value < height) { + if (value < height) { height = value } } @@ -99,23 +101,23 @@ class CollapsingToolbarState( val progress: Float @FloatRange(from = 0.0, to = 1.0) get() = - if(minHeight == maxHeight) { + if (minHeight == maxHeight) { 0f - }else{ + } else { ((height - minHeight).toFloat() / (maxHeight - minHeight)).coerceIn(0f, 1f) } private val scrollableState = ScrollableState { value -> - val consume = if(value < 0) { + val consume = if (value < 0) { max(minHeight.toFloat() - height, value) - }else{ + } else { min(maxHeight.toFloat() - height, value) } val current = consume + deferredConsumption val currentInt = current.toInt() - if(current.absoluteValue > 0) { + if (current.absoluteValue > 0) { height += currentInt deferredConsumption = current - currentInt } @@ -125,9 +127,7 @@ class CollapsingToolbarState( private var deferredConsumption: Float = 0f - /** - * @return consumed scroll value is returned - */ + /** @return consumed scroll value is returned */ @Deprecated( message = "feedScroll() is deprecated, use dispatchRawDelta() instead.", replaceWith = ReplaceWith("dispatchRawDelta(value)") @@ -135,12 +135,15 @@ class CollapsingToolbarState( fun feedScroll(value: Float): Float = dispatchRawDelta(value) @ExperimentalToolbarApi - suspend fun expand(duration: Int = 200) { + suspend fun expand(duration: Int = SPRING_BASED_DURATION) { val anim = AnimationState(height.toFloat()) scroll { var prev = anim.value - anim.animateTo(maxHeight.toFloat(), tween(duration)) { + anim.animateTo( + maxHeight.toFloat(), + if (duration == SPRING_BASED_DURATION) spring() else tween(duration) + ) { scrollBy(value - prev) prev = value } @@ -148,21 +151,22 @@ class CollapsingToolbarState( } @ExperimentalToolbarApi - suspend fun collapse(duration: Int = 200) { + suspend fun collapse(duration: Int = SPRING_BASED_DURATION) { val anim = AnimationState(height.toFloat()) scroll { var prev = anim.value - anim.animateTo(minHeight.toFloat(), tween(duration)) { + anim.animateTo( + minHeight.toFloat(), + if (duration == SPRING_BASED_DURATION) spring() else tween(duration) + ) { scrollBy(value - prev) prev = value } } } - /** - * @return Remaining velocity after fling - */ + /** @return Remaining velocity after fling */ suspend fun fling(flingBehavior: FlingBehavior, velocity: Float): Float { var left = velocity scroll { @@ -216,7 +220,7 @@ fun CollapsingToolbar( private class CollapsingToolbarMeasurePolicy( private val collapsingToolbarState: CollapsingToolbarState -): MeasurePolicy { +) : MeasurePolicy { override fun MeasureScope.measure( measurables: List, constraints: Constraints @@ -239,7 +243,7 @@ private class CollapsingToolbarMeasurePolicy( val maxHeight = placeables.maxOfOrNull { it.height } ?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0 - val maxWidth = placeables.maxOfOrNull{ it.width } + val maxWidth = placeables.maxOfOrNull { it.width } ?.coerceIn(constraints.minWidth, constraints.maxWidth) ?: 0 collapsingToolbarState.also { @@ -253,11 +257,11 @@ private class CollapsingToolbarMeasurePolicy( placeables.forEachIndexed { i, placeable -> val strategy = placeStrategy[i] - if(strategy is CollapsingToolbarData) { + if (strategy is CollapsingToolbarData) { strategy.progressListener?.onProgressUpdate(progress) } - when(strategy) { + when (strategy) { is CollapsingToolbarRoadData -> { val collapsed = strategy.whenCollapsed val expanded = strategy.whenExpanded @@ -278,11 +282,13 @@ private class CollapsingToolbarMeasurePolicy( placeable.place(offset.x, offset.y) } + is CollapsingToolbarParallaxData -> placeable.placeRelative( x = 0, y = -((maxHeight - minHeight) * (1 - progress) * strategy.ratio).roundToInt() ) + else -> placeable.placeRelative(0, 0) } } @@ -300,7 +306,7 @@ interface CollapsingToolbarScope { fun Modifier.pin(): Modifier } -internal object CollapsingToolbarScopeInstance: CollapsingToolbarScope { +internal object CollapsingToolbarScopeInstance : CollapsingToolbarScope { override fun Modifier.progress(listener: ProgressListener): Modifier { return this.then(ProgressUpdateListenerModifier(listener)) } @@ -321,7 +327,7 @@ internal object CollapsingToolbarScopeInstance: CollapsingToolbarScope { internal class RoadModifier( private val whenCollapsed: Alignment, private val whenExpanded: Alignment -): ParentDataModifier { +) : ParentDataModifier { override fun Density.modifyParentData(parentData: Any?): Any { return CollapsingToolbarRoadData( this@RoadModifier.whenCollapsed, this@RoadModifier.whenExpanded, @@ -332,13 +338,16 @@ internal class RoadModifier( internal class ParallaxModifier( private val ratio: Float -): ParentDataModifier { +) : ParentDataModifier { override fun Density.modifyParentData(parentData: Any?): Any { - return CollapsingToolbarParallaxData(ratio, (parentData as? CollapsingToolbarData)?.progressListener) + return CollapsingToolbarParallaxData( + ratio, + (parentData as? CollapsingToolbarData)?.progressListener + ) } } -internal class PinModifier: ParentDataModifier { +internal class PinModifier : ParentDataModifier { override fun Density.modifyParentData(parentData: Any?): Any { return CollapsingToolbarPinData((parentData as? CollapsingToolbarData)?.progressListener) } @@ -346,7 +355,7 @@ internal class PinModifier: ParentDataModifier { internal class ProgressUpdateListenerModifier( private val listener: ProgressListener -): ParentDataModifier { +) : ParentDataModifier { override fun Density.modifyParentData(parentData: Any?): Any { return CollapsingToolbarProgressData(listener) } @@ -362,19 +371,19 @@ internal sealed class CollapsingToolbarData( internal class CollapsingToolbarProgressData( progressListener: ProgressListener? -): CollapsingToolbarData(progressListener) +) : CollapsingToolbarData(progressListener) internal class CollapsingToolbarRoadData( var whenCollapsed: Alignment, var whenExpanded: Alignment, progressListener: ProgressListener? = null -): CollapsingToolbarData(progressListener) +) : CollapsingToolbarData(progressListener) internal class CollapsingToolbarPinData( progressListener: ProgressListener? = null -): CollapsingToolbarData(progressListener) +) : CollapsingToolbarData(progressListener) internal class CollapsingToolbarParallaxData( var ratio: Float, progressListener: ProgressListener? = null -): CollapsingToolbarData(progressListener) +) : CollapsingToolbarData(progressListener) diff --git a/lib/src/main/java/me/onebone/toolbar/CollapsingToolbarScaffold.kt b/lib/src/main/java/me/onebone/toolbar/CollapsingToolbarScaffold.kt index 3135c15..924f9e0 100644 --- a/lib/src/main/java/me/onebone/toolbar/CollapsingToolbarScaffold.kt +++ b/lib/src/main/java/me/onebone/toolbar/CollapsingToolbarScaffold.kt @@ -22,6 +22,11 @@ package me.onebone.toolbar +import androidx.annotation.FloatRange +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.ScrollableDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @@ -39,6 +44,9 @@ import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize import kotlin.math.max +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope @Stable class CollapsingToolbarScaffoldState( @@ -49,20 +57,118 @@ class CollapsingToolbarScaffoldState( get() = offsetYState.value internal val offsetYState = mutableStateOf(initialOffsetY) + + internal var scrollStrategy: ScrollStrategy? = null + + val offsetProgress: Float + @FloatRange(from = 0.0, to = 1.0) + get() = scrollStrategy?.let { validScrollStrategy -> + when (validScrollStrategy) { + ScrollStrategy.EnterAlways, + ScrollStrategy.EnterAlwaysCollapsed -> { + 1f - (-offsetY.toFloat() / toolbarState.minHeight.toFloat()).coerceIn(0f, 1f) + } + + ScrollStrategy.ExitUntilCollapsed -> 1f + } + } ?: 0f + + val totalProgress: Float + @FloatRange(from = 0.0, to = 1.0) + get() { + val toolbarMaxHeight = toolbarState.maxHeight.toFloat() + + return ((offsetY + toolbarState.height) / toolbarMaxHeight).coerceIn(0f, 1f) + } + + @ExperimentalToolbarApi + suspend fun offsetExpand(duration: Int = SPRING_BASED_DURATION) { + scrollStrategy?.let { validScrollStrategy -> + val anim = AnimationState(offsetY.toFloat()) + + anim.animateTo( + when (validScrollStrategy) { + ScrollStrategy.EnterAlways, + ScrollStrategy.EnterAlwaysCollapsed -> 0f + + ScrollStrategy.ExitUntilCollapsed -> return + }, + if (duration == SPRING_BASED_DURATION) { + spring() + } else { + tween(duration) + } + ) { + offsetYState.value = value.toInt() + } + } + } + + @ExperimentalToolbarApi + suspend fun offsetCollapse(duration: Int = SPRING_BASED_DURATION) { + scrollStrategy?.let { validScrollStrategy -> + val anim = AnimationState(offsetY.toFloat()) + + anim.animateTo( + when (validScrollStrategy) { + ScrollStrategy.EnterAlways, + ScrollStrategy.EnterAlwaysCollapsed -> -toolbarState.minHeight.toFloat() + + ScrollStrategy.ExitUntilCollapsed -> return + }, + if (duration == SPRING_BASED_DURATION) { + spring() + } else { + tween(duration) + } + ) { + offsetYState.value = value.toInt() + } + } + } + + @ExperimentalToolbarApi + suspend fun expand(duration: Int = SPRING_BASED_DURATION) { + coroutineScope { + awaitAll( + async { offsetExpand(duration) }, + async { toolbarState.expand(duration) } + ) + } + } + + @ExperimentalToolbarApi + suspend fun collapse(duration: Int = SPRING_BASED_DURATION) { + coroutineScope { + awaitAll( + async { offsetCollapse(duration) }, + async { toolbarState.collapse(duration) } + ) + } + } } -private class CollapsingToolbarScaffoldStateSaver: Saver> { +private class CollapsingToolbarScaffoldStateSaver : + Saver> { + override fun restore(value: List): CollapsingToolbarScaffoldState = CollapsingToolbarScaffoldState( CollapsingToolbarState(value[0] as Int), value[1] as Int - ) + ).also { + val restoredScrollStrategy = value[2] as String + + if (restoredScrollStrategy.isNotEmpty()) { + it.scrollStrategy = ScrollStrategy.valueOf(restoredScrollStrategy) + } + } override fun SaverScope.save(value: CollapsingToolbarScaffoldState): List = listOf( value.toolbarState.height, - value.offsetY - ) + value.offsetY, + value.scrollStrategy?.name.orEmpty() + ) } @Composable @@ -84,6 +190,7 @@ fun CollapsingToolbarScaffold( modifier: Modifier, state: CollapsingToolbarScaffoldState, scrollStrategy: ScrollStrategy, + snapConfig: SnapConfig? = null, enabled: Boolean = true, toolbarModifier: Modifier = Modifier, toolbar: @Composable CollapsingToolbarScope.() -> Unit, @@ -93,9 +200,8 @@ fun CollapsingToolbarScaffold( val layoutDirection = LocalLayoutDirection.current val nestedScrollConnection = remember(scrollStrategy, state) { - scrollStrategy.create(state.offsetYState, state.toolbarState, flingBehavior) + scrollStrategy.create(state, flingBehavior, snapConfig) } - val toolbarState = state.toolbarState Layout( @@ -179,7 +285,7 @@ fun CollapsingToolbarScaffold( } } -internal object CollapsingToolbarScaffoldScopeInstance: CollapsingToolbarScaffoldScope { +internal object CollapsingToolbarScaffoldScopeInstance : CollapsingToolbarScaffoldScope { @ExperimentalToolbarApi override fun Modifier.align(alignment: Alignment): Modifier = this.then(ScaffoldChildAlignmentModifier(alignment)) diff --git a/lib/src/main/java/me/onebone/toolbar/ScrollStrategy.kt b/lib/src/main/java/me/onebone/toolbar/ScrollStrategy.kt index b18c09b..47d94ed 100644 --- a/lib/src/main/java/me/onebone/toolbar/ScrollStrategy.kt +++ b/lib/src/main/java/me/onebone/toolbar/ScrollStrategy.kt @@ -32,33 +32,49 @@ import androidx.compose.ui.unit.Velocity enum class ScrollStrategy { EnterAlways { override fun create( - offsetY: MutableState, - toolbarState: CollapsingToolbarState, - flingBehavior: FlingBehavior - ): NestedScrollConnection = - EnterAlwaysNestedScrollConnection(offsetY, toolbarState, flingBehavior) + state: CollapsingToolbarScaffoldState, + flingBehavior: FlingBehavior, + snapConfig: SnapConfig? + ): NestedScrollConnection = EnterAlwaysNestedScrollConnection( + state, + flingBehavior, + snapConfig + ).also { + state.scrollStrategy = this + } }, EnterAlwaysCollapsed { override fun create( - offsetY: MutableState, - toolbarState: CollapsingToolbarState, - flingBehavior: FlingBehavior - ): NestedScrollConnection = - EnterAlwaysCollapsedNestedScrollConnection(offsetY, toolbarState, flingBehavior) + state: CollapsingToolbarScaffoldState, + flingBehavior: FlingBehavior, + snapConfig: SnapConfig? + ): NestedScrollConnection = EnterAlwaysCollapsedNestedScrollConnection( + state, + flingBehavior, + snapConfig + ).also { + state.scrollStrategy = this + } }, ExitUntilCollapsed { override fun create( - offsetY: MutableState, - toolbarState: CollapsingToolbarState, - flingBehavior: FlingBehavior + state: CollapsingToolbarScaffoldState, + flingBehavior: FlingBehavior, + snapConfig: SnapConfig? ): NestedScrollConnection = - ExitUntilCollapsedNestedScrollConnection(toolbarState, flingBehavior) + ExitUntilCollapsedNestedScrollConnection( + state, + flingBehavior, + snapConfig + ).also { + state.scrollStrategy = this + } }; internal abstract fun create( - offsetY: MutableState, - toolbarState: CollapsingToolbarState, - flingBehavior: FlingBehavior + state: CollapsingToolbarScaffoldState, + flingBehavior: FlingBehavior, + snapConfig: SnapConfig? ): NestedScrollConnection } @@ -78,32 +94,32 @@ private class ScrollDelegate( } internal class EnterAlwaysNestedScrollConnection( - private val offsetY: MutableState, - private val toolbarState: CollapsingToolbarState, - private val flingBehavior: FlingBehavior -): NestedScrollConnection { - private val scrollDelegate = ScrollDelegate(offsetY) - //private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl()) + private val state: CollapsingToolbarScaffoldState, + private val flingBehavior: FlingBehavior, + private val snapConfig: SnapConfig? +) : NestedScrollConnection { + + private val scrollDelegate = ScrollDelegate(state.offsetYState) override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val dy = available.y - val toolbar = toolbarState.height.toFloat() - val offset = offsetY.value.toFloat() + val toolbar = state.toolbarState.height.toFloat() + val offset = state.offsetY.toFloat() // -toolbarHeight <= offsetY + dy <= 0 - val consume = if(dy < 0) { - val toolbarConsumption = toolbarState.dispatchRawDelta(dy) + val consume = if (dy < 0) { + val toolbarConsumption = state.toolbarState.dispatchRawDelta(dy) val remaining = dy - toolbarConsumption val offsetConsumption = remaining.coerceAtLeast(-toolbar - offset) scrollDelegate.doScroll(offsetConsumption) toolbarConsumption + offsetConsumption - }else{ + } else { val offsetConsumption = dy.coerceAtMost(-offset) scrollDelegate.doScroll(offsetConsumption) - val toolbarConsumption = toolbarState.dispatchRawDelta(dy - offsetConsumption) + val toolbarConsumption = state.toolbarState.dispatchRawDelta(dy - offsetConsumption) offsetConsumption + toolbarConsumption } @@ -112,9 +128,9 @@ internal class EnterAlwaysNestedScrollConnection( } override suspend fun onPreFling(available: Velocity): Velocity { - val left = if(available.y > 0) { - toolbarState.fling(flingBehavior, available.y) - }else{ + val left = if (available.y > 0) { + state.toolbarState.fling(flingBehavior, available.y) + } else { // If velocity < 0, the main content should have a remaining scroll space // so the scroll resumes to the onPreScroll(..., Fling) phase. Hence we do // not need to process it at onPostFling() manually. @@ -123,27 +139,37 @@ internal class EnterAlwaysNestedScrollConnection( return Velocity(x = 0f, y = available.y - left) } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + snapConfig?.let { + handleToolbarScaffoldSnap(state, it) + } + + return super.onPostFling(consumed, available) + } } internal class EnterAlwaysCollapsedNestedScrollConnection( - private val offsetY: MutableState, - private val toolbarState: CollapsingToolbarState, - private val flingBehavior: FlingBehavior -): NestedScrollConnection { - private val scrollDelegate = ScrollDelegate(offsetY) - //private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl()) + private val state: CollapsingToolbarScaffoldState, + private val flingBehavior: FlingBehavior, + private val snapConfig: SnapConfig? +) : NestedScrollConnection { + + private val scrollDelegate = ScrollDelegate(state.offsetYState) override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val dy = available.y - val consumed = if(dy > 0) { // expanding: offset -> body -> toolbar - val offsetConsumption = dy.coerceAtMost(-offsetY.value.toFloat()) + val consumed = if (dy > 0) { // expanding: offset -> body -> toolbar + val offsetConsumption = dy.coerceAtMost(-state.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) + } else { // collapsing: toolbar -> offset -> body + val toolbarConsumption = state.toolbarState.dispatchRawDelta(dy) + val offsetConsumption = (dy - toolbarConsumption).coerceAtLeast( + -state.toolbarState.height.toFloat() - state.offsetY + ) scrollDelegate.doScroll(offsetConsumption) @@ -160,9 +186,9 @@ internal class EnterAlwaysCollapsedNestedScrollConnection( ): Offset { val dy = available.y - return if(dy > 0) { - Offset(0f, toolbarState.dispatchRawDelta(dy)) - }else{ + return if (dy > 0) { + Offset(0f, state.toolbarState.dispatchRawDelta(dy)) + } else { Offset(0f, 0f) } } @@ -170,12 +196,22 @@ internal class EnterAlwaysCollapsedNestedScrollConnection( override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { val dy = available.y - val left = if(dy > 0) { + val left = if (dy > 0) { // onPostFling() has positive available scroll value only called if the main scroll // has leftover scroll, i.e. the scroll of the main content has done. So we just process // fling if the available value is positive. - toolbarState.fling(flingBehavior, dy) - }else{ + state.toolbarState.fling(flingBehavior, dy) + } else { + snapConfig?.let { + val isToolbarScaffoldOffsetSnapping = state.offsetY != 0 + + if (isToolbarScaffoldOffsetSnapping) { + handleToolbarScaffoldOffsetSnap(state, it) + } else { + handleToolbarSnap(state.toolbarState, it) + } + } + dy } @@ -184,15 +220,16 @@ internal class EnterAlwaysCollapsedNestedScrollConnection( } internal class ExitUntilCollapsedNestedScrollConnection( - private val toolbarState: CollapsingToolbarState, - private val flingBehavior: FlingBehavior -): NestedScrollConnection { + private val state: CollapsingToolbarScaffoldState, + private val flingBehavior: FlingBehavior, + private val snapConfig: SnapConfig? +) : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { val dy = available.y - val consume = if(dy < 0) { // collapsing: toolbar -> body - toolbarState.dispatchRawDelta(dy) - }else{ + val consume = if (dy < 0) { // collapsing: toolbar -> body + state.toolbarState.dispatchRawDelta(dy) + } else { 0f } @@ -206,9 +243,9 @@ internal class ExitUntilCollapsedNestedScrollConnection( ): Offset { val dy = available.y - val consume = if(dy > 0) { // expanding: body -> toolbar - toolbarState.dispatchRawDelta(dy) - }else{ + val consume = if (dy > 0) { // expanding: body -> toolbar + state.toolbarState.dispatchRawDelta(dy) + } else { 0f } @@ -216,9 +253,9 @@ internal class ExitUntilCollapsedNestedScrollConnection( } override suspend fun onPreFling(available: Velocity): Velocity { - val left = if(available.y < 0) { - toolbarState.fling(flingBehavior, available.y) - }else{ + val left = if (available.y < 0) { + state.toolbarState.fling(flingBehavior, available.y) + } else { available.y } @@ -228,12 +265,54 @@ internal class ExitUntilCollapsedNestedScrollConnection( override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { val velocity = available.y - val left = if(velocity > 0) { - toolbarState.fling(flingBehavior, velocity) - }else{ + val left = if (velocity > 0) { + state.toolbarState.fling(flingBehavior, velocity) + } else { + snapConfig?.let { + if (state.offsetY <= state.toolbarState.maxHeight) { + handleToolbarSnap(state.toolbarState, it) + } + } + velocity } return Velocity(x = 0f, y = available.y - left) } } + +@OptIn(ExperimentalToolbarApi::class) +private suspend fun handleToolbarScaffoldSnap( + state: CollapsingToolbarScaffoldState, + snapConfig: SnapConfig +) { + if (state.totalProgress <= snapConfig.collapseThreshold) { + state.collapse() + } else { + state.expand() + } +} + +@OptIn(ExperimentalToolbarApi::class) +private suspend fun handleToolbarScaffoldOffsetSnap( + state: CollapsingToolbarScaffoldState, + snapConfig: SnapConfig +) { + if (state.offsetProgress <= snapConfig.collapseThreshold) { + state.offsetCollapse() + } else { + state.offsetExpand() + } +} + +@OptIn(ExperimentalToolbarApi::class) +private suspend fun handleToolbarSnap( + toolbarState: CollapsingToolbarState, + snapConfig: SnapConfig +) { + if (toolbarState.progress <= snapConfig.collapseThreshold) { + toolbarState.collapse() + } else { + toolbarState.expand() + } +} 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..68d8163 --- /dev/null +++ b/lib/src/main/java/me/onebone/toolbar/SnapConfig.kt @@ -0,0 +1,9 @@ +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 collapseThreshold: Float = 0.5F +) diff --git a/lib/src/main/java/me/onebone/toolbar/ToolbarWithFabScaffold.kt b/lib/src/main/java/me/onebone/toolbar/ToolbarWithFabScaffold.kt index 9d3735b..a47f8da 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