ZupIT/beagle

How to access style properties of a ServerDrivenComponent on Android?

Closed this issue · 9 comments

Use case

Hi all,
I open this issue to ask whether there is any built-in method in Beagle library or any way to access style properties of a ServerDrivenComponent on Android?
For example I have a custom widget called FloatingButton, and at Beagle Backend I use it as follows:

FloatingButton(
             color = "#ffff00",
             onPress = listOf(
                 OpenSettingScreen()
             )
         ).setStyle {
             backgroundColor = "#3596EC"
             cornerRadius = CornerRadius(radius = 12.0)
             size = Size(width = UnitValue(40.0), height = UnitValue(40.0))
         }

For properties like color and onPress of FloatingButton as I used above, of course I can access them on Android but how about backgroundColor, cornerRadius and size, which are defined in setStyle block, how can I access them?

If anyone has an idea, please let me know. Thank you! :D

this comes from the WidgetView() inheritance

@RegisterWidget("input")
data class Input() : WidgetView() {

    override fun buildView(rootView: RootView) = EditText(rootView.getContext()).apply {

    //        style?.backgroundColor?
    //        style?.cornerRadius?
       
    }
}

however some of these properties may work in your custom component because we modified it at the root of the render, but we couldn't do that at all
this is the current code that we apply to all widgets:

internal fun View.applyStyle(component: ServerDrivenComponent) {
    (component as? StyleComponent)?.let {
        if (it.style?.backgroundColor != null) {
            this.background = GradientDrawable()
            applyBackgroundColor(it)
            applyCornerRadius(it)
        } else {
            styleManagerFactory.applyStyleComponent(component = it, view = this)
        }
        applyStroke(it)
    }
}

internal fun View.applyViewBackgroundAndCorner(backgroundColor: Int?, component: StyleComponent) {
    if (backgroundColor != null) {
        this.background = GradientDrawable(
            GradientDrawable.Orientation.TOP_BOTTOM,
            intArrayOf(backgroundColor, backgroundColor)
        )

        this.applyCornerRadius(component)
    }
}

internal fun View.applyBackgroundColor(styleWidget: StyleComponent) {
    styleWidget.style?.backgroundColor?.toAndroidColor()?.let { androidColor ->
        (this.background as? GradientDrawable)?.setColor(androidColor)
    }
}

internal fun View.applyStroke(styleWidget: StyleComponent) {
    val color = styleWidget.style?.borderColor?.toAndroidColor()
    val width = styleWidget.style?.borderWidth?.toInt()?.dp()
    width?.let { strokeWidth ->
        color?.let { strokeColor ->
            val gradient = this.background as? GradientDrawable ?: GradientDrawable()
            gradient.setStroke(strokeWidth, strokeColor)
            this.background = gradient
        }
    }
}

internal fun View.applyCornerRadius(styleWidget: StyleComponent) {
    styleWidget.style?.cornerRadius?.radius?.let { cornerRadius ->
        (this.background as? GradientDrawable)?.cornerRadius = cornerRadius.dp().toFloat()
    }
}

@uziasferreirazup Nice. Thanks for your so helpful information. I didn't notice that I could access style within each custom widget :D

By the way, I'm trying to provide shadow effect around several custom widgets, and I intend to use elevation property on Android but I have no idea to achieve it.
I've tried to return a CardView layout but seemingly it doesn't work.
Do you have any suggestion for me? @uziasferreirazup
Supporting shadow effect will mean a lot to me 😬
Thank you in advance, bro!

Can I convert the custom widget itself to a native view and then add elevation for that view programmatically? @@

Normally I use elevation:

like in this page: https://developer.android.com/training/material/shadows-clipping

Customize View Shadows and Outlines
The bounds of a view's background drawable determine the default shape of its shadow. Outlines represent the outer shape of a graphics object and define the ripple area for touch feedback.

Consider this view, defined with a background drawable:

<TextView
    android:id="@+id/myview"
    ...
    android:elevation="2dp"
    android:background="@drawable/myrect" />
The background drawable is defined as a rectangle with rounded corners:


<!-- res/drawable/myrect.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
       android:shape="rectangle">
    <solid android:color="#42000000" />
    <corners android:radius="5dp" />
</shape>

The view casts a shadow with rounded corners, since the background drawable defines the view's outline. Providing a custom outline overrides the default shape of a view's shadow.

To define a custom outline for a view in your code:

Extend the ViewOutlineProvider class.
Override the getOutline() method.
Assign the new outline provider to your view with the View.setOutlineProvider() method.
You can create oval and rectangular outlines with rounded corners using the methods in the Outline class. The default outline provider for views obtains the outline from the view's background. To prevent a view from casting a shadow, set its outline provider to null.

Clip Views
Clipping views enables you to easily change the shape of a view. You can clip views for consistency with other design elements or to change the shape of a view in response to user input. You can clip a view to its outline area using the View.setClipToOutline() method. Only rectangle, circle, and round rectangle outlines support clipping, as determined by the Outline.canClip() method.

To clip a view to the shape of a drawable, set the drawable as the background of the view (as shown above) and call the View.setClipToOutline() method.

Clipping views is an expensive operation, so don't animate the shape you use to clip a view. To achieve this effect, use the Reveal Effect animation.

Example I do in the beagle:

@RegisterWidget("text")
data class Text(
    val text: Bind<String>,
) : WidgetView() {
    override fun buildView(rootView: RootView): TextView = TextView(rootView.getContext()).also {
        it.setTextColor(Color.BLACK)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            it.elevation = 4f
        }
        it.background = ContextCompat.getDrawable(rootView.getContext(), R.drawable.myrect)
        observeBindChanges(rootView, it, this@Text.text) { newText ->
            it.text = newText
        }
    }
}
myreact.yml

<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <!--the shadow comes from here-->
    <item
        android:bottom="0dp"
        android:drawable="@android:drawable/dialog_holo_light_frame"
        android:left="0dp"
        android:right="0dp"
        android:top="0dp">

    </item>

    <item
        android:bottom="0dp"
        android:left="0dp"
        android:right="0dp"
        android:top="0dp">
        <!--whatever you want in the background, here i preferred solid white -->
        <shape android:shape="rectangle">
            <solid android:color="@android:color/white" />

        </shape>
    </item>
</layer-list>

result:
Screenshot_1625251315

Yeah we can use a predefined layerlist to set background for our view, but if we use a custom widget at Beagle Backend and set corner radius for it, how can we handle the layerlist on client to achieve a rounded view with shadow effect?

this case is more complicated, I'll make an example for you but I can't right now, but basically you'll need to have your way of applying the style because the beagle's won't suit you because it instantiates a GradientDrawable and to apply the border it says that has to be a GradientDrawable.

something like this to get your layer list and apply the corner radius: https://stackoverflow.com/questions/15100660/create-layer-list-with-rounded-corners-programmatically

but in short, if your component has a background that is a layer-list the beagle won't be able to apply a corner radius. so in this case you'll need to create this support, it's possible but I don't have anything ready right now.

Anyway it's a good news indeed for me to know that it's possible :D
I'll try to research your suggested strategy and I also hope to see your example soon because perhaps my implementation won't produce the result as expected, in that case, your example will be a good reference for me.

Sorry for the long time it took to answer this issue:
I'm going to show you something cool so you can add behaviors across the tree the same way the beagle does.

You can create your own widget view, with that you will be able to add your behaviors without having to create new components to apply some behavior:

Example of MyWidgetView:

import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.LayerDrawable
import android.view.View
import androidx.core.content.ContextCompat
import br.com.zup.beagle.android.context.Bind
import br.com.zup.beagle.android.utils.observeBindChanges
import br.com.zup.beagle.android.widget.RootView
import br.com.zup.beagle.android.widget.WidgetView
import br.com.zup.beagle.core.CornerRadius
import br.com.zup.beagle.sample.R

abstract class MyWidgetView: WidgetView() {

    var customBackground: Bind<String>? = null

    var customCornerRadius: CornerRadius? = null

    override fun buildView(rootView: RootView): View {
        val view = myBuildView(rootView)

        if (view.background == null) {
            view.background = ContextCompat.getDrawable(rootView.getContext(), R.drawable.myrect)
        }

        customCornerRadius?.let {

            //THIS get layer change background color
            val backgroundDrawable = (view.background as LayerDrawable).findDrawableByLayerId(R.id.backgroundColor) as GradientDrawable

            backgroundDrawable.cornerRadius = it.radius?.toFloat() ?: 0.0f

            // end change background color

        }


        customBackground?.let { backgroundColorBind ->
            observeBindChanges(rootView, view, backgroundColorBind) { backgroundColor ->

                //THIS get layer change shadow color
                (view.background as LayerDrawable).findDrawableByLayerId(R.id.shadowBorder)

                //THIS get layer change background color
                val backgroundDrawable = (view.background as LayerDrawable).findDrawableByLayerId(R.id.backgroundColor) as GradientDrawable

                val androidColor = backgroundColor?.toAndroidColor()

                androidColor?.let {
                    backgroundDrawable.setColor(androidColor)
                }

                // end change background color

            }
        }

        return view
    }


    abstract fun myBuildView(rootView: RootView): View

}

fun String.toAndroidColor(): Int? = try {
    ColorUtils.hexColor(this)
} catch (ex: Exception) {
    null
}


object ColorUtils {

    fun hexColor(hexColor: String): Int {
        return when (hexColor.length) {
            4 -> Color.parseColor(formatHexRGBColor(hexColor))
            9 -> Color.parseColor(formatHexColorAlpha(hexColor))
            else -> Color.parseColor(formatHexRGBAColor(hexColor))
        }
    }

    private fun formatHexColorAlpha(color: String): String {
        return "^#([0-9A-F]{6})([0-9A-F]{2})$"
            .toRegex(RegexOption.IGNORE_CASE)
            .replace(color, "#\$2\$1")
    }

    private fun formatHexRGBColor(color: String): String {
        return "^#([0-9A-F])([0-9A-F])([0-9A-F])?$"
            .toRegex(RegexOption.IGNORE_CASE)
            .replace(color, "#\$1\$1\$2\$2\$3\$3")
    }

    private fun formatHexRGBAColor(color: String): String {
        return "^#([0-9A-F])([0-9A-F])([0-9A-F])([0-9A-F])?$"
            .toRegex(RegexOption.IGNORE_CASE)
            .replace(color, "#\$4\$4\$1\$1\$2\$2\$3\$3")
    }
}
myreact.xml
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:id="@+id/shadowBorder">
        <shape android:shape="rectangle">

        </shape>
    </item>

    <item
        android:left="2dp"
        android:right="2dp"
        android:top="2dp"
        android:id="@+id/backgroundColor"
        android:bottom="2dp">
        <shape android:shape="rectangle">

        </shape>
    </item>
</layer-list>

after you create your own widget view, you have to user them in your components that you wanted to add properties/behaviors:

Example:

@RegisterWidget("text")
data class Text(
    val text: Bind<String>,
) : MyWidgetView() {

    override fun myBuildView(rootView: RootView): View = TextView(rootView.getContext()).also {
        it.setTextColor(Color.BLACK)
        observeBindChanges(rootView, it, this@Text.text) { newText ->
            it.text = newText
        }
    }
}

Example of use this component:

        val text = br.com.zup.beagle.sample.widgets.Text(valueOf("text one"))
        text.customBackground = valueOf("#f1f1f1")
        text.customCornerRadius = CornerRadius(20.0)

this is just an example so you can cover more background cases, create shadows, and do other things you would like to do.