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