google / automotive-design-compose

Automotive Design for Compose is an extension to Jetpack Compose that allows every screen, component, and overlay of your Android App to be defined in Figma, and lets you see the latest changes to your Figma design in your app, immediately!
https://google.github.io/automotive-design-compose/
Apache License 2.0
115 stars 17 forks source link

DesignVariant not working with figma component instances #1331

Open miguel-galarza opened 3 months ago

miguel-galarza commented 3 months ago

With the purpose of reusing Figma components, it is usually created a generic component in Figma with different variants. For example a #buttonGeneric with two variants (Enabled, Disabled) associated to a property #buttonState. From Figma, then different instances of the #buttonGeneric could be added in a screen and for each one it is possible to select a specific variant by modifying the property #buttonState. The image below, describes the use case:

The problem is that this approach of implementation is not working properly in design compose. When @DesignVariant is used, it is not possible to associate each specific instance to different buttons in the screen (nodes). Moreover when the variant is applied, the parameters of the node are lost:

It seems that when using DesignVariant a complete replacement of the instance by the generic one occurs.

The following issue Cannot change variant of a component on the stage · Issue #691 · google/automotive-design-compose (github.com) reports a similar perspective of the issue but this workaround add additional complexity to the code and does not solve the TapCallback issue. The approach also loses the simplicity of adding simple nodes.

It would be really helpful to apply specific variants to specific nodes without the need of creating additional components for each case.

timothyfroehlich commented 3 months ago

Thank you for the detailed request. I've assigned this to @iamralpht so that we can triage this soon.

rylin8 commented 3 months ago

Are you trying to change the button variant at runtime? If not, you don't need to use @DesignVariant. Then you can name the instances differently and use those node names in your @Design annotations.

miguel-galarza commented 3 months ago

Hi @rylin8, Yes, it is needed to change the button style at runtime, and there may be different states for different buttons in the same screen.

rylin8 commented 3 months ago

One thing you could do is create placeholder nodes in your main frame with different names like #button1, #button2 etc. Then put @Composable (ComponentReplacementContext) -> Unit customizations on those nodes and replace them with a @Composable generated from a @DesignComponent function for that button. For an example see https://github.com/google/automotive-design-compose/blob/05569c2d087855e5be7b883be330bbe9c92144be/integration-tests/validation/src/main/java/com/android/designcompose/testapp/validation/examples/VariantPropertiesTest.kt#L101

miguel-galarza commented 3 months ago

Hi @rylin8,

I have tested the proposed solution by also adding a text and a TapCallback to the replacement composable. In the example provided in VariantPropertiesTest.kt, it would be something like this:

@DesignComponent(node = "#SquareBorder") fun Square( @DesignVariant(property = "BorderType") type: SquareBorder, @DesignVariant(property = "#SquareColor") color: SquareColor, @DesignVariant(property = "#SquareShadow") shadow: Shadow, @Design(node = "#SquareBorder") onTapCallback: TapCallback, @Design(node = "#text") textButton: String )

Independent buttons are now working as expected taking the states "Enabled" and "Disabled", but if we add some prototype change from Figma itself, for example a "While Pressing" then if two buttons have the same state both will be changed during the "While pressing". ButtonStates

Is there some solution for this?

Thank you and BR,

rylin8 commented 3 months ago

I'm not sure why that wouldn't be working. We have a test that does something very similar to what you're describing. Please check this example and see if it is similar to your scenario: https://github.com/google/automotive-design-compose/blob/main/integration-tests/validation/src/main/java/com/android/designcompose/testapp/validation/examples/VariantInteractionsTest.kt

iamralpht commented 1 month ago

@miguel-galarza Can you share your example (or a reduction of your example) that reproduces this issue, because we think we have a test case that covers it (but must have a variation that we're not covering)?

miguel-galarza commented 1 month ago

Hi,

I have repeated the tests with version "0.30.0-rc01".

The use case would be to have some generic button that can used multiple times in a screen by setting individual text, and callback events. The button should contain the states: enabled, disabled and pressed while keeping the individual text associated, and callback to each button. I have included a screenshot of the figma document and a MainActivity.kt with the summary of the example. The generic button is called "#button1" and three instances of the button are added to the screen, two of them replaced with ComponentReplacementContext and the third not.

In the example the button states works individually, but the "whilePressing" event defined the Figma prototype does not work.

Captura

MainActivity.kt

package com.example.designcomposenavtest

import android.content.ComponentName
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.android.designcompose.ComponentReplacementContext
import com.android.designcompose.DesignSettings
import com.android.designcompose.TapCallback
import com.android.designcompose.annotation.Design
import com.android.designcompose.annotation.DesignComponent
import com.android.designcompose.annotation.DesignDoc
import com.android.designcompose.annotation.DesignVariant
import timber.log.Timber

enum class ButtonState {
    disabled,
    enabled
}

@DesignDoc(id = "id_document")
interface StageFrame {
    @DesignComponent(node = "#stage1", isRoot = true)
    fun Stage1(
        @Design(node = "#button1-stage1") button1: @Composable (ComponentReplacementContext) -> Unit,
        @Design(node = "#button2-stage1") button2: @Composable (ComponentReplacementContext) -> Unit,
    )

    @DesignComponent(node = "#button1")
    fun ButtonVariants(
        @DesignVariant(property = "#statebutton") buttonState: ButtonState,
        @Design(node = "#textButton") textButton: String,
        @Design(node = "#button1") onTapCallback: TapCallback
    )
}

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setFigmaToken()
        DesignSettings.enableLiveUpdates(this)
        setContent {
            App()
        }
    }

    @Composable
    fun App() {

        //Button 1 States
        var buttonState1: ButtonState by remember { mutableStateOf(ButtonState.disabled) }
        var buttonText1: String by remember { mutableStateOf("Enabled b1") }

        //Button 2 States
        var buttonState2: ButtonState by remember { mutableStateOf(ButtonState.disabled) }
        var buttonText2: String by remember { mutableStateOf("Enabled b2") }

        StageFrameDoc.Stage1(
            button1 = {
                StageFrameDoc.ButtonVariants(
                    buttonState = buttonState1,
                    textButton = buttonText1,
                    onTapCallback = {
                        if (buttonState1 == ButtonState.enabled) {
                            buttonState1 = ButtonState.disabled
                            buttonText1 = "Disabled b1"
                        } else {
                            buttonState1 = ButtonState.enabled
                            buttonText1 = "Enabled b1"
                        }
                    }
                )
            },
            button2 = {
                StageFrameDoc.ButtonVariants(
                    buttonState = buttonState2,
                    textButton = buttonText2,
                    onTapCallback = {
                        if (buttonState2 == ButtonState.enabled) {
                            buttonState2 = ButtonState.disabled
                            buttonText2 = "Disabled b2"
                        } else {
                            buttonState2 = ButtonState.enabled
                            buttonText2 = "Enabled b2"
                        }
                    }
                )
            }
        )

        //Indicators of current status
        Text(
            modifier = Modifier.absoluteOffset(50.dp, 300.dp),
            text = "btn 1: $buttonText1",
            fontSize = 30.sp
        )
        Text(
            modifier = Modifier.absoluteOffset(50.dp, 400.dp),
            text = "btn 2: $buttonText2",
            fontSize = 30.sp
        )
    }

    private fun setFigmaToken() {
        val figmaAccessToken = "123"
        if (figmaAccessToken.isNotEmpty()) {
            val intent = Intent().apply {
                component =
                    ComponentName(
                        "com.example.designcomposenavtest",
                        "com.android.designcompose.ApiKeyService"
                    )
                action = "setApiKey"
                putExtra("ApiKey", figmaAccessToken)
            }
            startService(intent)
        } else {
            Timber.tag("MainActivity")
                .e("FIGMA_ACCESS_TOKEN is not defined. Skipping setting Figma token.")
        }
    }

}