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 package com.android.mechanics.demo.staging.behavior.reveal
18
19 import android.util.Log
20 import androidx.compose.runtime.Composable
21 import androidx.compose.runtime.remember
22 import androidx.compose.ui.Modifier
23 import androidx.compose.ui.geometry.Rect
24 import androidx.compose.ui.graphics.CompositingStrategy
25 import androidx.compose.ui.layout.ApproachLayoutModifierNode
26 import androidx.compose.ui.layout.ApproachMeasureScope
27 import androidx.compose.ui.layout.LayoutCoordinates
28 import androidx.compose.ui.layout.Measurable
29 import androidx.compose.ui.layout.MeasureResult
30 import androidx.compose.ui.layout.MeasureScope
31 import androidx.compose.ui.layout.Placeable
32 import androidx.compose.ui.node.ModifierNodeElement
33 import androidx.compose.ui.node.findNearestAncestor
34 import androidx.compose.ui.node.requireDensity
35 import androidx.compose.ui.node.requireLayoutCoordinates
36 import androidx.compose.ui.unit.Constraints
37 import androidx.compose.ui.unit.Dp
38 import androidx.compose.ui.unit.IntOffset
39 import androidx.compose.ui.unit.IntSize
40 import androidx.compose.ui.unit.dp
41 import androidx.compose.ui.unit.toSize
42 import com.android.mechanics.MotionValue
43 import com.android.mechanics.debug.findMotionValueDebugger
44 import com.android.mechanics.demo.staging.behavior.reveal.RevealContainerNode.Companion.TRAVERSAL_NODE_KEY
45 import com.android.mechanics.demo.staging.defaultEffectSpring
46 import com.android.mechanics.spec.DirectionalMotionSpec
47 import com.android.mechanics.spec.Guarantee
48 import com.android.mechanics.spec.Mapping
49 import com.android.mechanics.spec.MotionSpec
50 import com.android.mechanics.spec.builder
51 import com.android.mechanics.spec.reverseBuilder
52 import com.android.mechanics.spring.SpringParameters
53 import kotlinx.coroutines.DisposableHandle
54 import kotlinx.coroutines.launch
55
56 @Composable
rememberFadeContentRevealSpecnull57 fun rememberFadeContentRevealSpec(
58 showSpring: SpringParameters = defaultEffectSpring(),
59 hideSpring: SpringParameters = defaultEffectSpring(),
60 showDelta: Dp = 0.dp,
61 hideDelta: Dp = 0.dp,
62 showGuarantee: Guarantee = Guarantee.None,
63 hideGuarantee: Guarantee = Guarantee.None,
64 ): FadeContentRevealSpec {
65 return remember(showSpring, hideSpring, showGuarantee, hideGuarantee) {
66 FadeContentRevealSpec(
67 showSpring,
68 hideSpring,
69 showDelta,
70 hideDelta,
71 showGuarantee,
72 hideGuarantee,
73 )
74 }
75 }
76
77 @Composable
fadeRevealnull78 fun Modifier.fadeReveal(
79 spec: FadeContentRevealSpec = rememberFadeContentRevealSpec(),
80 debug: Boolean = false,
81 label: String? = null,
82 ): Modifier = this.then(FadeContentRevealElement(spec, debug, label))
83
84 data class FadeContentRevealSpec(
85 val showSpring: SpringParameters,
86 val hideSpring: SpringParameters,
87 val showDelta: Dp,
88 val hideDelta: Dp,
89 val showGuarantee: Guarantee,
90 val hideGuarantee: Guarantee,
91 )
92
93 internal class FadeContentRevealNode(
94 private var spec: FadeContentRevealSpec,
95 private val debug: Boolean,
96 private val label: String?,
97 ) : Modifier.Node(), ApproachLayoutModifierNode {
98
99 private class AttachedState(
100 val revealContainerNode: RevealContainerNode,
101 val alphaValue: MotionValue,
102 val debugDisposer: DisposableHandle?,
103 )
104
105 private var attachedState: AttachedState? = null
106
107 fun updateSpec(spec: FadeContentRevealSpec) {
108 this.spec = spec
109 attachedState?.updateSpec(lookaheadTargetBounds)
110 }
111
112 private var lookaheadTargetBounds = Rect.Zero
113
114 private fun AttachedState.updateSpec(lookaheadBounds: Rect) {
115 with(requireDensity()) {
116 val showSpec =
117 DirectionalMotionSpec.builder(
118 initialMapping = Mapping.Zero,
119 defaultSpring = spec.showSpring,
120 )
121 .toBreakpoint(atPosition = lookaheadBounds.bottom + spec.showDelta.toPx())
122 .continueWith(Mapping.One, guarantee = spec.showGuarantee)
123 .complete()
124
125 val hideSpec =
126 DirectionalMotionSpec.reverseBuilder(
127 initialMapping = Mapping.One,
128 defaultSpring = spec.hideSpring,
129 )
130 .toBreakpoint(atPosition = lookaheadBounds.bottom + spec.hideDelta.toPx())
131 .continueWith(Mapping.Zero, guarantee = spec.hideGuarantee)
132 .complete()
133
134 alphaValue.spec = MotionSpec(maxDirection = showSpec, minDirection = hideSpec)
135 }
136 }
137
138 override fun onAttach() {
139 val revealContainerNode =
140 checkNotNull(findNearestAncestor(TRAVERSAL_NODE_KEY)) as RevealContainerNode
141
142 val alphaValue =
143 MotionValue(
144 currentInput = revealContainerNode::containerHeight,
145 gestureContext = revealContainerNode,
146 label = "FadeReveal($label)::alpha",
147 )
148
149 var debugDisposer: DisposableHandle? = null
150 if (debug) {
151 val motionValueDebugger = findMotionValueDebugger()
152 if (motionValueDebugger != null) {
153 debugDisposer = motionValueDebugger.register(alphaValue)
154 } else {
155 Log.w(TAG, "Debugging requested, but debugger not found.")
156 }
157 }
158
159 attachedState = AttachedState(revealContainerNode, alphaValue, debugDisposer)
160 coroutineScope.launch { alphaValue.keepRunning() }
161 }
162
163 override fun onDetach() {
164 attachedState?.debugDisposer?.dispose()
165 attachedState = null
166 }
167
168 override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean {
169 with(checkNotNull(attachedState)) {
170 return revealContainerNode.contentScope.layoutState.currentTransition != null ||
171 !alphaValue.isStable
172 }
173 }
174
175 override fun Placeable.PlacementScope.isPlacementApproachInProgress(
176 lookaheadCoordinates: LayoutCoordinates
177 ): Boolean {
178 with(checkNotNull(attachedState)) {
179 return revealContainerNode.contentScope.layoutState.currentTransition != null ||
180 !alphaValue.isStable
181 }
182 }
183
184 override fun MeasureScope.measure(
185 measurable: Measurable,
186 constraints: Constraints,
187 ): MeasureResult {
188 val placeable = measurable.measure(constraints)
189 return layout(placeable.width, placeable.height) {
190 if (isLookingAhead && coordinates != null) {
191
192 with(checkNotNull(attachedState)) {
193 lookaheadTargetBounds =
194 Rect(
195 with(revealContainerNode.contentScope.lookaheadScope) {
196 revealContainerNode
197 .requireLayoutCoordinates()
198 .localLookaheadPositionOf(coordinates!!)
199 },
200 coordinates!!.size.toSize(),
201 )
202 updateSpec(lookaheadTargetBounds)
203 }
204 }
205
206 placeable.place(0, 0)
207 }
208 }
209
210 override fun ApproachMeasureScope.approachMeasure(
211 measurable: Measurable,
212 constraints: Constraints,
213 ): MeasureResult {
214 return measurable.measure(constraints).run {
215 layout(width, height) {
216 val revealAlpha = checkNotNull(attachedState).alphaValue.output
217
218 if (revealAlpha < 1) {
219 placeWithLayer(IntOffset.Zero) {
220 alpha = revealAlpha.coerceAtLeast(0f)
221 compositingStrategy = CompositingStrategy.ModulateAlpha
222 }
223 } else {
224 place(IntOffset.Zero)
225 }
226 }
227 }
228 }
229
230 companion object {
231 const val TAG = "FadeReveal"
232 }
233 }
234
235 internal data class FadeContentRevealElement(
236 val spec: FadeContentRevealSpec,
237 val debug: Boolean,
238 val label: String?,
239 ) : ModifierNodeElement<FadeContentRevealNode>() {
createnull240 override fun create(): FadeContentRevealNode = FadeContentRevealNode(spec, debug, label)
241
242 override fun update(node: FadeContentRevealNode) {
243 node.updateSpec(spec)
244 }
245 }
246