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