Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 19ec5dc4 authored by Michał Brzeziński's avatar Michał Brzeziński Committed by Android (Google) Code Review
Browse files

Merge changes I7331f8bb,I6ddcd605 into main

* changes:
  Removing GestureRecognizerProvider
  Preparing composable to handle live gesture progress in tutorial
parents 6aec1838 81c6cdc7
Loading
Loading
Loading
Loading
+5 −128
Original line number Diff line number Diff line
@@ -16,24 +16,11 @@

package com.android.systemui.inputdevice.tutorial.ui.composable

import android.graphics.ColorFilter
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import androidx.annotation.RawRes
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -46,29 +33,20 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.airbnb.lottie.LottieProperty
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.LottieDynamicProperties
import com.airbnb.lottie.compose.LottieDynamicProperty
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.airbnb.lottie.compose.rememberLottieDynamicProperty
import com.android.systemui.inputdevice.tutorial.ui.composable.TutorialActionState.Finished
import com.android.systemui.inputdevice.tutorial.ui.composable.TutorialActionState.InProgress
import com.android.systemui.inputdevice.tutorial.ui.composable.TutorialActionState.NotStarted

sealed interface TutorialActionState {
    data object NotStarted : TutorialActionState

    data class InProgress(val progress: Float = 0f) : TutorialActionState
    data class InProgress(
        val progress: Float = 0f,
        val startMarker: String? = null,
        val endMarker: String? = null,
    ) : TutorialActionState

    data object Finished : TutorialActionState
}
@@ -132,104 +110,3 @@ fun TutorialDescription(
        )
    }
}

@Composable
fun TutorialAnimation(
    actionState: TutorialActionState,
    config: TutorialScreenConfig,
    modifier: Modifier = Modifier,
) {
    Box(modifier = modifier.fillMaxWidth()) {
        AnimatedContent(
            targetState = actionState,
            transitionSpec = {
                if (initialState == NotStarted) {
                    val transitionDurationMillis = 150
                    fadeIn(animationSpec = tween(transitionDurationMillis, easing = LinearEasing))
                        .togetherWith(
                            fadeOut(animationSpec = snap(delayMillis = transitionDurationMillis))
                        )
                        // we explicitly don't want size transform because when targetState
                        // animation is loaded for the first time, AnimatedContent thinks target
                        // size is smaller and tries to shrink initial state animation
                        .using(sizeTransform = null)
                } else {
                    // empty transition works because all remaining transitions are from IN_PROGRESS
                    // state which shares initial animation frame with both FINISHED and NOT_STARTED
                    EnterTransition.None togetherWith ExitTransition.None
                }
            },
        ) { state ->
            when (state) {
                NotStarted ->
                    EducationAnimation(
                        config.animations.educationResId,
                        config.colors.animationColors,
                    )
                is InProgress ->
                    FrozenSuccessAnimation(
                        config.animations.successResId,
                        config.colors.animationColors,
                    )
                Finished ->
                    SuccessAnimation(config.animations.successResId, config.colors.animationColors)
            }
        }
    }
}

@Composable
private fun FrozenSuccessAnimation(
    @RawRes successAnimationId: Int,
    animationProperties: LottieDynamicProperties,
) {
    val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(successAnimationId))
    LottieAnimation(
        composition = composition,
        progress = { 0f }, // animation should freeze on 1st frame
        dynamicProperties = animationProperties,
    )
}

@Composable
private fun EducationAnimation(
    @RawRes educationAnimationId: Int,
    animationProperties: LottieDynamicProperties,
) {
    val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(educationAnimationId))
    val progress by
        animateLottieCompositionAsState(composition, iterations = LottieConstants.IterateForever)
    LottieAnimation(
        composition = composition,
        progress = { progress },
        dynamicProperties = animationProperties,
    )
}

@Composable
private fun SuccessAnimation(
    @RawRes successAnimationId: Int,
    animationProperties: LottieDynamicProperties,
) {
    val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(successAnimationId))
    val progress by animateLottieCompositionAsState(composition, iterations = 1)
    LottieAnimation(
        composition = composition,
        progress = { progress },
        dynamicProperties = animationProperties,
    )
}

@Composable
fun rememberColorFilterProperty(
    layerName: String,
    color: Color,
): LottieDynamicProperty<ColorFilter> {
    return rememberLottieDynamicProperty(
        LottieProperty.COLOR_FILTER,
        value = PorterDuffColorFilter(color.toArgb(), PorterDuff.Mode.SRC_ATOP),
        // "**" below means match zero or more layers, so ** layerName ** means find layer with that
        // name at any depth
        keyPath = arrayOf("**", layerName, "**"),
    )
}
+41 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.inputdevice.tutorial.ui.composable

import android.graphics.ColorFilter
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.airbnb.lottie.LottieProperty
import com.airbnb.lottie.compose.LottieDynamicProperty
import com.airbnb.lottie.compose.rememberLottieDynamicProperty

@Composable
fun rememberColorFilterProperty(
    layerName: String,
    color: Color,
): LottieDynamicProperty<ColorFilter> {
    return rememberLottieDynamicProperty(
        LottieProperty.COLOR_FILTER,
        value = PorterDuffColorFilter(color.toArgb(), PorterDuff.Mode.SRC_ATOP),
        // "**" below means match zero or more layers, so ** layerName ** means find layer with that
        // name at any depth
        keyPath = arrayOf("**", layerName, "**"),
    )
}
+149 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.systemui.inputdevice.tutorial.ui.composable

import androidx.annotation.RawRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.node.Ref
import androidx.compose.ui.util.lerp
import com.airbnb.lottie.LottieComposition
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.LottieDynamicProperties
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.android.systemui.inputdevice.tutorial.ui.composable.TutorialActionState.Finished
import com.android.systemui.inputdevice.tutorial.ui.composable.TutorialActionState.InProgress
import com.android.systemui.inputdevice.tutorial.ui.composable.TutorialActionState.NotStarted

@Composable
fun TutorialAnimation(
    actionState: TutorialActionState,
    config: TutorialScreenConfig,
    modifier: Modifier = Modifier,
) {
    Box(modifier = modifier.fillMaxWidth()) {
        AnimatedContent(
            targetState = actionState::class,
            transitionSpec = {
                EnterTransition.None.togetherWith(
                        fadeOut(animationSpec = tween(durationMillis = 10, easing = LinearEasing))
                    )
                    // we don't want size transform because when targetState animation is loaded for
                    // the first time, AnimatedContent thinks target size is smaller and tries to
                    // shrink initial state
                    .using(sizeTransform = null)
            },
        ) { state ->
            when (state) {
                NotStarted::class ->
                    EducationAnimation(
                        config.animations.educationResId,
                        config.colors.animationColors,
                    )
                InProgress::class ->
                    InProgressAnimation(
                        // actionState can be already of different class while this composable is
                        // transitioning to another one
                        actionState as? InProgress,
                        config.animations.educationResId,
                        config.colors.animationColors,
                    )
                Finished::class ->
                    SuccessAnimation(config.animations.successResId, config.colors.animationColors)
            }
        }
    }
}

@Composable
private fun EducationAnimation(
    @RawRes educationAnimationId: Int,
    animationProperties: LottieDynamicProperties,
) {
    val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(educationAnimationId))
    val progress by
        animateLottieCompositionAsState(composition, iterations = LottieConstants.IterateForever)
    LottieAnimation(
        composition = composition,
        progress = { progress },
        dynamicProperties = animationProperties,
    )
}

@Composable
private fun SuccessAnimation(
    @RawRes successAnimationId: Int,
    animationProperties: LottieDynamicProperties,
) {
    val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(successAnimationId))
    val progress by animateLottieCompositionAsState(composition, iterations = 1)
    LottieAnimation(
        composition = composition,
        progress = { progress },
        dynamicProperties = animationProperties,
    )
}

@Composable
private fun InProgressAnimation(
    state: InProgress?,
    @RawRes inProgressAnimationId: Int,
    animationProperties: LottieDynamicProperties,
) {
    // Caching latest progress for when we're animating this view away and state is null.
    // Without this there's jumpcut in the animation while it's animating away.
    // state should never be null when composable appears, only when disappearing
    val cached = remember { Ref<InProgress>() }
    cached.value = state ?: cached.value
    val progress = cached.value?.progress ?: 0f

    val composition by
        rememberLottieComposition(LottieCompositionSpec.RawRes(inProgressAnimationId))
    val startProgress =
        rememberSaveable(composition, cached.value?.startMarker) {
            composition.progressForMarker(cached.value?.startMarker)
        }
    val endProgress =
        rememberSaveable(composition, cached.value?.endMarker) {
            composition.progressForMarker(cached.value?.endMarker)
        }
    LottieAnimation(
        composition = composition,
        progress = { lerp(start = startProgress, stop = endProgress, fraction = progress) },
        dynamicProperties = animationProperties,
    )
}

private fun LottieComposition?.progressForMarker(marker: String?): Float {
    if (marker == null) return 0f
    val startFrame = this?.getMarker(marker)?.startFrame ?: 0f
    return this?.getProgressForFrame(startFrame) ?: 0f
}
+13 −8
Original line number Diff line number Diff line
@@ -16,15 +16,18 @@

package com.android.systemui.touchpad.tutorial.ui.composable

import android.content.res.Resources
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import com.airbnb.lottie.compose.rememberLottieDynamicProperties
import com.android.compose.theme.LocalAndroidColorScheme
import com.android.systemui.inputdevice.tutorial.ui.composable.TutorialScreenConfig
import com.android.systemui.inputdevice.tutorial.ui.composable.rememberColorFilterProperty
import com.android.systemui.res.R
import com.android.systemui.touchpad.tutorial.ui.gesture.BackGestureRecognizer
import com.android.systemui.touchpad.tutorial.ui.gesture.GestureRecognizer

@Composable
fun BackGestureTutorialScreen(onDoneButtonClicked: () -> Unit, onBack: () -> Unit) {
@@ -44,15 +47,17 @@ fun BackGestureTutorialScreen(onDoneButtonClicked: () -> Unit, onBack: () -> Uni
                    successResId = R.raw.trackpad_back_success,
                ),
        )
    val gestureRecognizerProvider =
        DistanceBasedGestureRecognizerProvider(
            recognizerFactory = { distanceThresholdPx, gestureStateCallback ->
                BackGestureRecognizer(distanceThresholdPx).also {
                    it.addGestureStateCallback(gestureStateCallback)
                }
    val recognizer = rememberBackGestureRecognizer(LocalContext.current.resources)
    GestureTutorialScreen(screenConfig, recognizer, onDoneButtonClicked, onBack)
}

@Composable
private fun rememberBackGestureRecognizer(resources: Resources): GestureRecognizer {
    val distance =
        resources.getDimensionPixelSize(
            com.android.internal.R.dimen.system_gestures_distance_threshold
        )
    GestureTutorialScreen(screenConfig, gestureRecognizerProvider, onDoneButtonClicked, onBack)
    return remember(distance) { BackGestureRecognizer(distance) }
}

@Composable
+4 −38
Original line number Diff line number Diff line
@@ -16,7 +16,6 @@

package com.android.systemui.touchpad.tutorial.ui.composable

import android.content.res.Resources
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
@@ -32,7 +31,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInteropFilter
import androidx.compose.ui.platform.LocalContext
import com.android.systemui.inputdevice.tutorial.ui.composable.ActionTutorialContent
import com.android.systemui.inputdevice.tutorial.ui.composable.TutorialActionState
import com.android.systemui.inputdevice.tutorial.ui.composable.TutorialScreenConfig
@@ -44,36 +42,6 @@ import com.android.systemui.touchpad.tutorial.ui.gesture.GestureState.InProgress
import com.android.systemui.touchpad.tutorial.ui.gesture.GestureState.NotStarted
import com.android.systemui.touchpad.tutorial.ui.gesture.TouchpadGestureHandler

interface GestureRecognizerProvider {

    @Composable
    fun rememberGestureRecognizer(
        resources: Resources,
        gestureStateChangedCallback: (GestureState) -> Unit,
    ): GestureRecognizer
}

typealias gestureStateCallback = (GestureState) -> Unit

class DistanceBasedGestureRecognizerProvider(
    val recognizerFactory: (Int, gestureStateCallback) -> GestureRecognizer
) : GestureRecognizerProvider {

    @Composable
    override fun rememberGestureRecognizer(
        resources: Resources,
        gestureStateChangedCallback: (GestureState) -> Unit,
    ): GestureRecognizer {
        val distanceThresholdPx =
            resources.getDimensionPixelSize(
                com.android.internal.R.dimen.system_gestures_distance_threshold
            ) * 5
        return remember(distanceThresholdPx) {
            recognizerFactory(distanceThresholdPx, gestureStateChangedCallback)
        }
    }
}

fun GestureState.toTutorialActionState(): TutorialActionState {
    return when (this) {
        NotStarted -> TutorialActionState.NotStarted
@@ -86,18 +54,16 @@ fun GestureState.toTutorialActionState(): TutorialActionState {
@Composable
fun GestureTutorialScreen(
    screenConfig: TutorialScreenConfig,
    gestureRecognizerProvider: GestureRecognizerProvider,
    gestureRecognizer: GestureRecognizer,
    onDoneButtonClicked: () -> Unit,
    onBack: () -> Unit,
) {
    BackHandler(onBack = onBack)
    var gestureState: GestureState by remember { mutableStateOf(NotStarted) }
    var easterEggTriggered by remember { mutableStateOf(false) }
    val gestureRecognizer =
        gestureRecognizerProvider.rememberGestureRecognizer(
            resources = LocalContext.current.resources,
            gestureStateChangedCallback = { gestureState = it },
        )
    LaunchedEffect(gestureRecognizer) {
        gestureRecognizer.addGestureStateCallback { gestureState = it }
    }
    val easterEggMonitor = EasterEggGestureMonitor { easterEggTriggered = true }
    val gestureHandler =
        remember(gestureRecognizer) { TouchpadGestureHandler(gestureRecognizer, easterEggMonitor) }
Loading