1 /*
<lambda>null2  * Copyright 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.compose.foundation.demos
18 
19 import android.content.Context
20 import android.widget.EditText
21 import android.widget.HorizontalScrollView
22 import android.widget.LinearLayout
23 import androidx.compose.animation.core.LinearEasing
24 import androidx.compose.animation.core.animateFloat
25 import androidx.compose.animation.core.animateOffsetAsState
26 import androidx.compose.animation.core.animateRectAsState
27 import androidx.compose.animation.core.infiniteRepeatable
28 import androidx.compose.animation.core.rememberInfiniteTransition
29 import androidx.compose.animation.core.tween
30 import androidx.compose.foundation.Canvas
31 import androidx.compose.foundation.border
32 import androidx.compose.foundation.clickable
33 import androidx.compose.foundation.focusable
34 import androidx.compose.foundation.layout.Arrangement
35 import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
36 import androidx.compose.foundation.layout.Box
37 import androidx.compose.foundation.layout.Column
38 import androidx.compose.foundation.layout.Row
39 import androidx.compose.foundation.layout.Spacer
40 import androidx.compose.foundation.layout.fillMaxWidth
41 import androidx.compose.foundation.layout.padding
42 import androidx.compose.foundation.layout.size
43 import androidx.compose.foundation.layout.width
44 import androidx.compose.foundation.lazy.LazyRow
45 import androidx.compose.foundation.onFocusedBoundsChanged
46 import androidx.compose.foundation.rememberScrollState
47 import androidx.compose.foundation.shape.CircleShape
48 import androidx.compose.foundation.verticalScroll
49 import androidx.compose.material.Button
50 import androidx.compose.material.Divider
51 import androidx.compose.material.Text
52 import androidx.compose.material.TextField
53 import androidx.compose.runtime.Composable
54 import androidx.compose.runtime.getValue
55 import androidx.compose.runtime.mutableStateOf
56 import androidx.compose.runtime.remember
57 import androidx.compose.runtime.setValue
58 import androidx.compose.ui.Modifier
59 import androidx.compose.ui.composed
60 import androidx.compose.ui.draw.clip
61 import androidx.compose.ui.draw.drawWithContent
62 import androidx.compose.ui.focus.FocusRequester
63 import androidx.compose.ui.focus.focusRequester
64 import androidx.compose.ui.geometry.CornerRadius
65 import androidx.compose.ui.geometry.Offset
66 import androidx.compose.ui.geometry.Rect
67 import androidx.compose.ui.geometry.Size
68 import androidx.compose.ui.geometry.isSpecified
69 import androidx.compose.ui.graphics.Color
70 import androidx.compose.ui.graphics.PathEffect.Companion.dashPathEffect
71 import androidx.compose.ui.graphics.drawscope.Stroke
72 import androidx.compose.ui.layout.LayoutCoordinates
73 import androidx.compose.ui.layout.boundsInRoot
74 import androidx.compose.ui.layout.onGloballyPositioned
75 import androidx.compose.ui.platform.ComposeView
76 import androidx.compose.ui.platform.LocalDensity
77 import androidx.compose.ui.platform.LocalFocusManager
78 import androidx.compose.ui.tooling.preview.Preview
79 import androidx.compose.ui.unit.dp
80 import androidx.compose.ui.unit.toSize
81 import androidx.compose.ui.viewinterop.AndroidView
82 
83 @Preview
84 @Composable
85 fun FocusedBoundsDemo() {
86     // This demo demonstrates multiple observers with two separate observers:
87     // 1. A pair of eyeballs that look at the focused child.
88     FocusedBoundsObserver(
89         // 2. A "marching ants" highlight around the focused child.
90         Modifier.highlightFocusedBounds()
91     ) {
92         Column(
93             modifier = Modifier.verticalScroll(rememberScrollState()),
94             verticalArrangement = spacedBy(4.dp)
95         ) {
96             Text(
97                 "Click in the various text fields below, or the eyeballs above, to see the focus " +
98                     "area animate between them."
99             )
100             Divider()
101 
102             FocusableDemoContent()
103 
104             // TODO(b/220030968) This won't work until the API can be moved to the UI module.
105             Text("Android view (broken: b/220030968):")
106             AndroidView(
107                 ::FocusableAndroidViewDemo,
108                 Modifier.padding(4.dp).border(2.dp, Color.Green)
109             ) {
110                 it.setContent {
111                     Column(Modifier.padding(4.dp).border(2.dp, Color.Blue)) {
112                         Text("Compose again")
113                         FocusableDemoContent()
114                     }
115                 }
116             }
117         }
118     }
119 }
120 
121 @Composable
FocusableDemoContentnull122 private fun FocusableDemoContent() {
123     Column(verticalArrangement = spacedBy(4.dp)) {
124         val focusManager = LocalFocusManager.current
125         Button(onClick = { focusManager.clearFocus() }) { Text("Clear focus") }
126         TextField("", {}, Modifier.fillMaxWidth())
127         Text("Lazy row:")
128         LazyRow(
129             modifier = Modifier.padding(horizontal = 32.dp).border(2.dp, Color.Black),
130             horizontalArrangement = spacedBy(8.dp)
131         ) {
132             items(50) { index -> TextField(index.toString(), {}, Modifier.width(64.dp)) }
133         }
134     }
135 }
136 
137 private class FocusableAndroidViewDemo(context: Context) : LinearLayout(context) {
138     private val composeView = ComposeView(context)
139 
<lambda>null140     init {
141         orientation = VERTICAL
142         val fields =
143             LinearLayout(context).apply {
144                 orientation = HORIZONTAL
145                 repeat(50) { index ->
146                     addView(EditText(context).apply { setText(index.toString()) })
147                 }
148             }
149         val fieldRow = HorizontalScrollView(context).apply { addView(fields) }
150         addView(fieldRow)
151         addView(composeView)
152     }
153 
setContentnull154     fun setContent(content: @Composable () -> Unit) {
155         composeView.setContent(content)
156     }
157 }
158 
159 @Composable
FocusedBoundsObservernull160 private fun FocusedBoundsObserver(modifier: Modifier, content: @Composable () -> Unit) {
161     var coordinates: LayoutCoordinates? by remember { mutableStateOf(null) }
162     var focusedBounds: LayoutCoordinates? by remember { mutableStateOf(null) }
163     var myBounds by remember { mutableStateOf(Rect.Zero) }
164     var focalPoint by remember { mutableStateOf(Offset.Unspecified) }
165 
166     fun update() {
167         if (coordinates == null || !coordinates!!.isAttached) {
168             myBounds = Rect.Zero
169             focalPoint = Offset.Unspecified
170             return
171         }
172         if (focusedBounds == null) {
173             focalPoint = Offset.Unspecified
174             return
175         }
176         val rootCoordinates = generateSequence(coordinates) { it.parentCoordinates }.last()
177         myBounds = coordinates!!.boundsInRoot()
178         focalPoint = rootCoordinates.localBoundingBoxOf(focusedBounds!!, clipBounds = false).center
179     }
180 
181     Column(
182         modifier
183             .onGloballyPositioned {
184                 coordinates = it
185                 update()
186             }
187             .onFocusedBoundsChanged {
188                 focusedBounds = it
189                 update()
190             }
191     ) {
192         Row(
193             horizontalArrangement = Arrangement.Center,
194             modifier = Modifier.padding(8.dp).fillMaxWidth()
195         ) {
196             Eyeball(focalPoint, myBounds)
197             Spacer(Modifier.width(36.dp))
198             Eyeball(focalPoint, myBounds)
199         }
200         Box(propagateMinConstraints = true) { content() }
201     }
202 }
203 
204 @Composable
Eyeballnull205 private fun Eyeball(focalPoint: Offset, parentBounds: Rect) {
206     var myCenter by remember { mutableStateOf(Offset.Unspecified) }
207     var mySize by remember { mutableStateOf(Size.Unspecified) }
208     val targetPoint =
209         if (focalPoint.isSpecified && myCenter.isSpecified && mySize.isSpecified) {
210             val foo = focalPoint.minus(myCenter)
211             val maxDistanceX =
212                 maxOf(myCenter.x - parentBounds.left, parentBounds.width - myCenter.x)
213             val maxDistanceY =
214                 maxOf(myCenter.y - parentBounds.top, parentBounds.height - myCenter.y)
215             val maxDistance = maxOf(maxDistanceX, maxDistanceY)
216             val scaleFactor = (mySize.minDimension / 2) / maxDistance
217             foo.times(scaleFactor)
218         } else {
219             Offset.Zero
220         }
221     val animatedTargetPoint by animateOffsetAsState(targetPoint)
222     val focusRequester = remember { FocusRequester() }
223 
224     Canvas(
225         Modifier.size(24.dp)
226             .onGloballyPositioned {
227                 myCenter = it.boundsInRoot().center
228                 mySize = it.size.toSize()
229             }
230             .clip(CircleShape)
231             // Make the eyeballs focusable, just for fun.
232             .clickable { focusRequester.requestFocus() }
233             .focusRequester(focusRequester)
234             .focusable()
235     ) {
236         drawCircle(Color.White)
237         drawCircle(Color.Black, style = Stroke(1.dp.toPx()))
238 
239         val pupilCenter = center + animatedTargetPoint
240         val pupilRadius = size.minDimension / 4f
241         drawCircle(Color.Black, center = pupilCenter, radius = pupilRadius)
242         drawCircle(
243             Color.White,
244             center = pupilCenter - (Offset(pupilRadius / 2, pupilRadius / 2)),
245             radius = pupilRadius / 3
246         )
247     }
248 }
249 
<lambda>null250 private fun Modifier.highlightFocusedBounds() = composed {
251     var coordinates: LayoutCoordinates? by remember { mutableStateOf(null) }
252     var focusedChild: LayoutCoordinates? by remember { mutableStateOf(null) }
253     var focusedBounds by remember { mutableStateOf(Rect.Zero) }
254     var focusedBoundsClipped by remember { mutableStateOf(Rect.Zero) }
255     val density = LocalDensity.current
256 
257     fun update() {
258         with(density) {
259             focusedBounds =
260                 calculateHighlightBounds(focusedChild, coordinates, clipBounds = false)
261                     .inflate(1.dp.toPx())
262             focusedBoundsClipped =
263                 calculateHighlightBounds(focusedChild, coordinates, clipBounds = true)
264                     .inflate(1.dp.toPx())
265         }
266     }
267 
268     Modifier.onGloballyPositioned {
269             coordinates = it
270             update()
271         }
272         .onFocusedBoundsChanged {
273             focusedChild = it
274             update()
275         }
276         .drawAnimatedFocusHighlight(focusedBoundsClipped, focusedBounds)
277 }
278 
calculateHighlightBoundsnull279 private fun calculateHighlightBounds(
280     child: LayoutCoordinates?,
281     coordinates: LayoutCoordinates?,
282     clipBounds: Boolean,
283 ): Rect {
284     if (coordinates == null || !coordinates.isAttached) return Rect.Zero
285     return child?.let { coordinates.localBoundingBoxOf(it, clipBounds) }
286         ?: coordinates.localBoundingBoxOf(coordinates)
287 }
288 
drawAnimatedFocusHighlightnull289 private fun Modifier.drawAnimatedFocusHighlight(
290     primaryBounds: Rect,
291     secondaryBounds: Rect
292 ): Modifier = composed {
293     val animatedPrimaryBounds by animateRectAsState(primaryBounds)
294     val animatedSecondaryBounds by animateRectAsState(secondaryBounds)
295     val strokeDashes = remember { floatArrayOf(10f, 10f) }
296     val strokeDashPhase by
297         rememberInfiniteTransition()
298             .animateFloat(0f, 20f, infiniteRepeatable(tween(500, easing = LinearEasing)))
299 
300     drawWithContent {
301         drawContent()
302 
303         if (
304             animatedSecondaryBounds != Rect.Zero && animatedSecondaryBounds != animatedPrimaryBounds
305         ) {
306             drawRoundRect(
307                 color = Color.LightGray,
308                 alpha = 0.5f,
309                 topLeft = animatedSecondaryBounds.topLeft,
310                 size = animatedSecondaryBounds.size,
311                 cornerRadius = CornerRadius(4.dp.toPx(), 4.dp.toPx()),
312                 style =
313                     Stroke(
314                         width = 3.dp.toPx(),
315                         pathEffect = dashPathEffect(strokeDashes, strokeDashPhase)
316                     )
317             )
318         }
319 
320         // Draw the primary bounds on top so it's always visible.
321         if (animatedPrimaryBounds != Rect.Zero) {
322             drawRoundRect(
323                 color = Color.Blue,
324                 alpha = 0.5f,
325                 topLeft = animatedPrimaryBounds.topLeft,
326                 size = animatedPrimaryBounds.size,
327                 cornerRadius = CornerRadius(4.dp.toPx(), 4.dp.toPx()),
328                 style =
329                     Stroke(
330                         width = 3.dp.toPx(),
331                         pathEffect = dashPathEffect(strokeDashes, strokeDashPhase)
332                     )
333             )
334         }
335     }
336 }
337