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