1 /* <lambda>null2 * Copyright (C) 2022 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 package androidx.constraintlayout.compose 18 19 import android.graphics.Matrix 20 import androidx.compose.ui.geometry.Offset 21 import androidx.compose.ui.geometry.Size 22 import androidx.compose.ui.graphics.Color 23 import androidx.compose.ui.graphics.Path 24 import androidx.compose.ui.graphics.PathEffect 25 import androidx.compose.ui.graphics.drawscope.DrawScope 26 import androidx.compose.ui.graphics.drawscope.Stroke 27 import androidx.compose.ui.graphics.drawscope.translate 28 import androidx.compose.ui.graphics.nativeCanvas 29 import androidx.compose.ui.layout.Measurable 30 import androidx.compose.ui.layout.Placeable 31 import androidx.compose.ui.layout.layoutId 32 import androidx.compose.ui.unit.Constraints 33 import androidx.compose.ui.unit.Density 34 import androidx.compose.ui.unit.IntSize 35 import androidx.compose.ui.unit.LayoutDirection 36 import androidx.compose.ui.unit.dp 37 import androidx.compose.ui.util.fastForEach 38 import androidx.constraintlayout.core.motion.Motion 39 import androidx.constraintlayout.core.state.Dimension 40 import androidx.constraintlayout.core.state.Transition 41 import androidx.constraintlayout.core.state.WidgetFrame 42 import androidx.constraintlayout.core.widgets.Optimizer 43 44 @ExperimentalMotionApi 45 internal class MotionMeasurer(density: Density) : Measurer2(density) { 46 private val DEBUG = false 47 private var lastProgressInInterpolation = 0f 48 val transition = Transition { with(density) { it.dp.toPx() } } 49 50 // TODO: Explicitly declare `getDesignInfo` so that studio tooling can identify the method, also 51 // make sure that the constraints/dimensions returned are for the start/current ConstraintSet 52 53 private fun measureConstraintSet( 54 optimizationLevel: Int, 55 constraintSet: ConstraintSet, 56 measurables: List<Measurable>, 57 constraints: Constraints 58 ) { 59 state.reset() 60 constraintSet.applyTo(state, measurables) 61 buildMapping(state, measurables) 62 state.apply(root) 63 root.children.fastForEach { it.isAnimated = true } 64 applyRootSize(constraints) 65 root.updateHierarchy() 66 67 if (DEBUG) { 68 root.debugName = "ConstraintLayout" 69 root.children.fastForEach { child -> 70 child.debugName = 71 (child.companionWidget as? Measurable)?.layoutId?.toString() ?: "NOTAG" 72 } 73 } 74 75 root.optimizationLevel = optimizationLevel 76 // No need to set sizes and size modes as we passed them to the state above. 77 root.measure(Optimizer.OPTIMIZATION_NONE, 0, 0, 0, 0, 0, 0, 0, 0) 78 } 79 80 @Suppress("UnavailableSymbol") 81 fun performInterpolationMeasure( 82 constraints: Constraints, 83 layoutDirection: LayoutDirection, 84 constraintSetStart: ConstraintSet, 85 constraintSetEnd: ConstraintSet, 86 @SuppressWarnings("HiddenTypeParameter") transition: TransitionImpl, 87 measurables: List<Measurable>, 88 placeableMap: MutableMap<Measurable, Placeable>, // Initialized by caller, filled by us 89 optimizationLevel: Int, 90 progress: Float, 91 compositionSource: CompositionSource, 92 invalidateOnConstraintsCallback: ShouldInvalidateCallback? 93 ): IntSize { 94 placeables = placeableMap 95 val needsRemeasure = 96 needsRemeasure( 97 constraints = constraints, 98 source = compositionSource, 99 invalidateOnConstraintsCallback = invalidateOnConstraintsCallback 100 ) 101 102 if ( 103 lastProgressInInterpolation != progress || 104 (layoutInformationReceiver?.getForcedWidth() != Int.MIN_VALUE && 105 layoutInformationReceiver?.getForcedHeight() != Int.MIN_VALUE) || 106 needsRemeasure 107 ) { 108 recalculateInterpolation( 109 constraints = constraints, 110 layoutDirection = layoutDirection, 111 constraintSetStart = constraintSetStart, 112 constraintSetEnd = constraintSetEnd, 113 transition = transition, 114 measurables = measurables, 115 optimizationLevel = optimizationLevel, 116 progress = progress, 117 remeasure = needsRemeasure 118 ) 119 } 120 oldConstraints = constraints 121 return IntSize(root.width, root.height) 122 } 123 124 /** 125 * Nullable reference of [Constraints] used for the `invalidateOnConstraintsCallback`. 126 * 127 * Helps us to indicate when we can start calling the callback, as we need at least one measure 128 * pass to populate this reference. 129 */ 130 private var oldConstraints: Constraints? = null 131 132 /** 133 * Indicates if the layout requires measuring before computing the interpolation. 134 * 135 * This might happen if the size of MotionLayout or any of its children changed. 136 * 137 * MotionLayout size might change from its parent Layout, and in some cases the children size 138 * might change (eg: A Text layout has a longer string appended). 139 */ 140 private fun needsRemeasure( 141 constraints: Constraints, 142 source: CompositionSource, 143 invalidateOnConstraintsCallback: ShouldInvalidateCallback? 144 ): Boolean { 145 if (this.transition.isEmpty || frameCache.isEmpty()) { 146 // Nothing measured (by MotionMeasurer) 147 return true 148 } 149 150 if (oldConstraints != null && invalidateOnConstraintsCallback != null) { 151 // User is deciding when to invalidate on measuring constraints 152 if (invalidateOnConstraintsCallback(oldConstraints!!, constraints)) { 153 return true 154 } 155 } else { 156 // Default behavior, only take this path if there's no user logic to invalidate 157 if ( 158 (constraints.hasFixedHeight && !state.sameFixedHeight(constraints.maxHeight)) || 159 (constraints.hasFixedWidth && !state.sameFixedWidth(constraints.maxWidth)) 160 ) { 161 // Layout size changed 162 return true 163 } 164 } 165 166 // Content recomposed. Or marked as such by InvalidationStrategy.onObservedStateChange. 167 return source == CompositionSource.Content 168 } 169 170 /** 171 * Remeasures based on [constraintSetStart] and [constraintSetEnd] if needed. 172 * 173 * Runs the interpolation for the given [progress]. 174 * 175 * Finally, updates the [Measurable]s dimension if they changed during interpolation. 176 */ 177 private fun recalculateInterpolation( 178 constraints: Constraints, 179 layoutDirection: LayoutDirection, 180 constraintSetStart: ConstraintSet, 181 constraintSetEnd: ConstraintSet, 182 transition: TransitionImpl?, 183 measurables: List<Measurable>, 184 optimizationLevel: Int, 185 progress: Float, 186 remeasure: Boolean 187 ) { 188 lastProgressInInterpolation = progress 189 if (remeasure) { 190 this.transition.clear() 191 resetMeasureState() 192 // Define the size of the ConstraintLayout. 193 state.width( 194 if (constraints.hasFixedWidth) { 195 Dimension.createFixed(constraints.maxWidth) 196 } else { 197 Dimension.createWrap().min(constraints.minWidth) 198 } 199 ) 200 state.height( 201 if (constraints.hasFixedHeight) { 202 Dimension.createFixed(constraints.maxHeight) 203 } else { 204 Dimension.createWrap().min(constraints.minHeight) 205 } 206 ) 207 // Build constraint set and apply it to the state. 208 state.rootIncomingConstraints = constraints 209 state.isRtl = layoutDirection == LayoutDirection.Rtl 210 211 measureConstraintSet(optimizationLevel, constraintSetStart, measurables, constraints) 212 this.transition.updateFrom(root, Transition.START) 213 measureConstraintSet(optimizationLevel, constraintSetEnd, measurables, constraints) 214 this.transition.updateFrom(root, Transition.END) 215 transition?.applyKeyFramesTo(this.transition) 216 } else { 217 // Have to remap even if there's no reason to remeasure 218 buildMapping(state, measurables) 219 } 220 this.transition.interpolate(root.width, root.height, progress) 221 root.width = this.transition.interpolatedWidth 222 root.height = this.transition.interpolatedHeight 223 // Update measurables to interpolated dimensions 224 root.children.fastForEach { child -> 225 // Update measurables to the interpolated dimensions 226 val measurable = (child.companionWidget as? Measurable) ?: return@fastForEach 227 val interpolatedFrame = this.transition.getInterpolated(child) ?: return@fastForEach 228 placeables[measurable] = 229 measurable.measure( 230 Constraints.fixed(interpolatedFrame.width(), interpolatedFrame.height()) 231 ) 232 frameCache[measurable.anyOrNullId] = interpolatedFrame 233 } 234 235 if (layoutInformationReceiver?.getLayoutInformationMode() == LayoutInfoFlags.BOUNDS) { 236 computeLayoutResult() 237 } 238 } 239 240 private fun encodeKeyFrames( 241 json: StringBuilder, 242 location: FloatArray, 243 types: IntArray, 244 progress: IntArray, 245 count: Int 246 ) { 247 if (count == 0) { 248 return 249 } 250 json.append("keyTypes : [") 251 for (i in 0 until count) { 252 val m = types[i] 253 json.append(" $m,") 254 } 255 json.append("],\n") 256 257 json.append("keyPos : [") 258 for (i in 0 until count * 2) { 259 val f = location[i] 260 json.append(" $f,") 261 } 262 json.append("],\n ") 263 264 json.append("keyFrames : [") 265 for (i in 0 until count) { 266 val f = progress[i] 267 json.append(" $f,") 268 } 269 json.append("],\n ") 270 } 271 272 fun encodeRoot(json: StringBuilder) { 273 json.append(" root: {") 274 json.append("interpolated: { left: 0,") 275 json.append(" top: 0,") 276 json.append(" right: ${root.width} ,") 277 json.append(" bottom: ${root.height} ,") 278 json.append(" } }") 279 } 280 281 override fun computeLayoutResult() { 282 val json = StringBuilder() 283 json.append("{ ") 284 encodeRoot(json) 285 val mode = IntArray(50) 286 val pos = IntArray(50) 287 val key = FloatArray(100) 288 289 root.children.fastForEach { child -> 290 val start = transition.getStart(child.stringId) 291 val end = transition.getEnd(child.stringId) 292 val interpolated = transition.getInterpolated(child.stringId) 293 val path = transition.getPath(child.stringId) 294 val count = transition.getKeyFrames(child.stringId, key, mode, pos) 295 296 json.append(" ${child.stringId}: {") 297 json.append(" interpolated : ") 298 interpolated.serialize(json, true) 299 300 json.append(", start : ") 301 start.serialize(json) 302 303 json.append(", end : ") 304 end.serialize(json) 305 encodeKeyFrames(json, key, mode, pos, count) 306 json.append(" path : [") 307 for (point in path) { 308 json.append(" $point ,") 309 } 310 json.append(" ] ") 311 json.append("}, ") 312 } 313 json.append(" }") 314 layoutInformationReceiver?.setLayoutInformation(json.toString()) 315 } 316 317 /** 318 * Draws debug information related to the current Transition. 319 * 320 * Typically, this means drawing the bounds of each widget at the start/end positions, the path 321 * they take and indicators for KeyPositions. 322 */ 323 fun DrawScope.drawDebug( 324 drawBounds: Boolean = true, 325 drawPaths: Boolean = true, 326 drawKeyPositions: Boolean = true, 327 ) { 328 val pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) 329 330 root.children.fastForEach { child -> 331 val startFrame = transition.getStart(child) 332 val endFrame = transition.getEnd(child) 333 if (drawBounds) { 334 // Draw widget bounds at the start and end 335 drawFrame(frame = startFrame, pathEffect = pathEffect, color = Color.Blue) 336 drawFrame(frame = endFrame, pathEffect = pathEffect, color = Color.Blue) 337 translate(2f, 2f) { 338 // Do an additional offset draw in case the bounds are not visible/obstructed 339 drawFrame(frame = startFrame, pathEffect = pathEffect, color = Color.White) 340 drawFrame(frame = endFrame, pathEffect = pathEffect, color = Color.White) 341 } 342 } 343 drawPaths( 344 parentWidth = size.width, 345 parentHeight = size.height, 346 startFrame = startFrame, 347 drawPath = drawPaths, 348 drawKeyPositions = drawKeyPositions 349 ) 350 } 351 } 352 353 private fun DrawScope.drawPaths( 354 parentWidth: Float, 355 parentHeight: Float, 356 startFrame: WidgetFrame, 357 drawPath: Boolean, 358 drawKeyPositions: Boolean 359 ) { 360 val debugRender = MotionRenderDebug(23f) 361 debugRender.basicDraw( 362 drawContext.canvas.nativeCanvas, 363 transition.getMotion(startFrame.widget.stringId), 364 1000, 365 parentWidth.toInt(), 366 parentHeight.toInt(), 367 drawPath, 368 drawKeyPositions 369 ) 370 } 371 372 private fun DrawScope.drawFrameDebug( 373 parentWidth: Float, 374 parentHeight: Float, 375 startFrame: WidgetFrame, 376 endFrame: WidgetFrame, 377 pathEffect: PathEffect, 378 color: Color 379 ) { 380 drawFrame(startFrame, pathEffect, color) 381 drawFrame(endFrame, pathEffect, color) 382 val numKeyPositions = transition.getNumberKeyPositions(startFrame) 383 val debugRender = MotionRenderDebug(23f) 384 385 debugRender.draw( 386 drawContext.canvas.nativeCanvas, 387 transition.getMotion(startFrame.widget.stringId), 388 1000, 389 Motion.DRAW_PATH_BASIC, 390 parentWidth.toInt(), 391 parentHeight.toInt() 392 ) 393 if (numKeyPositions == 0) { 394 // drawLine( 395 // start = Offset(startFrame.centerX(), startFrame.centerY()), 396 // end = Offset(endFrame.centerX(), endFrame.centerY()), 397 // color = color, 398 // strokeWidth = 3f, 399 // pathEffect = pathEffect 400 // ) 401 } else { 402 val x = FloatArray(numKeyPositions) 403 val y = FloatArray(numKeyPositions) 404 val pos = FloatArray(numKeyPositions) 405 transition.fillKeyPositions(startFrame, x, y, pos) 406 407 for (i in 0..numKeyPositions - 1) { 408 val keyFrameProgress = pos[i] / 100f 409 val frameWidth = 410 ((1 - keyFrameProgress) * startFrame.width()) + 411 (keyFrameProgress * endFrame.width()) 412 val frameHeight = 413 ((1 - keyFrameProgress) * startFrame.height()) + 414 (keyFrameProgress * endFrame.height()) 415 val curX = x[i] * parentWidth + frameWidth / 2f 416 val curY = y[i] * parentHeight + frameHeight / 2f 417 // drawLine( 418 // start = Offset(prex, prey), 419 // end = Offset(curX, curY), 420 // color = color, 421 // strokeWidth = 3f, 422 // pathEffect = pathEffect 423 // ) 424 val path = Path() 425 val pathSize = 20f 426 path.moveTo(curX - pathSize, curY) 427 path.lineTo(curX, curY + pathSize) 428 path.lineTo(curX + pathSize, curY) 429 path.lineTo(curX, curY - pathSize) 430 path.close() 431 432 val stroke = Stroke(width = 3f) 433 drawPath(path, color, 1f, stroke) 434 } 435 // drawLine( 436 // start = Offset(prex, prey), 437 // end = Offset(endFrame.centerX(), endFrame.centerY()), 438 // color = color, 439 // strokeWidth = 3f, 440 // pathEffect = pathEffect 441 // ) 442 } 443 } 444 445 private fun DrawScope.drawFrame(frame: WidgetFrame, pathEffect: PathEffect, color: Color) { 446 if (frame.isDefaultTransform) { 447 val drawStyle = Stroke(width = 3f, pathEffect = pathEffect) 448 drawRect( 449 color, 450 Offset(frame.left.toFloat(), frame.top.toFloat()), 451 Size(frame.width().toFloat(), frame.height().toFloat()), 452 style = drawStyle 453 ) 454 } else { 455 val matrix = Matrix() 456 if (!frame.rotationZ.isNaN()) { 457 matrix.preRotate(frame.rotationZ, frame.centerX(), frame.centerY()) 458 } 459 val scaleX = if (frame.scaleX.isNaN()) 1f else frame.scaleX 460 val scaleY = if (frame.scaleY.isNaN()) 1f else frame.scaleY 461 matrix.preScale(scaleX, scaleY, frame.centerX(), frame.centerY()) 462 val points = 463 floatArrayOf( 464 frame.left.toFloat(), 465 frame.top.toFloat(), 466 frame.right.toFloat(), 467 frame.top.toFloat(), 468 frame.right.toFloat(), 469 frame.bottom.toFloat(), 470 frame.left.toFloat(), 471 frame.bottom.toFloat() 472 ) 473 matrix.mapPoints(points) 474 drawLine( 475 start = Offset(points[0], points[1]), 476 end = Offset(points[2], points[3]), 477 color = color, 478 strokeWidth = 3f, 479 pathEffect = pathEffect 480 ) 481 drawLine( 482 start = Offset(points[2], points[3]), 483 end = Offset(points[4], points[5]), 484 color = color, 485 strokeWidth = 3f, 486 pathEffect = pathEffect 487 ) 488 drawLine( 489 start = Offset(points[4], points[5]), 490 end = Offset(points[6], points[7]), 491 color = color, 492 strokeWidth = 3f, 493 pathEffect = pathEffect 494 ) 495 drawLine( 496 start = Offset(points[6], points[7]), 497 end = Offset(points[0], points[1]), 498 color = color, 499 strokeWidth = 3f, 500 pathEffect = pathEffect 501 ) 502 } 503 } 504 505 /** 506 * Calculates and returns a [Color] value of the custom property given by [name] on the 507 * ConstraintWidget corresponding to [id], the value is calculated at the given [progress] value 508 * on the current Transition. 509 * 510 * Returns [Color.Unspecified] if the custom property doesn't exist. 511 */ 512 fun getCustomColor(id: String, name: String, progress: Float): Color { 513 if (!transition.contains(id)) { 514 return Color.Unspecified 515 } 516 transition.interpolate(root.width, root.height, progress) 517 518 val interpolatedFrame = transition.getInterpolated(id) 519 520 if (!interpolatedFrame.containsCustom(name)) { 521 return Color.Unspecified 522 } 523 return Color(interpolatedFrame.getCustomColor(name)) 524 } 525 526 /** 527 * Calculates and returns a [Float] value of the custom property given by [name] on the 528 * ConstraintWidget corresponding to [id], the value is calculated at the given [progress] value 529 * on the current Transition. 530 * 531 * Returns [Float.NaN] if the custom property doesn't exist. 532 */ 533 fun getCustomFloat(id: String, name: String, progress: Float): Float { 534 if (!transition.contains(id)) { 535 return Float.NaN 536 } 537 transition.interpolate(root.width, root.height, progress) 538 539 val interpolatedFrame = transition.getInterpolated(id) 540 return interpolatedFrame.getCustomFloat(name) 541 } 542 543 fun clearConstraintSets() { 544 transition.clear() 545 frameCache.clear() 546 } 547 548 @Suppress("UnavailableSymbol") 549 fun initWith( 550 start: ConstraintSet, 551 end: ConstraintSet, 552 layoutDirection: LayoutDirection, 553 @SuppressWarnings("HiddenTypeParameter") transition: TransitionImpl, 554 progress: Float 555 ) { 556 clearConstraintSets() 557 558 state.isRtl = layoutDirection == LayoutDirection.Rtl 559 start.applyTo(state, emptyList()) 560 start.applyTo(this.transition, Transition.START) 561 state.apply(root) 562 this.transition.updateFrom(root, Transition.START) 563 564 start.applyTo(state, emptyList()) 565 end.applyTo(this.transition, Transition.END) 566 state.apply(root) 567 this.transition.updateFrom(root, Transition.END) 568 569 this.transition.interpolate(0, 0, progress) 570 transition.applyAllTo(this.transition) 571 } 572 } 573 574 /** 575 * Functional interface to represent the callback of type `(old: Constraints, new: Constraints) -> 576 * Boolean` 577 */ 578 internal fun interface ShouldInvalidateCallback { invokenull579 operator fun invoke(old: Constraints, new: Constraints): Boolean 580 } 581