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