From 3c962e2e74ecfa670729865b46ec6c50251d91c9 Mon Sep 17 00:00:00 2001 From: DatLag Date: Sat, 29 Jul 2023 15:05:47 +0200 Subject: [PATCH 1/2] initial multiplatform support --- app/build.gradle | 12 +- build.gradle => build.gradle.kts | 21 +- gradle/publish.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- lib/build.gradle | 61 --- lib/build.gradle.kts | 49 +++ lib/proguard-rules.pro | 2 +- .../{main => androidMain}/AndroidManifest.xml | 0 .../onebone/toolbar/CollapsingToolbarTest.kt | 105 ----- .../toolbar/ExampleInstrumentedTest.kt | 24 -- .../java/me/onebone/toolbar/Annotations.kt | 36 -- .../me/onebone/toolbar/AppBarContainer.kt | 192 --------- .../me/onebone/toolbar/CollapsingToolbar.kt | 386 ------------------ .../toolbar/CollapsingToolbarScaffold.kt | 200 --------- .../java/me/onebone/toolbar/FabPlacement.kt | 32 -- .../java/me/onebone/toolbar/FabPosition.kt | 6 - .../java/me/onebone/toolbar/ScrollStrategy.kt | 239 ----------- .../onebone/toolbar/ToolbarWithFabScaffold.kt | 104 ----- .../me/onebone/toolbar/ExampleUnitTest.kt | 17 - settings.gradle => settings.gradle.kts | 4 +- 20 files changed, 66 insertions(+), 1428 deletions(-) rename build.gradle => build.gradle.kts (56%) delete mode 100644 lib/build.gradle create mode 100644 lib/build.gradle.kts rename lib/src/{main => androidMain}/AndroidManifest.xml (100%) delete mode 100644 lib/src/androidTest/java/me/onebone/toolbar/CollapsingToolbarTest.kt delete mode 100644 lib/src/androidTest/java/me/onebone/toolbar/ExampleInstrumentedTest.kt delete mode 100644 lib/src/main/java/me/onebone/toolbar/Annotations.kt delete mode 100644 lib/src/main/java/me/onebone/toolbar/AppBarContainer.kt delete mode 100644 lib/src/main/java/me/onebone/toolbar/CollapsingToolbar.kt delete mode 100644 lib/src/main/java/me/onebone/toolbar/CollapsingToolbarScaffold.kt delete mode 100644 lib/src/main/java/me/onebone/toolbar/FabPlacement.kt delete mode 100644 lib/src/main/java/me/onebone/toolbar/FabPosition.kt delete mode 100644 lib/src/main/java/me/onebone/toolbar/ScrollStrategy.kt delete mode 100644 lib/src/main/java/me/onebone/toolbar/ToolbarWithFabScaffold.kt delete mode 100644 lib/src/test/java/me/onebone/toolbar/ExampleUnitTest.kt rename settings.gradle => settings.gradle.kts (55%) diff --git a/app/build.gradle b/app/build.gradle index 730b1b9..0fad91b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -4,8 +4,10 @@ plugins { } android { - compileSdkVersion 33 - buildToolsVersion "30.0.3" + compileSdk = 33 + buildToolsVersion = "30.0.3" + + namespace = "me.onebone.toolbar" defaultConfig { applicationId "me.onebone.toolbar" @@ -30,15 +32,9 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } - kotlinOptions { - jvmTarget = '1.8' - } buildFeatures { compose true } - composeOptions { - kotlinCompilerExtensionVersion compose_compiler_version - } } dependencies { diff --git a/build.gradle b/build.gradle.kts similarity index 56% rename from build.gradle rename to build.gradle.kts index b33f02f..38a3158 100644 --- a/build.gradle +++ b/build.gradle.kts @@ -1,19 +1,18 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. -buildscript { - ext { - library_version = '2.3.5' - - kotlin_version = '1.7.20' - compose_compiler_version = '1.3.2' - } +plugins { + kotlin("multiplatform") version "1.9.0" apply false + kotlin("android") version "1.9.0" apply false + id("org.jetbrains.compose") version "1.4.3" apply false +} +buildscript { repositories { google() mavenCentral() + gradlePluginPortal() } dependencies { - classpath 'com.android.tools.build:gradle:7.3.1' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath("com.android.tools.build:gradle:8.0.2") // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -26,7 +25,3 @@ allprojects { mavenCentral() } } - -task clean(type: Delete) { - delete rootProject.buildDir -} diff --git a/gradle/publish.gradle b/gradle/publish.gradle index e16a310..9d22d98 100644 --- a/gradle/publish.gradle +++ b/gradle/publish.gradle @@ -26,7 +26,7 @@ afterEvaluate { release(MavenPublication) { groupId = 'me.onebone' artifactId = 'toolbar-compose' - version = rootProject.ext.library_version + version = "2.3.5" from components.release diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 404484b..8ff7746 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip diff --git a/lib/build.gradle b/lib/build.gradle deleted file mode 100644 index c5a3a24..0000000 --- a/lib/build.gradle +++ /dev/null @@ -1,61 +0,0 @@ -plugins { - id 'com.android.library' - id 'kotlin-android' -} - -android { - namespace 'me.onebone.toolbar' - - compileSdkVersion 33 - buildToolsVersion "30.0.3" - - defaultConfig { - minSdkVersion 21 - targetSdkVersion 33 - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles "consumer-rules.pro" - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = '1.8' - } - - buildFeatures { - compose = true - } - - composeOptions { - kotlinCompilerExtensionVersion "$compose_compiler_version" - } - - packagingOptions { - exclude "META-INF/AL2.0" - exclude "META-INF/LGPL2.1" - } -} - -dependencies { - testImplementation 'junit:junit:4.13.2' - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - - implementation platform('androidx.compose:compose-bom:2023.01.00') - - implementation "androidx.compose.ui:ui" - implementation "androidx.compose.foundation:foundation" - implementation "androidx.compose.ui:ui-tooling" - androidTestImplementation "androidx.compose.ui:ui-test-junit4" -} - -apply from: rootProject.file('gradle/publish.gradle') diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts new file mode 100644 index 0000000..bfa6971 --- /dev/null +++ b/lib/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + kotlin("multiplatform") + id("com.android.library") + id("org.jetbrains.compose") +} + +kotlin { + androidTarget("android") + jvm() + + sourceSets { + val commonMain by getting { + dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + } + } + + val androidMain by getting { + dependsOn(commonMain) + } + + val jvmMain by getting { + dependsOn(commonMain) + } + } +} + +android { + namespace = "me.onebone.toolbar" + + compileSdk = 33 + + defaultConfig { + minSdkVersion(21) + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + packagingOptions { + exclude("META-INF/AL2.0") + exclude("META-INF/LGPL2.1") + } +} + diff --git a/lib/proguard-rules.pro b/lib/proguard-rules.pro index f1b4245..2f9dc5a 100644 --- a/lib/proguard-rules.pro +++ b/lib/proguard-rules.pro @@ -1,6 +1,6 @@ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. +# proguardFiles setting in build.gradle.kts. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html diff --git a/lib/src/main/AndroidManifest.xml b/lib/src/androidMain/AndroidManifest.xml similarity index 100% rename from lib/src/main/AndroidManifest.xml rename to lib/src/androidMain/AndroidManifest.xml diff --git a/lib/src/androidTest/java/me/onebone/toolbar/CollapsingToolbarTest.kt b/lib/src/androidTest/java/me/onebone/toolbar/CollapsingToolbarTest.kt deleted file mode 100644 index 52e8f6c..0000000 --- a/lib/src/androidTest/java/me/onebone/toolbar/CollapsingToolbarTest.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright (c) 2021 onebone - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, - * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR - * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE - * OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package me.onebone.toolbar - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.Text -import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTag -import androidx.compose.ui.test.assertHeightIsEqualTo -import androidx.compose.ui.test.hasTestTag -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.performTouchInput -import androidx.compose.ui.test.swipeUp -import androidx.compose.ui.unit.dp -import androidx.test.ext.junit.runners.AndroidJUnit4 -import kotlin.math.abs -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class CollapsingToolbarTest { - @get:Rule - val rule = createComposeRule() - - @Test - fun testCollapse() { - val state = CollapsingToolbarScaffoldState( - CollapsingToolbarState() - ) - - rule.setContent { - CollapsingToolbarScaffold( - modifier = Modifier - .fillMaxSize(), - state = state, - scrollStrategy = ScrollStrategy.ExitUntilCollapsed, - toolbarModifier = Modifier - .semantics { - testTag = "toolbar" - }, - toolbar = { - Box(modifier = Modifier - .fillMaxWidth() - .height(300.dp)) - Box(modifier = Modifier - .fillMaxWidth() - .height(50.dp)) - } - ) { - LazyColumn(modifier = Modifier - .fillMaxSize() - .semantics { - testTag = "contentList" - } - ) { - items(List(100) { "Hello $it" }) { - Text(text = it) - } - } - } - } - - assert(state.toolbarState.progress == 1f) - - rule.onNode(hasTestTag("toolbar")) - .assertHeightIsEqualTo(300.dp) - - rule.onNode(hasTestTag("contentList")) - .performTouchInput { - swipeUp() - } - - rule.onNode(hasTestTag("toolbar")) - .assertHeightIsEqualTo(50.dp) - - assert(abs(state.toolbarState.progress) < 0.01f) - } -} diff --git a/lib/src/androidTest/java/me/onebone/toolbar/ExampleInstrumentedTest.kt b/lib/src/androidTest/java/me/onebone/toolbar/ExampleInstrumentedTest.kt deleted file mode 100644 index 03ac7f1..0000000 --- a/lib/src/androidTest/java/me/onebone/toolbar/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package me.onebone.toolbar - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 - -import org.junit.Test -import org.junit.runner.RunWith - -import org.junit.Assert.* - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("me.onebone.toolbar.test", appContext.packageName) - } -} diff --git a/lib/src/main/java/me/onebone/toolbar/Annotations.kt b/lib/src/main/java/me/onebone/toolbar/Annotations.kt deleted file mode 100644 index 2fe0745..0000000 --- a/lib/src/main/java/me/onebone/toolbar/Annotations.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (c) 2021 onebone - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, - * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR - * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE - * OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package me.onebone.toolbar - -@RequiresOptIn( - message = "This is an experimental API of compose-collapsing-toolbar. Any declarations with " + - "the annotation might be removed or changed in some way without any notice.", - level = RequiresOptIn.Level.WARNING -) -@Target( - AnnotationTarget.FUNCTION, - AnnotationTarget.PROPERTY, - AnnotationTarget.CLASS -) -@Retention(AnnotationRetention.BINARY) -annotation class ExperimentalToolbarApi diff --git a/lib/src/main/java/me/onebone/toolbar/AppBarContainer.kt b/lib/src/main/java/me/onebone/toolbar/AppBarContainer.kt deleted file mode 100644 index 9c2f30f..0000000 --- a/lib/src/main/java/me/onebone/toolbar/AppBarContainer.kt +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright (c) 2021 onebone - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, - * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR - * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE - * OR OTHER DEALINGS IN THE SOFTWARE. - */ - -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.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)", - "me.onebone.toolbar" - ) -) -@Composable -fun AppbarContainer( - modifier: Modifier = Modifier, - scrollStrategy: ScrollStrategy, - collapsingToolbarState: CollapsingToolbarState, - content: @Composable AppbarContainerScope.() -> Unit -) { - AppBarContainer( - modifier = modifier, - scrollStrategy = scrollStrategy, - collapsingToolbarState = collapsingToolbarState, - content = content - ) -} - -@Deprecated( - "AppBarContainer is replaced with CollapsingToolbarScaffold", - replaceWith = ReplaceWith( - "CollapsingToolbarScaffold", - "me.onebone.toolbar" - ) -) -@Composable -fun AppBarContainer( - modifier: Modifier = Modifier, - scrollStrategy: ScrollStrategy, - /** The state of a connected collapsing toolbar */ - collapsingToolbarState: CollapsingToolbarState, - 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) - } - - Layout( - content = { scope.content() }, - measurePolicy = measurePolicy, - modifier = modifier - ) -} - -interface AppbarContainerScope { - fun Modifier.appBarBody(): Modifier -} - -internal class AppbarContainerScopeImpl( - private val nestedScrollConnection: NestedScrollConnection -): AppbarContainerScope { - override fun Modifier.appBarBody(): Modifier { - return this - .then(AppBarBodyMarkerModifier) - .nestedScroll(nestedScrollConnection) - } -} - -private object AppBarBodyMarkerModifier: ParentDataModifier { - override fun Density.modifyParentData(parentData: Any?): Any { - return AppBarBodyMarker - } -} - -private object AppBarBodyMarker - -private class AppbarMeasurePolicy( - private val scrollStrategy: ScrollStrategy, - private val toolbarState: CollapsingToolbarState, - private val offsetY: State -): MeasurePolicy { - override fun MeasureScope.measure( - measurables: List, - constraints: Constraints - ): MeasureResult { - var width = 0 - var height = 0 - - var toolbarPlaceable: Placeable? = null - - val nonToolbars = measurables.filter { - val data = it.parentData - 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 - )) - width = max(width, placeable.width) - height = max(height, placeable.height) - - toolbarPlaceable = placeable - - false - }else{ - true - } - } - - val placeables = nonToolbars.map { measurable -> - val childConstraints = if(scrollStrategy == ScrollStrategy.ExitUntilCollapsed) { - constraints.copy( - minWidth = 0, - minHeight = 0, - maxHeight = max(0, constraints.maxHeight - toolbarState.minHeight) - ) - }else{ - constraints.copy( - minWidth = 0, - minHeight = 0 - ) - } - - val placeable = measurable.measure(childConstraints) - - width = max(width, placeable.width) - height = max(height, placeable.height) - - placeable - } - - height += (toolbarPlaceable?.height ?: 0) - - return layout( - width.coerceIn(constraints.minWidth, constraints.maxWidth), - height.coerceIn(constraints.minHeight, constraints.maxHeight) - ) { - toolbarPlaceable?.place(x = 0, y = offsetY.value) - - placeables.forEach { placeable -> - placeable.place( - x = 0, - y = offsetY.value + (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 deleted file mode 100644 index d11ddf3..0000000 --- a/lib/src/main/java/me/onebone/toolbar/CollapsingToolbar.kt +++ /dev/null @@ -1,386 +0,0 @@ -/* - * Copyright (c) 2021 onebone - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, - * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR - * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE - * OR OTHER DEALINGS IN THE SOFTWARE. - */ - -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.tween -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.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.unit.Constraints -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.IntSize -import kotlin.math.absoluteValue -import kotlin.math.max -import kotlin.math.min -import kotlin.math.roundToInt - -@Stable -class CollapsingToolbarState( - initial: Int = Int.MAX_VALUE -): 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. - */ - var minHeight: Int - get() = minHeightState - internal set(value) { - minHeightState = 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. - */ - var maxHeight: Int - get() = maxHeightState - internal set(value) { - maxHeightState = value - - if(value < height) { - height = value - } - } - - private var maxHeightState by mutableStateOf(Int.MAX_VALUE) - private var minHeightState by mutableStateOf(0) - - val progress: Float - @FloatRange(from = 0.0, to = 1.0) - get() = - if(minHeight == maxHeight) { - 0f - }else{ - ((height - minHeight).toFloat() / (maxHeight - minHeight)).coerceIn(0f, 1f) - } - - private val scrollableState = ScrollableState { value -> - val consume = if(value < 0) { - max(minHeight.toFloat() - height, value) - }else{ - min(maxHeight.toFloat() - height, value) - } - - val current = consume + deferredConsumption - val currentInt = current.toInt() - - if(current.absoluteValue > 0) { - height += currentInt - deferredConsumption = current - currentInt - } - - consume - } - - private var deferredConsumption: Float = 0f - - /** - * @return consumed scroll value is returned - */ - @Deprecated( - message = "feedScroll() is deprecated, use dispatchRawDelta() instead.", - replaceWith = ReplaceWith("dispatchRawDelta(value)") - ) - fun feedScroll(value: Float): Float = dispatchRawDelta(value) - - @ExperimentalToolbarApi - suspend fun expand(duration: Int = 200) { - val anim = AnimationState(height.toFloat()) - - scroll { - var prev = anim.value - anim.animateTo(maxHeight.toFloat(), tween(duration)) { - scrollBy(value - prev) - prev = value - } - } - } - - @ExperimentalToolbarApi - suspend fun collapse(duration: Int = 200) { - val anim = AnimationState(height.toFloat()) - - scroll { - var prev = anim.value - anim.animateTo(minHeight.toFloat(), tween(duration)) { - scrollBy(value - prev) - prev = 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 - } - - override val isScrollInProgress: Boolean - get() = scrollableState.isScrollInProgress - - override fun dispatchRawDelta(delta: Float): Float = scrollableState.dispatchRawDelta(delta) - - override suspend fun scroll( - scrollPriority: MutatePriority, - block: suspend ScrollScope.() -> Unit - ) = scrollableState.scroll(scrollPriority, block) -} - -@Composable -fun rememberCollapsingToolbarState( - initial: Int = Int.MAX_VALUE -): CollapsingToolbarState { - return remember { - CollapsingToolbarState( - initial = initial - ) - } -} - -@Composable -fun CollapsingToolbar( - modifier: Modifier = Modifier, - clipToBounds: Boolean = true, - collapsingToolbarState: CollapsingToolbarState, - content: @Composable CollapsingToolbarScope.() -> Unit -) { - val measurePolicy = remember(collapsingToolbarState) { - CollapsingToolbarMeasurePolicy(collapsingToolbarState) - } - - Layout( - content = { CollapsingToolbarScopeInstance.content() }, - measurePolicy = measurePolicy, - modifier = modifier.then( - if (clipToBounds) { - Modifier.clipToBounds() - } else { - Modifier - } - ) - ) -} - -private class CollapsingToolbarMeasurePolicy( - private val collapsingToolbarState: CollapsingToolbarState -): MeasurePolicy { - override fun MeasureScope.measure( - measurables: List, - constraints: Constraints - ): MeasureResult { - val placeables = measurables.map { - it.measure( - constraints.copy( - minWidth = 0, - minHeight = 0, - maxHeight = Constraints.Infinity - ) - ) - } - - val placeStrategy = measurables.map { it.parentData } - - val minHeight = placeables.minOfOrNull { it.height } - ?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0 - - val maxHeight = placeables.maxOfOrNull { it.height } - ?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0 - - val maxWidth = placeables.maxOfOrNull{ it.width } - ?.coerceIn(constraints.minWidth, constraints.maxWidth) ?: 0 - - collapsingToolbarState.also { - it.minHeight = minHeight - it.maxHeight = maxHeight - } - - val height = collapsingToolbarState.height - return layout(maxWidth, height) { - val progress = collapsingToolbarState.progress - - placeables.forEachIndexed { i, placeable -> - val strategy = placeStrategy[i] - if(strategy is CollapsingToolbarData) { - strategy.progressListener?.onProgressUpdate(progress) - } - - when(strategy) { - is CollapsingToolbarRoadData -> { - val collapsed = strategy.whenCollapsed - val expanded = strategy.whenExpanded - - val collapsedOffset = collapsed.align( - size = IntSize(placeable.width, placeable.height), - space = IntSize(maxWidth, height), - layoutDirection = layoutDirection - ) - - val expandedOffset = expanded.align( - size = IntSize(placeable.width, placeable.height), - space = IntSize(maxWidth, height), - layoutDirection = layoutDirection - ) - - val offset = collapsedOffset + (expandedOffset - collapsedOffset) * progress - - 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) - } - } - } - } -} - -interface CollapsingToolbarScope { - fun Modifier.progress(listener: ProgressListener): Modifier - - fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier - - fun Modifier.parallax(ratio: Float = 0.2f): Modifier - - fun Modifier.pin(): Modifier -} - -internal object CollapsingToolbarScopeInstance: CollapsingToolbarScope { - override fun Modifier.progress(listener: ProgressListener): Modifier { - return this.then(ProgressUpdateListenerModifier(listener)) - } - - override fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier { - return this.then(RoadModifier(whenCollapsed, whenExpanded)) - } - - override fun Modifier.parallax(ratio: Float): Modifier { - return this.then(ParallaxModifier(ratio)) - } - - override fun Modifier.pin(): Modifier { - return this.then(PinModifier()) - } -} - -internal class RoadModifier( - private val whenCollapsed: Alignment, - private val whenExpanded: Alignment -): ParentDataModifier { - override fun Density.modifyParentData(parentData: Any?): Any { - return CollapsingToolbarRoadData( - this@RoadModifier.whenCollapsed, this@RoadModifier.whenExpanded, - (parentData as? CollapsingToolbarData)?.progressListener - ) - } -} - -internal class ParallaxModifier( - private val ratio: Float -): ParentDataModifier { - override fun Density.modifyParentData(parentData: Any?): Any { - return CollapsingToolbarParallaxData(ratio, (parentData as? CollapsingToolbarData)?.progressListener) - } -} - -internal class PinModifier: ParentDataModifier { - override fun Density.modifyParentData(parentData: Any?): Any { - return CollapsingToolbarPinData((parentData as? CollapsingToolbarData)?.progressListener) - } -} - -internal class ProgressUpdateListenerModifier( - private val listener: ProgressListener -): ParentDataModifier { - override fun Density.modifyParentData(parentData: Any?): Any { - return CollapsingToolbarProgressData(listener) - } -} - -fun interface ProgressListener { - fun onProgressUpdate(value: Float) -} - -internal sealed class CollapsingToolbarData( - var progressListener: ProgressListener? -) - -internal class CollapsingToolbarProgressData( - progressListener: ProgressListener? -): CollapsingToolbarData(progressListener) - -internal class CollapsingToolbarRoadData( - var whenCollapsed: Alignment, - var whenExpanded: Alignment, - progressListener: ProgressListener? = null -): CollapsingToolbarData(progressListener) - -internal class CollapsingToolbarPinData( - progressListener: ProgressListener? = null -): CollapsingToolbarData(progressListener) - -internal class CollapsingToolbarParallaxData( - var ratio: Float, - progressListener: ProgressListener? = null -): CollapsingToolbarData(progressListener) diff --git a/lib/src/main/java/me/onebone/toolbar/CollapsingToolbarScaffold.kt b/lib/src/main/java/me/onebone/toolbar/CollapsingToolbarScaffold.kt deleted file mode 100644 index afdf0cd..0000000 --- a/lib/src/main/java/me/onebone/toolbar/CollapsingToolbarScaffold.kt +++ /dev/null @@ -1,200 +0,0 @@ -/* - * Copyright (c) 2021 onebone - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, - * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR - * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE - * OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package me.onebone.toolbar - -import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.SaverScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.ParentDataModifier -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.IntSize -import kotlin.math.max - -@Stable -class CollapsingToolbarScaffoldState( - val toolbarState: CollapsingToolbarState, - initialOffsetY: Int = 0 -) { - val offsetY: Int - get() = offsetYState.value - - internal val offsetYState = mutableStateOf(initialOffsetY) -} - -private class CollapsingToolbarScaffoldStateSaver: Saver> { - override fun restore(value: List): CollapsingToolbarScaffoldState = - CollapsingToolbarScaffoldState( - CollapsingToolbarState(value[0] as Int), - value[1] as Int - ) - - override fun SaverScope.save(value: CollapsingToolbarScaffoldState): List = - listOf( - value.toolbarState.height, - value.offsetY - ) -} - -@Composable -fun rememberCollapsingToolbarScaffoldState( - toolbarState: CollapsingToolbarState = rememberCollapsingToolbarState() -): CollapsingToolbarScaffoldState { - return rememberSaveable(toolbarState, saver = CollapsingToolbarScaffoldStateSaver()) { - CollapsingToolbarScaffoldState(toolbarState) - } -} - -interface CollapsingToolbarScaffoldScope { - @ExperimentalToolbarApi - fun Modifier.align(alignment: Alignment): Modifier -} - -@Composable -fun CollapsingToolbarScaffold( - modifier: Modifier, - state: CollapsingToolbarScaffoldState, - scrollStrategy: ScrollStrategy, - enabled: Boolean = true, - toolbarModifier: Modifier = Modifier, - toolbarClipToBounds: Boolean = true, - toolbar: @Composable CollapsingToolbarScope.() -> Unit, - body: @Composable CollapsingToolbarScaffoldScope.() -> Unit -) { - val flingBehavior = ScrollableDefaults.flingBehavior() - val layoutDirection = LocalLayoutDirection.current - - val nestedScrollConnection = remember(scrollStrategy, state) { - scrollStrategy.create(state.offsetYState, state.toolbarState, flingBehavior) - } - - val toolbarState = state.toolbarState - - Layout( - content = { - CollapsingToolbar( - modifier = toolbarModifier, - clipToBounds = toolbarClipToBounds, - collapsingToolbarState = toolbarState, - ) { - toolbar() - } - - CollapsingToolbarScaffoldScopeInstance.body() - }, - modifier = modifier - .then( - if (enabled) { - Modifier.nestedScroll(nestedScrollConnection) - } else { - Modifier - } - ) - ) { measurables, constraints -> - check(measurables.size >= 2) { - "the number of children should be at least 2: toolbar, (at least one) body" - } - - val toolbarConstraints = constraints.copy( - minWidth = 0, - minHeight = 0 - ) - val bodyConstraints = constraints.copy( - minWidth = 0, - minHeight = 0, - maxHeight = when (scrollStrategy) { - ScrollStrategy.ExitUntilCollapsed -> - (constraints.maxHeight - toolbarState.minHeight).coerceAtLeast(0) - - ScrollStrategy.EnterAlways, ScrollStrategy.EnterAlwaysCollapsed -> - constraints.maxHeight - } - ) - - val toolbarPlaceable = measurables[0].measure(toolbarConstraints) - - val bodyMeasurables = measurables.subList(1, measurables.size) - val childrenAlignments = bodyMeasurables.map { - (it.parentData as? ScaffoldParentData)?.alignment - } - val bodyPlaceables = bodyMeasurables.map { - it.measure(bodyConstraints) - } - - val toolbarHeight = toolbarPlaceable.height - - val width = max( - toolbarPlaceable.width, - bodyPlaceables.maxOfOrNull { it.width } ?: 0 - ).coerceIn(constraints.minWidth, constraints.maxWidth) - val height = max( - toolbarHeight, - bodyPlaceables.maxOfOrNull { it.height } ?: 0 - ).coerceIn(constraints.minHeight, constraints.maxHeight) - - layout(width, height) { - bodyPlaceables.forEachIndexed { index, placeable -> - val alignment = childrenAlignments[index] - - if (alignment == null) { - placeable.placeRelative(0, toolbarHeight + state.offsetY) - } else { - val offset = alignment.align( - size = IntSize(placeable.width, placeable.height), - space = IntSize(width, height), - layoutDirection = layoutDirection - ) - placeable.place(offset) - } - } - toolbarPlaceable.placeRelative(0, state.offsetY) - } - } -} - -internal object CollapsingToolbarScaffoldScopeInstance: CollapsingToolbarScaffoldScope { - @ExperimentalToolbarApi - override fun Modifier.align(alignment: Alignment): Modifier = - this.then(ScaffoldChildAlignmentModifier(alignment)) -} - -private class ScaffoldChildAlignmentModifier( - private val alignment: Alignment -) : ParentDataModifier { - override fun Density.modifyParentData(parentData: Any?): Any { - return (parentData as? ScaffoldParentData) ?: ScaffoldParentData(alignment) - } -} - -private data class ScaffoldParentData( - var alignment: Alignment? = null -) diff --git a/lib/src/main/java/me/onebone/toolbar/FabPlacement.kt b/lib/src/main/java/me/onebone/toolbar/FabPlacement.kt deleted file mode 100644 index 5f82e50..0000000 --- a/lib/src/main/java/me/onebone/toolbar/FabPlacement.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2021 onebone - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, - * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR - * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE - * OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package me.onebone.toolbar - -import androidx.compose.runtime.Immutable - -@Immutable -class FabPlacement( - val left: Int, - val width: Int, - val height: Int -) \ No newline at end of file diff --git a/lib/src/main/java/me/onebone/toolbar/FabPosition.kt b/lib/src/main/java/me/onebone/toolbar/FabPosition.kt deleted file mode 100644 index 5ac6f50..0000000 --- a/lib/src/main/java/me/onebone/toolbar/FabPosition.kt +++ /dev/null @@ -1,6 +0,0 @@ -package me.onebone.toolbar - -enum class FabPosition { - Center, - End -} \ No newline at end of file diff --git a/lib/src/main/java/me/onebone/toolbar/ScrollStrategy.kt b/lib/src/main/java/me/onebone/toolbar/ScrollStrategy.kt deleted file mode 100644 index b18c09b..0000000 --- a/lib/src/main/java/me/onebone/toolbar/ScrollStrategy.kt +++ /dev/null @@ -1,239 +0,0 @@ -/* - * Copyright (c) 2021 onebone - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, - * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR - * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE - * OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package me.onebone.toolbar - -import androidx.compose.foundation.gestures.FlingBehavior -import androidx.compose.runtime.MutableState -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 - -enum class ScrollStrategy { - EnterAlways { - override fun create( - offsetY: MutableState, - toolbarState: CollapsingToolbarState, - flingBehavior: FlingBehavior - ): NestedScrollConnection = - EnterAlwaysNestedScrollConnection(offsetY, toolbarState, flingBehavior) - }, - EnterAlwaysCollapsed { - override fun create( - offsetY: MutableState, - toolbarState: CollapsingToolbarState, - flingBehavior: FlingBehavior - ): NestedScrollConnection = - EnterAlwaysCollapsedNestedScrollConnection(offsetY, toolbarState, flingBehavior) - }, - ExitUntilCollapsed { - override fun create( - offsetY: MutableState, - toolbarState: CollapsingToolbarState, - flingBehavior: FlingBehavior - ): NestedScrollConnection = - ExitUntilCollapsedNestedScrollConnection(toolbarState, flingBehavior) - }; - - internal abstract fun create( - offsetY: MutableState, - toolbarState: CollapsingToolbarState, - flingBehavior: FlingBehavior - ): NestedScrollConnection -} - -private class ScrollDelegate( - private val offsetY: MutableState -) { - private var scrollToBeConsumed: Float = 0f - - fun doScroll(delta: Float) { - val scroll = scrollToBeConsumed + delta - val scrollInt = scroll.toInt() - - scrollToBeConsumed = scroll - scrollInt - - offsetY.value += scrollInt - } -} - -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()) - - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - val dy = available.y - - val toolbar = toolbarState.height.toFloat() - val offset = offsetY.value.toFloat() - - // -toolbarHeight <= offsetY + dy <= 0 - val consume = if(dy < 0) { - val toolbarConsumption = toolbarState.dispatchRawDelta(dy) - val remaining = dy - toolbarConsumption - val offsetConsumption = remaining.coerceAtLeast(-toolbar - offset) - scrollDelegate.doScroll(offsetConsumption) - - toolbarConsumption + offsetConsumption - }else{ - val offsetConsumption = dy.coerceAtMost(-offset) - scrollDelegate.doScroll(offsetConsumption) - - val toolbarConsumption = toolbarState.dispatchRawDelta(dy - offsetConsumption) - - offsetConsumption + toolbarConsumption - } - - return Offset(0f, consume) - } - - override suspend fun onPreFling(available: Velocity): Velocity { - val left = if(available.y > 0) { - 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. - available.y - } - - return Velocity(x = 0f, y = available.y - left) - } -} - -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()) - - 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()) - scrollDelegate.doScroll(offsetConsumption) - - offsetConsumption - }else{ // collapsing: toolbar -> offset -> body - val toolbarConsumption = toolbarState.dispatchRawDelta(dy) - val offsetConsumption = (dy - toolbarConsumption).coerceAtLeast(-toolbarState.height.toFloat() - offsetY.value) - - scrollDelegate.doScroll(offsetConsumption) - - toolbarConsumption + offsetConsumption - } - - return Offset(0f, consumed) - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - val dy = available.y - - return if(dy > 0) { - Offset(0f, toolbarState.dispatchRawDelta(dy)) - }else{ - Offset(0f, 0f) - } - } - - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - val dy = available.y - - 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{ - dy - } - - return Velocity(x = 0f, y = available.y - left) - } -} - -internal class ExitUntilCollapsedNestedScrollConnection( - private val toolbarState: CollapsingToolbarState, - private val flingBehavior: FlingBehavior -): 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{ - 0f - } - - return Offset(0f, consume) - } - - override fun onPostScroll( - consumed: Offset, - available: Offset, - source: NestedScrollSource - ): Offset { - val dy = available.y - - val consume = if(dy > 0) { // expanding: body -> toolbar - toolbarState.dispatchRawDelta(dy) - }else{ - 0f - } - - return Offset(0f, consume) - } - - override suspend fun onPreFling(available: Velocity): Velocity { - val left = if(available.y < 0) { - toolbarState.fling(flingBehavior, available.y) - }else{ - available.y - } - - return Velocity(x = 0f, y = available.y - left) - } - - override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { - val velocity = available.y - - val left = if(velocity > 0) { - toolbarState.fling(flingBehavior, velocity) - }else{ - velocity - } - - return Velocity(x = 0f, y = available.y - left) - } -} diff --git a/lib/src/main/java/me/onebone/toolbar/ToolbarWithFabScaffold.kt b/lib/src/main/java/me/onebone/toolbar/ToolbarWithFabScaffold.kt deleted file mode 100644 index 67c0a3f..0000000 --- a/lib/src/main/java/me/onebone/toolbar/ToolbarWithFabScaffold.kt +++ /dev/null @@ -1,104 +0,0 @@ -package me.onebone.toolbar - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.SubcomposeLayout -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp - -@ExperimentalToolbarApi -@Composable -fun ToolbarWithFabScaffold( - modifier: Modifier, - state: CollapsingToolbarScaffoldState, - scrollStrategy: ScrollStrategy, - toolbarModifier: Modifier = Modifier, - toolbarClipToBounds: Boolean = true, - toolbar: @Composable CollapsingToolbarScope.() -> Unit, - fab: @Composable () -> Unit, - fabPosition: FabPosition = FabPosition.End, - body: @Composable CollapsingToolbarScaffoldScope.() -> Unit -) { - SubcomposeLayout( - modifier = modifier - ) { constraints -> - - val toolbarScaffoldConstraints = constraints.copy( - minWidth = 0, - minHeight = 0, - maxHeight = constraints.maxHeight - ) - - val toolbarScaffoldPlaceables = subcompose(ToolbarWithFabScaffoldContent.ToolbarScaffold) { - CollapsingToolbarScaffold( - modifier = modifier, - state = state, - scrollStrategy = scrollStrategy, - toolbarModifier = toolbarModifier, - toolbarClipToBounds = toolbarClipToBounds, - toolbar = toolbar, - body = body - ) - }.map { it.measure(toolbarScaffoldConstraints) } - - val fabConstraints = constraints.copy( - minWidth = 0, - minHeight = 0 - ) - - val fabPlaceables = subcompose( - ToolbarWithFabScaffoldContent.Fab, - fab - ).mapNotNull { measurable -> - measurable.measure(fabConstraints).takeIf { it.height != 0 && it.width != 0 } - } - - val fabPlacement = if (fabPlaceables.isNotEmpty()) { - val fabWidth = fabPlaceables.maxOfOrNull { it.width } ?: 0 - val fabHeight = fabPlaceables.maxOfOrNull { it.height } ?: 0 - // FAB distance from the left of the layout, taking into account LTR / RTL - val fabLeftOffset = if (fabPosition == FabPosition.End) { - if (layoutDirection == LayoutDirection.Ltr) { - constraints.maxWidth - 16.dp.roundToPx() - fabWidth - } else { - 16.dp.roundToPx() - } - } else { - (constraints.maxWidth - fabWidth) / 2 - } - - FabPlacement( - left = fabLeftOffset, - width = fabWidth, - height = fabHeight - ) - } else { - null - } - - val fabOffsetFromBottom = fabPlacement?.let { - it.height + 16.dp.roundToPx() - } - - val width = constraints.maxWidth - val height = constraints.maxHeight - - layout(width, height) { - toolbarScaffoldPlaceables.forEach { - it.place(0, 0) - } - - fabPlacement?.let { placement -> - fabPlaceables.forEach { - it.place(placement.left, height - fabOffsetFromBottom!!) - } - } - - } - - } -} - -private enum class ToolbarWithFabScaffoldContent { - ToolbarScaffold, Fab -} diff --git a/lib/src/test/java/me/onebone/toolbar/ExampleUnitTest.kt b/lib/src/test/java/me/onebone/toolbar/ExampleUnitTest.kt deleted file mode 100644 index 314ec8d..0000000 --- a/lib/src/test/java/me/onebone/toolbar/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package me.onebone.toolbar - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/settings.gradle b/settings.gradle.kts similarity index 55% rename from settings.gradle rename to settings.gradle.kts index b1c743a..8f6a7c8 100644 --- a/settings.gradle +++ b/settings.gradle.kts @@ -1,3 +1,3 @@ -include ':lib' -include ':app' +include(":lib") +include(":app") rootProject.name = "Collapsing Toolbar" From acde1a25a8f385846c5f2c3256daf18da09161ab Mon Sep 17 00:00:00 2001 From: DatLag Date: Sat, 29 Jul 2023 15:06:04 +0200 Subject: [PATCH 2/2] initial multiplatform support --- .idea/artifacts/lib_jvm.xml | 8 + .idea/kotlinc.xml | 6 + .../kotlin/me/onebone/toolbar/Annotations.kt | 36 ++ .../me/onebone/toolbar/AppBarContainer.kt | 192 +++++++++ .../me/onebone/toolbar/CollapsingToolbar.kt | 384 ++++++++++++++++++ .../toolbar/CollapsingToolbarScaffold.kt | 200 +++++++++ .../kotlin/me/onebone/toolbar/FabPlacement.kt | 32 ++ .../kotlin/me/onebone/toolbar/FabPosition.kt | 6 + .../me/onebone/toolbar/ScrollStrategy.kt | 239 +++++++++++ .../onebone/toolbar/ToolbarWithFabScaffold.kt | 104 +++++ 10 files changed, 1207 insertions(+) create mode 100644 .idea/artifacts/lib_jvm.xml create mode 100644 .idea/kotlinc.xml create mode 100644 lib/src/commonMain/kotlin/me/onebone/toolbar/Annotations.kt create mode 100644 lib/src/commonMain/kotlin/me/onebone/toolbar/AppBarContainer.kt create mode 100644 lib/src/commonMain/kotlin/me/onebone/toolbar/CollapsingToolbar.kt create mode 100644 lib/src/commonMain/kotlin/me/onebone/toolbar/CollapsingToolbarScaffold.kt create mode 100644 lib/src/commonMain/kotlin/me/onebone/toolbar/FabPlacement.kt create mode 100644 lib/src/commonMain/kotlin/me/onebone/toolbar/FabPosition.kt create mode 100644 lib/src/commonMain/kotlin/me/onebone/toolbar/ScrollStrategy.kt create mode 100644 lib/src/commonMain/kotlin/me/onebone/toolbar/ToolbarWithFabScaffold.kt diff --git a/.idea/artifacts/lib_jvm.xml b/.idea/artifacts/lib_jvm.xml new file mode 100644 index 0000000..dbaabc5 --- /dev/null +++ b/.idea/artifacts/lib_jvm.xml @@ -0,0 +1,8 @@ + + + $PROJECT_DIR$/lib/build/libs + + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..fdf8d99 --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/lib/src/commonMain/kotlin/me/onebone/toolbar/Annotations.kt b/lib/src/commonMain/kotlin/me/onebone/toolbar/Annotations.kt new file mode 100644 index 0000000..2fe0745 --- /dev/null +++ b/lib/src/commonMain/kotlin/me/onebone/toolbar/Annotations.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package me.onebone.toolbar + +@RequiresOptIn( + message = "This is an experimental API of compose-collapsing-toolbar. Any declarations with " + + "the annotation might be removed or changed in some way without any notice.", + level = RequiresOptIn.Level.WARNING +) +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CLASS +) +@Retention(AnnotationRetention.BINARY) +annotation class ExperimentalToolbarApi diff --git a/lib/src/commonMain/kotlin/me/onebone/toolbar/AppBarContainer.kt b/lib/src/commonMain/kotlin/me/onebone/toolbar/AppBarContainer.kt new file mode 100644 index 0000000..9c2f30f --- /dev/null +++ b/lib/src/commonMain/kotlin/me/onebone/toolbar/AppBarContainer.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +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.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)", + "me.onebone.toolbar" + ) +) +@Composable +fun AppbarContainer( + modifier: Modifier = Modifier, + scrollStrategy: ScrollStrategy, + collapsingToolbarState: CollapsingToolbarState, + content: @Composable AppbarContainerScope.() -> Unit +) { + AppBarContainer( + modifier = modifier, + scrollStrategy = scrollStrategy, + collapsingToolbarState = collapsingToolbarState, + content = content + ) +} + +@Deprecated( + "AppBarContainer is replaced with CollapsingToolbarScaffold", + replaceWith = ReplaceWith( + "CollapsingToolbarScaffold", + "me.onebone.toolbar" + ) +) +@Composable +fun AppBarContainer( + modifier: Modifier = Modifier, + scrollStrategy: ScrollStrategy, + /** The state of a connected collapsing toolbar */ + collapsingToolbarState: CollapsingToolbarState, + 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) + } + + Layout( + content = { scope.content() }, + measurePolicy = measurePolicy, + modifier = modifier + ) +} + +interface AppbarContainerScope { + fun Modifier.appBarBody(): Modifier +} + +internal class AppbarContainerScopeImpl( + private val nestedScrollConnection: NestedScrollConnection +): AppbarContainerScope { + override fun Modifier.appBarBody(): Modifier { + return this + .then(AppBarBodyMarkerModifier) + .nestedScroll(nestedScrollConnection) + } +} + +private object AppBarBodyMarkerModifier: ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return AppBarBodyMarker + } +} + +private object AppBarBodyMarker + +private class AppbarMeasurePolicy( + private val scrollStrategy: ScrollStrategy, + private val toolbarState: CollapsingToolbarState, + private val offsetY: State +): MeasurePolicy { + override fun MeasureScope.measure( + measurables: List, + constraints: Constraints + ): MeasureResult { + var width = 0 + var height = 0 + + var toolbarPlaceable: Placeable? = null + + val nonToolbars = measurables.filter { + val data = it.parentData + 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 + )) + width = max(width, placeable.width) + height = max(height, placeable.height) + + toolbarPlaceable = placeable + + false + }else{ + true + } + } + + val placeables = nonToolbars.map { measurable -> + val childConstraints = if(scrollStrategy == ScrollStrategy.ExitUntilCollapsed) { + constraints.copy( + minWidth = 0, + minHeight = 0, + maxHeight = max(0, constraints.maxHeight - toolbarState.minHeight) + ) + }else{ + constraints.copy( + minWidth = 0, + minHeight = 0 + ) + } + + val placeable = measurable.measure(childConstraints) + + width = max(width, placeable.width) + height = max(height, placeable.height) + + placeable + } + + height += (toolbarPlaceable?.height ?: 0) + + return layout( + width.coerceIn(constraints.minWidth, constraints.maxWidth), + height.coerceIn(constraints.minHeight, constraints.maxHeight) + ) { + toolbarPlaceable?.place(x = 0, y = offsetY.value) + + placeables.forEach { placeable -> + placeable.place( + x = 0, + y = offsetY.value + (toolbarPlaceable?.height ?: 0) + ) + } + } + } +} diff --git a/lib/src/commonMain/kotlin/me/onebone/toolbar/CollapsingToolbar.kt b/lib/src/commonMain/kotlin/me/onebone/toolbar/CollapsingToolbar.kt new file mode 100644 index 0000000..b24acdb --- /dev/null +++ b/lib/src/commonMain/kotlin/me/onebone/toolbar/CollapsingToolbar.kt @@ -0,0 +1,384 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package me.onebone.toolbar + +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.animateTo +import androidx.compose.animation.core.tween +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.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.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import kotlin.math.absoluteValue +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +@Stable +class CollapsingToolbarState( + initial: Int = Int.MAX_VALUE +): 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. + */ + var minHeight: Int + get() = minHeightState + internal set(value) { + minHeightState = 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. + */ + var maxHeight: Int + get() = maxHeightState + internal set(value) { + maxHeightState = value + + if(value < height) { + height = value + } + } + + private var maxHeightState by mutableStateOf(Int.MAX_VALUE) + private var minHeightState by mutableStateOf(0) + + val progress: Float + get() = + if(minHeight == maxHeight) { + 0f + }else{ + ((height - minHeight).toFloat() / (maxHeight - minHeight)).coerceIn(0f, 1f) + } + + private val scrollableState = ScrollableState { value -> + val consume = if(value < 0) { + max(minHeight.toFloat() - height, value) + }else{ + min(maxHeight.toFloat() - height, value) + } + + val current = consume + deferredConsumption + val currentInt = current.toInt() + + if(current.absoluteValue > 0) { + height += currentInt + deferredConsumption = current - currentInt + } + + consume + } + + private var deferredConsumption: Float = 0f + + /** + * @return consumed scroll value is returned + */ + @Deprecated( + message = "feedScroll() is deprecated, use dispatchRawDelta() instead.", + replaceWith = ReplaceWith("dispatchRawDelta(value)") + ) + fun feedScroll(value: Float): Float = dispatchRawDelta(value) + + @ExperimentalToolbarApi + suspend fun expand(duration: Int = 200) { + val anim = AnimationState(height.toFloat()) + + scroll { + var prev = anim.value + anim.animateTo(maxHeight.toFloat(), tween(duration)) { + scrollBy(value - prev) + prev = value + } + } + } + + @ExperimentalToolbarApi + suspend fun collapse(duration: Int = 200) { + val anim = AnimationState(height.toFloat()) + + scroll { + var prev = anim.value + anim.animateTo(minHeight.toFloat(), tween(duration)) { + scrollBy(value - prev) + prev = 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 + } + + override val isScrollInProgress: Boolean + get() = scrollableState.isScrollInProgress + + override fun dispatchRawDelta(delta: Float): Float = scrollableState.dispatchRawDelta(delta) + + override suspend fun scroll( + scrollPriority: MutatePriority, + block: suspend ScrollScope.() -> Unit + ) = scrollableState.scroll(scrollPriority, block) +} + +@Composable +fun rememberCollapsingToolbarState( + initial: Int = Int.MAX_VALUE +): CollapsingToolbarState { + return remember { + CollapsingToolbarState( + initial = initial + ) + } +} + +@Composable +fun CollapsingToolbar( + modifier: Modifier = Modifier, + clipToBounds: Boolean = true, + collapsingToolbarState: CollapsingToolbarState, + content: @Composable CollapsingToolbarScope.() -> Unit +) { + val measurePolicy = remember(collapsingToolbarState) { + CollapsingToolbarMeasurePolicy(collapsingToolbarState) + } + + Layout( + content = { CollapsingToolbarScopeInstance.content() }, + measurePolicy = measurePolicy, + modifier = modifier.then( + if (clipToBounds) { + Modifier.clipToBounds() + } else { + Modifier + } + ) + ) +} + +private class CollapsingToolbarMeasurePolicy( + private val collapsingToolbarState: CollapsingToolbarState +): MeasurePolicy { + override fun MeasureScope.measure( + measurables: List, + constraints: Constraints + ): MeasureResult { + val placeables = measurables.map { + it.measure( + constraints.copy( + minWidth = 0, + minHeight = 0, + maxHeight = Constraints.Infinity + ) + ) + } + + val placeStrategy = measurables.map { it.parentData } + + val minHeight = placeables.minOfOrNull { it.height } + ?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0 + + val maxHeight = placeables.maxOfOrNull { it.height } + ?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0 + + val maxWidth = placeables.maxOfOrNull{ it.width } + ?.coerceIn(constraints.minWidth, constraints.maxWidth) ?: 0 + + collapsingToolbarState.also { + it.minHeight = minHeight + it.maxHeight = maxHeight + } + + val height = collapsingToolbarState.height + return layout(maxWidth, height) { + val progress = collapsingToolbarState.progress + + placeables.forEachIndexed { i, placeable -> + val strategy = placeStrategy[i] + if(strategy is CollapsingToolbarData) { + strategy.progressListener?.onProgressUpdate(progress) + } + + when(strategy) { + is CollapsingToolbarRoadData -> { + val collapsed = strategy.whenCollapsed + val expanded = strategy.whenExpanded + + val collapsedOffset = collapsed.align( + size = IntSize(placeable.width, placeable.height), + space = IntSize(maxWidth, height), + layoutDirection = layoutDirection + ) + + val expandedOffset = expanded.align( + size = IntSize(placeable.width, placeable.height), + space = IntSize(maxWidth, height), + layoutDirection = layoutDirection + ) + + val offset = collapsedOffset + (expandedOffset - collapsedOffset) * progress + + 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) + } + } + } + } +} + +interface CollapsingToolbarScope { + fun Modifier.progress(listener: ProgressListener): Modifier + + fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier + + fun Modifier.parallax(ratio: Float = 0.2f): Modifier + + fun Modifier.pin(): Modifier +} + +internal object CollapsingToolbarScopeInstance: CollapsingToolbarScope { + override fun Modifier.progress(listener: ProgressListener): Modifier { + return this.then(ProgressUpdateListenerModifier(listener)) + } + + override fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier { + return this.then(RoadModifier(whenCollapsed, whenExpanded)) + } + + override fun Modifier.parallax(ratio: Float): Modifier { + return this.then(ParallaxModifier(ratio)) + } + + override fun Modifier.pin(): Modifier { + return this.then(PinModifier()) + } +} + +internal class RoadModifier( + private val whenCollapsed: Alignment, + private val whenExpanded: Alignment +): ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return CollapsingToolbarRoadData( + this@RoadModifier.whenCollapsed, this@RoadModifier.whenExpanded, + (parentData as? CollapsingToolbarData)?.progressListener + ) + } +} + +internal class ParallaxModifier( + private val ratio: Float +): ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return CollapsingToolbarParallaxData(ratio, (parentData as? CollapsingToolbarData)?.progressListener) + } +} + +internal class PinModifier: ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return CollapsingToolbarPinData((parentData as? CollapsingToolbarData)?.progressListener) + } +} + +internal class ProgressUpdateListenerModifier( + private val listener: ProgressListener +): ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return CollapsingToolbarProgressData(listener) + } +} + +fun interface ProgressListener { + fun onProgressUpdate(value: Float) +} + +internal sealed class CollapsingToolbarData( + var progressListener: ProgressListener? +) + +internal class CollapsingToolbarProgressData( + progressListener: ProgressListener? +): CollapsingToolbarData(progressListener) + +internal class CollapsingToolbarRoadData( + var whenCollapsed: Alignment, + var whenExpanded: Alignment, + progressListener: ProgressListener? = null +): CollapsingToolbarData(progressListener) + +internal class CollapsingToolbarPinData( + progressListener: ProgressListener? = null +): CollapsingToolbarData(progressListener) + +internal class CollapsingToolbarParallaxData( + var ratio: Float, + progressListener: ProgressListener? = null +): CollapsingToolbarData(progressListener) diff --git a/lib/src/commonMain/kotlin/me/onebone/toolbar/CollapsingToolbarScaffold.kt b/lib/src/commonMain/kotlin/me/onebone/toolbar/CollapsingToolbarScaffold.kt new file mode 100644 index 0000000..afdf0cd --- /dev/null +++ b/lib/src/commonMain/kotlin/me/onebone/toolbar/CollapsingToolbarScaffold.kt @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package me.onebone.toolbar + +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.ParentDataModifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import kotlin.math.max + +@Stable +class CollapsingToolbarScaffoldState( + val toolbarState: CollapsingToolbarState, + initialOffsetY: Int = 0 +) { + val offsetY: Int + get() = offsetYState.value + + internal val offsetYState = mutableStateOf(initialOffsetY) +} + +private class CollapsingToolbarScaffoldStateSaver: Saver> { + override fun restore(value: List): CollapsingToolbarScaffoldState = + CollapsingToolbarScaffoldState( + CollapsingToolbarState(value[0] as Int), + value[1] as Int + ) + + override fun SaverScope.save(value: CollapsingToolbarScaffoldState): List = + listOf( + value.toolbarState.height, + value.offsetY + ) +} + +@Composable +fun rememberCollapsingToolbarScaffoldState( + toolbarState: CollapsingToolbarState = rememberCollapsingToolbarState() +): CollapsingToolbarScaffoldState { + return rememberSaveable(toolbarState, saver = CollapsingToolbarScaffoldStateSaver()) { + CollapsingToolbarScaffoldState(toolbarState) + } +} + +interface CollapsingToolbarScaffoldScope { + @ExperimentalToolbarApi + fun Modifier.align(alignment: Alignment): Modifier +} + +@Composable +fun CollapsingToolbarScaffold( + modifier: Modifier, + state: CollapsingToolbarScaffoldState, + scrollStrategy: ScrollStrategy, + enabled: Boolean = true, + toolbarModifier: Modifier = Modifier, + toolbarClipToBounds: Boolean = true, + toolbar: @Composable CollapsingToolbarScope.() -> Unit, + body: @Composable CollapsingToolbarScaffoldScope.() -> Unit +) { + val flingBehavior = ScrollableDefaults.flingBehavior() + val layoutDirection = LocalLayoutDirection.current + + val nestedScrollConnection = remember(scrollStrategy, state) { + scrollStrategy.create(state.offsetYState, state.toolbarState, flingBehavior) + } + + val toolbarState = state.toolbarState + + Layout( + content = { + CollapsingToolbar( + modifier = toolbarModifier, + clipToBounds = toolbarClipToBounds, + collapsingToolbarState = toolbarState, + ) { + toolbar() + } + + CollapsingToolbarScaffoldScopeInstance.body() + }, + modifier = modifier + .then( + if (enabled) { + Modifier.nestedScroll(nestedScrollConnection) + } else { + Modifier + } + ) + ) { measurables, constraints -> + check(measurables.size >= 2) { + "the number of children should be at least 2: toolbar, (at least one) body" + } + + val toolbarConstraints = constraints.copy( + minWidth = 0, + minHeight = 0 + ) + val bodyConstraints = constraints.copy( + minWidth = 0, + minHeight = 0, + maxHeight = when (scrollStrategy) { + ScrollStrategy.ExitUntilCollapsed -> + (constraints.maxHeight - toolbarState.minHeight).coerceAtLeast(0) + + ScrollStrategy.EnterAlways, ScrollStrategy.EnterAlwaysCollapsed -> + constraints.maxHeight + } + ) + + val toolbarPlaceable = measurables[0].measure(toolbarConstraints) + + val bodyMeasurables = measurables.subList(1, measurables.size) + val childrenAlignments = bodyMeasurables.map { + (it.parentData as? ScaffoldParentData)?.alignment + } + val bodyPlaceables = bodyMeasurables.map { + it.measure(bodyConstraints) + } + + val toolbarHeight = toolbarPlaceable.height + + val width = max( + toolbarPlaceable.width, + bodyPlaceables.maxOfOrNull { it.width } ?: 0 + ).coerceIn(constraints.minWidth, constraints.maxWidth) + val height = max( + toolbarHeight, + bodyPlaceables.maxOfOrNull { it.height } ?: 0 + ).coerceIn(constraints.minHeight, constraints.maxHeight) + + layout(width, height) { + bodyPlaceables.forEachIndexed { index, placeable -> + val alignment = childrenAlignments[index] + + if (alignment == null) { + placeable.placeRelative(0, toolbarHeight + state.offsetY) + } else { + val offset = alignment.align( + size = IntSize(placeable.width, placeable.height), + space = IntSize(width, height), + layoutDirection = layoutDirection + ) + placeable.place(offset) + } + } + toolbarPlaceable.placeRelative(0, state.offsetY) + } + } +} + +internal object CollapsingToolbarScaffoldScopeInstance: CollapsingToolbarScaffoldScope { + @ExperimentalToolbarApi + override fun Modifier.align(alignment: Alignment): Modifier = + this.then(ScaffoldChildAlignmentModifier(alignment)) +} + +private class ScaffoldChildAlignmentModifier( + private val alignment: Alignment +) : ParentDataModifier { + override fun Density.modifyParentData(parentData: Any?): Any { + return (parentData as? ScaffoldParentData) ?: ScaffoldParentData(alignment) + } +} + +private data class ScaffoldParentData( + var alignment: Alignment? = null +) diff --git a/lib/src/commonMain/kotlin/me/onebone/toolbar/FabPlacement.kt b/lib/src/commonMain/kotlin/me/onebone/toolbar/FabPlacement.kt new file mode 100644 index 0000000..5f82e50 --- /dev/null +++ b/lib/src/commonMain/kotlin/me/onebone/toolbar/FabPlacement.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package me.onebone.toolbar + +import androidx.compose.runtime.Immutable + +@Immutable +class FabPlacement( + val left: Int, + val width: Int, + val height: Int +) \ No newline at end of file diff --git a/lib/src/commonMain/kotlin/me/onebone/toolbar/FabPosition.kt b/lib/src/commonMain/kotlin/me/onebone/toolbar/FabPosition.kt new file mode 100644 index 0000000..5ac6f50 --- /dev/null +++ b/lib/src/commonMain/kotlin/me/onebone/toolbar/FabPosition.kt @@ -0,0 +1,6 @@ +package me.onebone.toolbar + +enum class FabPosition { + Center, + End +} \ No newline at end of file diff --git a/lib/src/commonMain/kotlin/me/onebone/toolbar/ScrollStrategy.kt b/lib/src/commonMain/kotlin/me/onebone/toolbar/ScrollStrategy.kt new file mode 100644 index 0000000..b18c09b --- /dev/null +++ b/lib/src/commonMain/kotlin/me/onebone/toolbar/ScrollStrategy.kt @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2021 onebone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE + * OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package me.onebone.toolbar + +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.runtime.MutableState +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 + +enum class ScrollStrategy { + EnterAlways { + override fun create( + offsetY: MutableState, + toolbarState: CollapsingToolbarState, + flingBehavior: FlingBehavior + ): NestedScrollConnection = + EnterAlwaysNestedScrollConnection(offsetY, toolbarState, flingBehavior) + }, + EnterAlwaysCollapsed { + override fun create( + offsetY: MutableState, + toolbarState: CollapsingToolbarState, + flingBehavior: FlingBehavior + ): NestedScrollConnection = + EnterAlwaysCollapsedNestedScrollConnection(offsetY, toolbarState, flingBehavior) + }, + ExitUntilCollapsed { + override fun create( + offsetY: MutableState, + toolbarState: CollapsingToolbarState, + flingBehavior: FlingBehavior + ): NestedScrollConnection = + ExitUntilCollapsedNestedScrollConnection(toolbarState, flingBehavior) + }; + + internal abstract fun create( + offsetY: MutableState, + toolbarState: CollapsingToolbarState, + flingBehavior: FlingBehavior + ): NestedScrollConnection +} + +private class ScrollDelegate( + private val offsetY: MutableState +) { + private var scrollToBeConsumed: Float = 0f + + fun doScroll(delta: Float) { + val scroll = scrollToBeConsumed + delta + val scrollInt = scroll.toInt() + + scrollToBeConsumed = scroll - scrollInt + + offsetY.value += scrollInt + } +} + +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()) + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val dy = available.y + + val toolbar = toolbarState.height.toFloat() + val offset = offsetY.value.toFloat() + + // -toolbarHeight <= offsetY + dy <= 0 + val consume = if(dy < 0) { + val toolbarConsumption = toolbarState.dispatchRawDelta(dy) + val remaining = dy - toolbarConsumption + val offsetConsumption = remaining.coerceAtLeast(-toolbar - offset) + scrollDelegate.doScroll(offsetConsumption) + + toolbarConsumption + offsetConsumption + }else{ + val offsetConsumption = dy.coerceAtMost(-offset) + scrollDelegate.doScroll(offsetConsumption) + + val toolbarConsumption = toolbarState.dispatchRawDelta(dy - offsetConsumption) + + offsetConsumption + toolbarConsumption + } + + return Offset(0f, consume) + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val left = if(available.y > 0) { + 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. + available.y + } + + return Velocity(x = 0f, y = available.y - left) + } +} + +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()) + + 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()) + scrollDelegate.doScroll(offsetConsumption) + + offsetConsumption + }else{ // collapsing: toolbar -> offset -> body + val toolbarConsumption = toolbarState.dispatchRawDelta(dy) + val offsetConsumption = (dy - toolbarConsumption).coerceAtLeast(-toolbarState.height.toFloat() - offsetY.value) + + scrollDelegate.doScroll(offsetConsumption) + + toolbarConsumption + offsetConsumption + } + + return Offset(0f, consumed) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + val dy = available.y + + return if(dy > 0) { + Offset(0f, toolbarState.dispatchRawDelta(dy)) + }else{ + Offset(0f, 0f) + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val dy = available.y + + 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{ + dy + } + + return Velocity(x = 0f, y = available.y - left) + } +} + +internal class ExitUntilCollapsedNestedScrollConnection( + private val toolbarState: CollapsingToolbarState, + private val flingBehavior: FlingBehavior +): 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{ + 0f + } + + return Offset(0f, consume) + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + val dy = available.y + + val consume = if(dy > 0) { // expanding: body -> toolbar + toolbarState.dispatchRawDelta(dy) + }else{ + 0f + } + + return Offset(0f, consume) + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val left = if(available.y < 0) { + toolbarState.fling(flingBehavior, available.y) + }else{ + available.y + } + + return Velocity(x = 0f, y = available.y - left) + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val velocity = available.y + + val left = if(velocity > 0) { + toolbarState.fling(flingBehavior, velocity) + }else{ + velocity + } + + return Velocity(x = 0f, y = available.y - left) + } +} diff --git a/lib/src/commonMain/kotlin/me/onebone/toolbar/ToolbarWithFabScaffold.kt b/lib/src/commonMain/kotlin/me/onebone/toolbar/ToolbarWithFabScaffold.kt new file mode 100644 index 0000000..67c0a3f --- /dev/null +++ b/lib/src/commonMain/kotlin/me/onebone/toolbar/ToolbarWithFabScaffold.kt @@ -0,0 +1,104 @@ +package me.onebone.toolbar + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp + +@ExperimentalToolbarApi +@Composable +fun ToolbarWithFabScaffold( + modifier: Modifier, + state: CollapsingToolbarScaffoldState, + scrollStrategy: ScrollStrategy, + toolbarModifier: Modifier = Modifier, + toolbarClipToBounds: Boolean = true, + toolbar: @Composable CollapsingToolbarScope.() -> Unit, + fab: @Composable () -> Unit, + fabPosition: FabPosition = FabPosition.End, + body: @Composable CollapsingToolbarScaffoldScope.() -> Unit +) { + SubcomposeLayout( + modifier = modifier + ) { constraints -> + + val toolbarScaffoldConstraints = constraints.copy( + minWidth = 0, + minHeight = 0, + maxHeight = constraints.maxHeight + ) + + val toolbarScaffoldPlaceables = subcompose(ToolbarWithFabScaffoldContent.ToolbarScaffold) { + CollapsingToolbarScaffold( + modifier = modifier, + state = state, + scrollStrategy = scrollStrategy, + toolbarModifier = toolbarModifier, + toolbarClipToBounds = toolbarClipToBounds, + toolbar = toolbar, + body = body + ) + }.map { it.measure(toolbarScaffoldConstraints) } + + val fabConstraints = constraints.copy( + minWidth = 0, + minHeight = 0 + ) + + val fabPlaceables = subcompose( + ToolbarWithFabScaffoldContent.Fab, + fab + ).mapNotNull { measurable -> + measurable.measure(fabConstraints).takeIf { it.height != 0 && it.width != 0 } + } + + val fabPlacement = if (fabPlaceables.isNotEmpty()) { + val fabWidth = fabPlaceables.maxOfOrNull { it.width } ?: 0 + val fabHeight = fabPlaceables.maxOfOrNull { it.height } ?: 0 + // FAB distance from the left of the layout, taking into account LTR / RTL + val fabLeftOffset = if (fabPosition == FabPosition.End) { + if (layoutDirection == LayoutDirection.Ltr) { + constraints.maxWidth - 16.dp.roundToPx() - fabWidth + } else { + 16.dp.roundToPx() + } + } else { + (constraints.maxWidth - fabWidth) / 2 + } + + FabPlacement( + left = fabLeftOffset, + width = fabWidth, + height = fabHeight + ) + } else { + null + } + + val fabOffsetFromBottom = fabPlacement?.let { + it.height + 16.dp.roundToPx() + } + + val width = constraints.maxWidth + val height = constraints.maxHeight + + layout(width, height) { + toolbarScaffoldPlaceables.forEach { + it.place(0, 0) + } + + fabPlacement?.let { placement -> + fabPlaceables.forEach { + it.place(placement.left, height - fabOffsetFromBottom!!) + } + } + + } + + } +} + +private enum class ToolbarWithFabScaffoldContent { + ToolbarScaffold, Fab +}