• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2023 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.systemui.bouncer.ui.composable
18 
19 import androidx.annotation.VisibleForTesting
20 import androidx.compose.animation.core.Animatable
21 import androidx.compose.animation.core.AnimationVector1D
22 import androidx.compose.animation.core.tween
23 import androidx.compose.foundation.Canvas
24 import androidx.compose.foundation.gestures.awaitEachGesture
25 import androidx.compose.foundation.gestures.awaitFirstDown
26 import androidx.compose.foundation.gestures.detectDragGestures
27 import androidx.compose.foundation.layout.Box
28 import androidx.compose.foundation.layout.fillMaxWidth
29 import androidx.compose.foundation.layout.height
30 import androidx.compose.foundation.layout.width
31 import androidx.compose.material3.MaterialTheme
32 import androidx.compose.runtime.Composable
33 import androidx.compose.runtime.DisposableEffect
34 import androidx.compose.runtime.LaunchedEffect
35 import androidx.compose.runtime.getValue
36 import androidx.compose.runtime.mutableStateOf
37 import androidx.compose.runtime.remember
38 import androidx.compose.runtime.rememberCoroutineScope
39 import androidx.compose.runtime.setValue
40 import androidx.compose.ui.Alignment
41 import androidx.compose.ui.Modifier
42 import androidx.compose.ui.draw.clipToBounds
43 import androidx.compose.ui.geometry.Offset
44 import androidx.compose.ui.graphics.StrokeCap
45 import androidx.compose.ui.input.pointer.pointerInput
46 import androidx.compose.ui.layout.LayoutCoordinates
47 import androidx.compose.ui.layout.onGloballyPositioned
48 import androidx.compose.ui.platform.LocalDensity
49 import androidx.compose.ui.platform.LocalView
50 import androidx.compose.ui.res.integerResource
51 import androidx.compose.ui.unit.DpSize
52 import androidx.compose.ui.unit.dp
53 import androidx.lifecycle.compose.collectAsStateWithLifecycle
54 import com.android.compose.animation.Easings
55 import com.android.compose.modifiers.thenIf
56 import com.android.internal.R
57 import com.android.systemui.bouncer.ui.composable.MotionTestKeys.dotAppearFadeIn
58 import com.android.systemui.bouncer.ui.composable.MotionTestKeys.dotAppearMoveUp
59 import com.android.systemui.bouncer.ui.composable.MotionTestKeys.dotScaling
60 import com.android.systemui.bouncer.ui.composable.MotionTestKeys.entryCompleted
61 import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel
62 import com.android.systemui.bouncer.ui.viewmodel.PatternDotViewModel
63 import com.android.systemui.compose.modifiers.sysuiResTag
64 import kotlin.math.min
65 import kotlin.math.pow
66 import kotlin.math.sqrt
67 import kotlinx.coroutines.coroutineScope
68 import kotlinx.coroutines.launch
69 import platform.test.motion.compose.values.MotionTestValueKey
70 import platform.test.motion.compose.values.motionTestValues
71 
72 /**
73  * UI for the input part of a pattern-requiring version of the bouncer.
74  *
75  * The user can press, hold, and drag their pointer to select dots along a grid of dots.
76  *
77  * If [centerDotsVertically] is `true`, the dots should be centered along the axis of interest; if
78  * `false`, the dots will be pushed towards the end/bottom of the axis.
79  */
80 @Composable
81 @VisibleForTesting
82 fun PatternBouncer(
83     viewModel: PatternBouncerViewModel,
84     centerDotsVertically: Boolean,
85     modifier: Modifier = Modifier,
86 ) {
87     val scope = rememberCoroutineScope()
88     val density = LocalDensity.current
89     DisposableEffect(Unit) { onDispose { viewModel.onHidden() } }
90 
91     val colCount = viewModel.columnCount
92     val rowCount = viewModel.rowCount
93 
94     val dotColor = MaterialTheme.colorScheme.secondary
95     val dotRadius = with(density) { (DOT_DIAMETER_DP / 2).dp.toPx() }
96     val lineColor = MaterialTheme.colorScheme.primary
97     val lineStrokeWidth = with(density) { LINE_STROKE_WIDTH_DP.dp.toPx() }
98 
99     // All dots that should be rendered on the grid.
100     val dots: List<PatternDotViewModel> by viewModel.dots.collectAsStateWithLifecycle()
101     // The most recently selected dot, if the user is currently dragging.
102     val currentDot: PatternDotViewModel? by viewModel.currentDot.collectAsStateWithLifecycle()
103     // The dots selected so far, if the user is currently dragging.
104     val selectedDots: List<PatternDotViewModel> by
105         viewModel.selectedDots.collectAsStateWithLifecycle()
106     val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsStateWithLifecycle()
107     val isAnimationEnabled: Boolean by viewModel.isPatternVisible.collectAsStateWithLifecycle()
108     val animateFailure: Boolean by viewModel.animateFailure.collectAsStateWithLifecycle()
109 
110     // Map of animatables for the scale of each dot, keyed by dot.
111     val dotScalingAnimatables = remember(dots) { dots.associateWith { Animatable(1f) } }
112     // Map of animatables for the lines that connect between selected dots, keyed by the destination
113     // dot of the line.
114     val lineFadeOutAnimatables = remember(dots) { dots.associateWith { Animatable(1f) } }
115     val lineFadeOutAnimationDurationMs =
116         integerResource(R.integer.lock_pattern_line_fade_out_duration)
117     val lineFadeOutAnimationDelayMs = integerResource(R.integer.lock_pattern_line_fade_out_delay)
118 
119     val dotAppearFadeInAnimatables = remember(dots) { dots.associateWith { Animatable(0f) } }
120     val dotAppearMoveUpAnimatables = remember(dots) { dots.associateWith { Animatable(0f) } }
121     val dotAppearMaxOffsetPixels =
122         remember(dots) {
123             dots.associateWith { dot -> with(density) { (80 + (20 * dot.y)).dp.toPx() } }
124         }
125 
126     var entryAnimationCompleted by remember { mutableStateOf(false) }
127     LaunchedEffect(Unit) {
128         showEntryAnimation(dotAppearFadeInAnimatables, dotAppearMoveUpAnimatables)
129         entryAnimationCompleted = true
130     }
131 
132     val view = LocalView.current
133 
134     // When the current dot is changed, we need to update our animations.
135     LaunchedEffect(currentDot, isAnimationEnabled) {
136         // Perform haptic feedback, but only if the current dot is not null, so we don't perform it
137         // when the UI first shows up or when the user lifts their pointer/finger.
138         if (currentDot != null) {
139             viewModel.performDotFeedback(view)
140         }
141 
142         if (!isAnimationEnabled) {
143             return@LaunchedEffect
144         }
145 
146         // Make sure that the current dot is scaled up while the other dots are scaled back
147         // down.
148         dotScalingAnimatables.entries.forEach { (dot, animatable) ->
149             val isSelected = dot == currentDot
150             // Launch using the longer-lived scope because we want these animations to proceed to
151             // completion even if the LaunchedEffect is canceled because its key objects have
152             // changed.
153             scope.launch {
154                 if (isSelected) {
155                     animatable.animateTo(
156                         targetValue = (SELECTED_DOT_DIAMETER_DP / DOT_DIAMETER_DP.toFloat()),
157                         animationSpec =
158                             tween(
159                                 durationMillis = SELECTED_DOT_REACTION_ANIMATION_DURATION_MS,
160                                 easing = Easings.StandardAccelerate,
161                             ),
162                     )
163                 } else {
164                     animatable.animateTo(
165                         targetValue = 1f,
166                         animationSpec =
167                             tween(
168                                 durationMillis = SELECTED_DOT_RETRACT_ANIMATION_DURATION_MS,
169                                 easing = Easings.StandardDecelerate,
170                             ),
171                     )
172                 }
173             }
174         }
175 
176         selectedDots.forEach { dot ->
177             lineFadeOutAnimatables[dot]?.let { line ->
178                 if (!line.isRunning) {
179                     // Launch using the longer-lived scope because we want these animations to
180                     // proceed to completion even if the LaunchedEffect is canceled because its key
181                     // objects have changed.
182                     scope.launch {
183                         if (dot == currentDot) {
184                             // Reset the fade-out animation for the current dot. When the
185                             // current dot is switched, this entire code block runs again for
186                             // the newly selected dot.
187                             line.snapTo(1f)
188                         } else {
189                             // For all non-current dots, make sure that the lines are fading
190                             // out.
191                             line.animateTo(
192                                 targetValue = 0f,
193                                 animationSpec =
194                                     tween(
195                                         durationMillis = lineFadeOutAnimationDurationMs,
196                                         delayMillis = lineFadeOutAnimationDelayMs,
197                                     ),
198                             )
199                         }
200                     }
201                 }
202             }
203         }
204     }
205 
206     // Show the failure animation if the user entered the wrong input.
207     LaunchedEffect(animateFailure) {
208         if (animateFailure) {
209             showFailureAnimation(dots = dots, scalingAnimatables = dotScalingAnimatables)
210             viewModel.onFailureAnimationShown()
211         }
212     }
213 
214     // This is the position of the input pointer.
215     var inputPosition: Offset? by remember { mutableStateOf(null) }
216     var gridCoordinates: LayoutCoordinates? by remember { mutableStateOf(null) }
217     var offset: Offset by remember { mutableStateOf(Offset.Zero) }
218     var scale: Float by remember { mutableStateOf(1f) }
219     // This is the size of the drawing area, in dips.
220     val dotDrawingArea =
221         remember(colCount, rowCount) {
222             DpSize(
223                 // Because the width also includes spacing to the left and right of the leftmost and
224                 // rightmost dots in the grid and because UX mocks specify the width without that
225                 // spacing, the actual width needs to be defined slightly bigger than the UX mock
226                 // width.
227                 width = (262 * colCount / 2).dp,
228                 // Because the height also includes spacing above and below the topmost and
229                 // bottommost
230                 // dots in the grid and because UX mocks specify the height without that spacing,
231                 // the
232                 // actual height needs to be defined slightly bigger than the UX mock height.
233                 height = (262 * rowCount / 2).dp,
234             )
235         }
236 
237     Box(
238         modifier =
239             modifier.fillMaxWidth().thenIf(isInputEnabled) {
240                 Modifier.pointerInput(Unit) {
241                         awaitEachGesture {
242                             awaitFirstDown()
243                             viewModel.onDown()
244                         }
245                     }
246                     .pointerInput(Unit) {
247                         detectDragGestures(
248                             onDragStart = { start ->
249                                 inputPosition = start
250                                 viewModel.onDragStart()
251                             },
252                             onDragEnd = {
253                                 inputPosition = null
254                                 if (isAnimationEnabled) {
255                                     lineFadeOutAnimatables.values.forEach { animatable ->
256                                         // Launch using the longer-lived scope because we want these
257                                         // animations to proceed to completion even if the
258                                         // surrounding scope is canceled.
259                                         scope.launch { animatable.animateTo(1f) }
260                                     }
261                                 }
262                                 viewModel.onDragEnd()
263                             },
264                         ) { change, _ ->
265                             inputPosition = change.position
266                             change.position.minus(offset).div(scale).let {
267                                 viewModel.onDrag(
268                                     xPx =
269                                         it.x -
270                                             ((size.width - dotDrawingArea.width.roundToPx()) / 2),
271                                     yPx = it.y,
272                                     containerSizePx = dotDrawingArea.width.roundToPx(),
273                                 )
274                             }
275                         }
276                     }
277             }
278     ) {
279         Canvas(
280             Modifier.sysuiResTag("bouncer_pattern_root")
281                 .width(dotDrawingArea.width)
282                 .height(dotDrawingArea.height)
283                 // Need to clip to bounds to make sure that the lines don't follow the input pointer
284                 // when it leaves the bounds of the dot grid.
285                 .clipToBounds()
286                 .align(Alignment.Center)
287                 .onGloballyPositioned { coordinates -> gridCoordinates = coordinates }
288                 .motionTestValues {
289                     entryAnimationCompleted exportAs entryCompleted
290                     dotAppearFadeInAnimatables.map { it.value.value } exportAs dotAppearFadeIn
291                     dotAppearMoveUpAnimatables.map { it.value.value } exportAs dotAppearMoveUp
292                     dotScalingAnimatables.map { it.value.value } exportAs dotScaling
293                 }
294         ) {
295             gridCoordinates?.let { nonNullCoordinates ->
296                 val containerSize = nonNullCoordinates.size
297                 if (containerSize.width <= 0 || containerSize.height <= 0) {
298                     return@let
299                 }
300 
301                 val horizontalSpacing = containerSize.width.toFloat() / colCount
302                 val verticalSpacing = containerSize.height.toFloat() / rowCount
303                 val spacing = min(horizontalSpacing, verticalSpacing)
304                 val horizontalOffset =
305                     offset(
306                         availableSize = containerSize.width,
307                         spacingPerDot = spacing,
308                         dotCount = colCount,
309                         isCentered = true,
310                     )
311                 val verticalOffset =
312                     offset(
313                         availableSize = containerSize.height,
314                         spacingPerDot = spacing,
315                         dotCount = rowCount,
316                         isCentered = centerDotsVertically,
317                     )
318                 offset = Offset(horizontalOffset, verticalOffset)
319                 scale = (colCount * spacing) / containerSize.width
320 
321                 if (isAnimationEnabled) {
322                     // Draw lines between dots.
323                     selectedDots.forEachIndexed { index, dot ->
324                         if (index > 0) {
325                             val previousDot = selectedDots[index - 1]
326                             val lineFadeOutAnimationProgress =
327                                 lineFadeOutAnimatables[previousDot]!!.value
328                             val startLerp = 1 - lineFadeOutAnimationProgress
329                             val from =
330                                 pixelOffset(previousDot, spacing, horizontalOffset, verticalOffset)
331                             val to = pixelOffset(dot, spacing, horizontalOffset, verticalOffset)
332                             val lerpedFrom =
333                                 Offset(
334                                     x = from.x + (to.x - from.x) * startLerp,
335                                     y = from.y + (to.y - from.y) * startLerp,
336                                 )
337                             drawLine(
338                                 start = lerpedFrom,
339                                 end = to,
340                                 cap = StrokeCap.Round,
341                                 alpha = lineFadeOutAnimationProgress * lineAlpha(spacing),
342                                 color = lineColor,
343                                 strokeWidth = lineStrokeWidth,
344                             )
345                         }
346                     }
347 
348                     // Draw the line between the most recently-selected dot and the input pointer
349                     // position.
350                     inputPosition?.let { lineEnd ->
351                         currentDot?.let { dot ->
352                             val from = pixelOffset(dot, spacing, horizontalOffset, verticalOffset)
353                             val lineLength =
354                                 sqrt((from.y - lineEnd.y).pow(2) + (from.x - lineEnd.x).pow(2))
355                             drawLine(
356                                 start = from,
357                                 end = lineEnd,
358                                 cap = StrokeCap.Round,
359                                 alpha = lineAlpha(spacing, lineLength),
360                                 color = lineColor,
361                                 strokeWidth = lineStrokeWidth,
362                             )
363                         }
364                     }
365                 }
366 
367                 // Draw each dot on the grid.
368                 dots.forEach { dot ->
369                     val initialOffset = checkNotNull(dotAppearMaxOffsetPixels[dot])
370                     val appearOffset =
371                         (1 - checkNotNull(dotAppearMoveUpAnimatables[dot]).value) * initialOffset
372                     drawCircle(
373                         center =
374                             pixelOffset(
375                                 dot,
376                                 spacing,
377                                 horizontalOffset,
378                                 verticalOffset + appearOffset,
379                             ),
380                         color =
381                             dotColor.copy(
382                                 alpha = checkNotNull(dotAppearFadeInAnimatables[dot]).value
383                             ),
384                         radius = dotRadius * checkNotNull(dotScalingAnimatables[dot]).value,
385                     )
386                 }
387             }
388         }
389     }
390 }
391 
showEntryAnimationnull392 private suspend fun showEntryAnimation(
393     dotAppearFadeInAnimatables: Map<PatternDotViewModel, Animatable<Float, AnimationVector1D>>,
394     dotAppearMoveUpAnimatables: Map<PatternDotViewModel, Animatable<Float, AnimationVector1D>>,
395 ) {
396     coroutineScope {
397         dotAppearFadeInAnimatables.forEach { (dot, animatable) ->
398             launch {
399                 animatable.animateTo(
400                     targetValue = 1f,
401                     animationSpec =
402                         tween(
403                             delayMillis = 33 * dot.y,
404                             durationMillis = 450,
405                             easing = Easings.LegacyDecelerate,
406                         ),
407                 )
408             }
409         }
410         dotAppearMoveUpAnimatables.forEach { (dot, animatable) ->
411             launch {
412                 animatable.animateTo(
413                     targetValue = 1f,
414                     animationSpec =
415                         tween(
416                             delayMillis = 0,
417                             durationMillis = 450 + (33 * dot.y),
418                             easing = Easings.StandardDecelerate,
419                         ),
420                 )
421             }
422         }
423     }
424 }
425 
426 /** Returns an [Offset] representation of the given [dot], in pixel coordinates. */
pixelOffsetnull427 private fun pixelOffset(
428     dot: PatternDotViewModel,
429     spacing: Float,
430     horizontalOffset: Float,
431     verticalOffset: Float,
432 ): Offset {
433     return Offset(
434         x = dot.x * spacing + spacing / 2 + horizontalOffset,
435         y = dot.y * spacing + spacing / 2 + verticalOffset,
436     )
437 }
438 
439 /**
440  * Returns the alpha for a line between dots where dots are normally [gridSpacing] apart from each
441  * other on the dot grid and the line ends [lineLength] away from the origin dot.
442  *
443  * The reason [lineLength] can be different from [gridSpacing] is that all lines originate in dots
444  * but one line might end where the user input pointer is, which isn't always a dot position.
445  */
lineAlphanull446 private fun lineAlpha(gridSpacing: Float, lineLength: Float = gridSpacing): Float {
447     // Custom curve for the alpha of a line as a function of its distance from its source dot. The
448     // farther the user input pointer goes from the line, the more opaque the line gets.
449     return ((lineLength / gridSpacing - 0.3f) * 4f).coerceIn(0f, 1f)
450 }
451 
showFailureAnimationnull452 private suspend fun showFailureAnimation(
453     dots: List<PatternDotViewModel>,
454     scalingAnimatables: Map<PatternDotViewModel, Animatable<Float, AnimationVector1D>>,
455 ) {
456     val dotsByRow =
457         buildList<MutableList<PatternDotViewModel>> {
458             dots.forEach { dot ->
459                 val rowIndex = dot.y
460                 while (size <= rowIndex) {
461                     add(mutableListOf())
462                 }
463                 get(rowIndex).add(dot)
464             }
465         }
466 
467     coroutineScope {
468         dotsByRow.forEachIndexed { rowIndex, rowDots ->
469             rowDots.forEach { dot ->
470                 scalingAnimatables[dot]?.let { dotScaleAnimatable ->
471                     launch {
472                         dotScaleAnimatable.animateTo(
473                             targetValue =
474                                 FAILURE_ANIMATION_DOT_DIAMETER_DP / DOT_DIAMETER_DP.toFloat(),
475                             animationSpec =
476                                 tween(
477                                     durationMillis =
478                                         FAILURE_ANIMATION_DOT_SHRINK_ANIMATION_DURATION_MS,
479                                     delayMillis =
480                                         rowIndex * FAILURE_ANIMATION_DOT_SHRINK_STAGGER_DELAY_MS,
481                                     easing = Easings.Linear,
482                                 ),
483                         )
484 
485                         dotScaleAnimatable.animateTo(
486                             targetValue = 1f,
487                             animationSpec =
488                                 tween(
489                                     durationMillis =
490                                         FAILURE_ANIMATION_DOT_REVERT_ANIMATION_DURATION,
491                                     easing = Easings.Standard,
492                                 ),
493                         )
494                     }
495                 }
496             }
497         }
498     }
499 }
500 
501 /**
502  * Returns the amount of offset along the axis, in pixels, that should be applied to all dots.
503  *
504  * @param availableSize The size of the container, along the axis of interest.
505  * @param spacingPerDot The amount of pixels that each dot should take (including the area around
506  *   that dot).
507  * @param dotCount The number of dots along the axis (e.g. if the axis of interest is the
508  *   horizontal/x axis, this is the number of columns in the dot grid).
509  * @param isCentered Whether the dots should be centered along the axis of interest; if `false`, the
510  *   dots will be pushed towards to end/bottom of the axis.
511  */
offsetnull512 private fun offset(
513     availableSize: Int,
514     spacingPerDot: Float,
515     dotCount: Int,
516     isCentered: Boolean = false,
517 ): Float {
518     val default = availableSize - spacingPerDot * dotCount
519     return if (isCentered) {
520         default / 2
521     } else {
522         default
523     }
524 }
525 
526 private const val DOT_DIAMETER_DP = 14
527 private const val SELECTED_DOT_DIAMETER_DP = (DOT_DIAMETER_DP * 1.5).toInt()
528 private const val SELECTED_DOT_REACTION_ANIMATION_DURATION_MS = 83
529 private const val SELECTED_DOT_RETRACT_ANIMATION_DURATION_MS = 750
530 private const val LINE_STROKE_WIDTH_DP = 22
531 private const val FAILURE_ANIMATION_DOT_DIAMETER_DP = (DOT_DIAMETER_DP * 0.81f).toInt()
532 private const val FAILURE_ANIMATION_DOT_SHRINK_ANIMATION_DURATION_MS = 50
533 private const val FAILURE_ANIMATION_DOT_SHRINK_STAGGER_DELAY_MS = 33
534 private const val FAILURE_ANIMATION_DOT_REVERT_ANIMATION_DURATION = 617
535 
536 @VisibleForTesting
537 object MotionTestKeys {
538     val entryCompleted = MotionTestValueKey<Boolean>("PinBouncer::entryAnimationCompleted")
539     val dotAppearFadeIn = MotionTestValueKey<List<Float>>("PinBouncer::dotAppearFadeIn")
540     val dotAppearMoveUp = MotionTestValueKey<List<Float>>("PinBouncer::dotAppearMoveUp")
541     val dotScaling = MotionTestValueKey<List<Float>>("PinBouncer::dotScaling")
542 }
543