1 /* 2 * Copyright (C) 2025 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 @file:OptIn(ExperimentalAnimatableApi::class) 18 19 package com.android.mechanics.demo.presentation 20 21 import androidx.compose.animation.core.ExperimentalAnimatableApi 22 import androidx.compose.foundation.background 23 import androidx.compose.foundation.layout.Arrangement 24 import androidx.compose.foundation.layout.Box 25 import androidx.compose.foundation.layout.Column 26 import androidx.compose.foundation.layout.ColumnScope 27 import androidx.compose.foundation.layout.Row 28 import androidx.compose.foundation.layout.fillMaxWidth 29 import androidx.compose.foundation.layout.height 30 import androidx.compose.foundation.layout.padding 31 import androidx.compose.foundation.layout.size 32 import androidx.compose.foundation.shape.RoundedCornerShape 33 import androidx.compose.material3.MaterialTheme 34 import androidx.compose.material3.Slider 35 import androidx.compose.material3.Text 36 import androidx.compose.runtime.Composable 37 import androidx.compose.runtime.getValue 38 import androidx.compose.runtime.mutableFloatStateOf 39 import androidx.compose.runtime.mutableStateOf 40 import androidx.compose.runtime.remember 41 import androidx.compose.runtime.setValue 42 import androidx.compose.ui.Alignment 43 import androidx.compose.ui.Modifier 44 import androidx.compose.ui.draw.clip 45 import androidx.compose.ui.draw.drawBehind 46 import androidx.compose.ui.geometry.CornerRadius 47 import androidx.compose.ui.graphics.PathEffect 48 import androidx.compose.ui.graphics.drawscope.Stroke 49 import androidx.compose.ui.graphics.graphicsLayer 50 import androidx.compose.ui.layout.onPlaced 51 import androidx.compose.ui.layout.positionInParent 52 import androidx.compose.ui.platform.LocalDensity 53 import androidx.compose.ui.unit.Dp 54 import androidx.compose.ui.unit.dp 55 import com.android.compose.modifiers.height 56 import com.android.compose.modifiers.width 57 import com.android.mechanics.debug.DebugMotionValueVisualization 58 import com.android.mechanics.demo.staging.defaultSpatialSpring 59 import com.android.mechanics.demo.staging.rememberDistanceGestureContext 60 import com.android.mechanics.demo.staging.rememberMotionValue 61 import com.android.mechanics.demo.tuneable.Demo 62 import com.android.mechanics.demo.tuneable.DpSlider 63 import com.android.mechanics.demo.tuneable.Dropdown 64 import com.android.mechanics.demo.tuneable.SpringParameterSection 65 import com.android.mechanics.spec.Guarantee 66 import com.android.mechanics.spec.Mapping 67 import com.android.mechanics.spec.MotionSpec 68 import com.android.mechanics.spec.builder 69 import com.android.mechanics.spring.SpringParameters 70 import kotlin.math.min 71 72 object GuaranteeBoxDemo : Demo<GuaranteeBoxDemo.Config> { 73 enum class Scenario(val label: String) { 74 Mapped("Mapped"), 75 Triggered("With Triggers"), 76 Guaranteed("With Gurarantee"), 77 } 78 79 data class Config( 80 val defaultSpring: SpringParameters, 81 val boxWidth: Dp, 82 val minVisibleWidth: Dp, 83 val guaranteeDistance: Dp, 84 ) 85 86 var inputRange by mutableStateOf(0f..0f) 87 88 @Composable DemoUinull89 override fun DemoUi(config: Config, modifier: Modifier) { 90 val colors = MaterialTheme.colorScheme 91 var activeScenario by remember { mutableStateOf(Scenario.Guaranteed) } 92 93 var placedBoxWidth by remember { mutableFloatStateOf(0f) } 94 var placedBoxX by remember { mutableFloatStateOf(0f) } 95 96 // Also using GestureContext.dragOffset as input. 97 val gestureContext = rememberDistanceGestureContext() 98 val spec = 99 rememberSpec( 100 activeScenario, 101 { placedBoxX }, 102 { placedBoxWidth }, 103 inputOutputRange = inputRange, 104 config, 105 ) 106 val motionValue = rememberMotionValue(gestureContext::dragOffset, { spec }, gestureContext) 107 Column( 108 verticalArrangement = Arrangement.spacedBy(24.dp), 109 modifier = modifier.fillMaxWidth().padding(vertical = 24.dp, horizontal = 48.dp), 110 ) { 111 112 // Scenario selector 113 Row { 114 Text("Example Scenario: ") 115 116 Dropdown( 117 activeScenario, 118 Scenario.entries, 119 { it.label }, 120 { activeScenario = it }, 121 modifier = Modifier.fillMaxWidth(), 122 ) 123 } 124 125 // Output visualization 126 val density = LocalDensity.current 127 var minShapeSize by remember { mutableFloatStateOf(with(density) { 16.dp.toPx() }) } 128 129 Box( 130 contentAlignment = Alignment.Center, 131 modifier = 132 Modifier.fillMaxWidth().padding(horizontal = 48.dp).onPlaced { 133 inputRange = 0f..it.size.width.toFloat() 134 }, 135 ) { 136 val colors = MaterialTheme.colorScheme 137 val stroke = 138 remember(this) { 139 with(density) { 140 val intervals = floatArrayOf(4.dp.toPx(), 4.dp.toPx()) 141 Stroke(2.dp.toPx(), pathEffect = PathEffect.dashPathEffect(intervals)) 142 } 143 } 144 145 // To illustrate the effect, use an outer Box to visually hint the spec dimension. 146 // In real code, the dimensions for the spec are derived from Lookahead instead 147 Box( 148 contentAlignment = Alignment.CenterStart, 149 modifier = 150 Modifier.size(config.boxWidth, 64.dp) 151 .onPlaced { 152 placedBoxWidth = it.size.width.toFloat() 153 placedBoxX = it.positionInParent().x 154 } 155 .drawBehind { 156 drawRoundRect( 157 colors.primary, 158 cornerRadius = CornerRadius(24.dp.toPx()), 159 alpha = 0.1f, 160 style = stroke, 161 ) 162 }, 163 ) { 164 Box( 165 modifier = 166 Modifier.width { motionValue.floatValue.toInt() } 167 .height { 168 // Make it look prettier by offsetting the height from 169 val targetHeight = 64.dp.toPx() 170 val cornerRadius = 24.dp.toPx() * 2 171 172 (targetHeight - 173 (cornerRadius - motionValue.floatValue).coerceAtLeast( 174 0f 175 )) 176 .toInt() 177 } 178 .clip(remember { RoundedCornerShape(24.dp) }) 179 .graphicsLayer { 180 alpha = 181 (motionValue.floatValue / (minShapeSize / 2)).coerceIn( 182 0f, 183 1f, 184 ) 185 } 186 .background(MaterialTheme.colorScheme.primary) 187 ) 188 } 189 } 190 // MotionValue visualization 191 DebugMotionValueVisualization( 192 motionValue, 193 inputRange, 194 modifier = Modifier.fillMaxWidth().height(64.dp), 195 ) 196 197 // Input visualization 198 Slider( 199 value = gestureContext.dragOffset, 200 valueRange = inputRange, 201 onValueChange = { gestureContext.dragOffset = it }, 202 modifier = Modifier.fillMaxWidth(), 203 ) 204 } 205 } 206 207 @Composable rememberSpecnull208 fun rememberSpec( 209 scenario: Scenario, 210 x: () -> Float, 211 width: () -> Float, 212 inputOutputRange: ClosedFloatingPointRange<Float>, 213 config: Config, 214 ): MotionSpec { 215 216 val density = LocalDensity.current 217 val left = x() 218 val widthVal = width() 219 val right = left + widthVal 220 221 return remember(scenario, inputOutputRange, config, left, widthVal, density) { 222 with(density) { 223 val guarantee = Guarantee.InputDelta(config.guaranteeDistance.toPx()) 224 val minSize = config.minVisibleWidth.toPx() 225 when (scenario) { 226 Scenario.Mapped -> 227 MotionSpec.builder(config.defaultSpring, initialMapping = Mapping.Zero) 228 .toBreakpoint(left) 229 .jumpTo(0f) 230 .continueWithTargetValue(widthVal) 231 .toBreakpoint(right) 232 .completeWith(Mapping.Fixed(widthVal)) 233 234 Scenario.Triggered -> 235 MotionSpec.builder(config.defaultSpring, initialMapping = Mapping.Zero) 236 .toBreakpoint(min(left + minSize, right)) 237 .jumpTo(minSize) 238 .continueWithTargetValue(widthVal - minSize) 239 .toBreakpoint(right) 240 .completeWith(Mapping.Fixed(widthVal)) 241 242 Scenario.Guaranteed -> 243 MotionSpec.builder(config.defaultSpring, initialMapping = Mapping.Zero) 244 .toBreakpoint(min(left + minSize, right)) 245 .jumpTo(minSize, guarantee = guarantee) 246 .continueWithTargetValue(widthVal - minSize) 247 .toBreakpoint(right) 248 .completeWith(Mapping.Fixed(widthVal), guarantee = guarantee) 249 } 250 } 251 } 252 } 253 254 @Composable rememberDefaultConfignull255 override fun rememberDefaultConfig(): Config { 256 val defaultSpring = defaultSpatialSpring() 257 return remember(defaultSpring) { 258 Config( 259 defaultSpring, 260 boxWidth = 192.dp, 261 minVisibleWidth = 24.dp, 262 guaranteeDistance = 24.dp, 263 ) 264 } 265 } 266 267 override val visualizationInputRange: ClosedFloatingPointRange<Float> 268 get() = inputRange 269 270 @Composable ConfigUinull271 override fun ColumnScope.ConfigUi(config: Config, onConfigChanged: (Config) -> Unit) { 272 273 SpringParameterSection( 274 "spring", 275 config.defaultSpring, 276 { onConfigChanged(config.copy(defaultSpring = it)) }, 277 "spring", 278 modifier = Modifier.fillMaxWidth(), 279 ) 280 281 Text("Box Width") 282 DpSlider( 283 config.boxWidth, 284 { onConfigChanged(config.copy(boxWidth = it)) }, 285 valueRange = 48.dp..200.dp, 286 modifier = Modifier.fillMaxWidth(), 287 ) 288 289 Text("Min visible width") 290 DpSlider( 291 config.minVisibleWidth, 292 { onConfigChanged(config.copy(minVisibleWidth = it)) }, 293 valueRange = 0.dp..48.dp, 294 modifier = Modifier.fillMaxWidth(), 295 ) 296 297 Text("Guarantee Distance") 298 DpSlider( 299 config.guaranteeDistance, 300 { onConfigChanged(config.copy(guaranteeDistance = it)) }, 301 valueRange = 0.dp..200.dp, 302 modifier = Modifier.fillMaxWidth(), 303 ) 304 } 305 306 override val identifier: String = "GuaranteeBoxDemo" 307 } 308