Blog Infos
Author
Published
Topics
Published
Topics

The JetPack Compose logo inside the Jetpack Compose logo inside the Jetpack Compose logo inside the Jetpack Compose logo …

 

Let’s create a Compose Button from scratch and apply Separation of Concerns.

Design Language Systems are becoming an increasing trend among Mobile designers. Your lovely designer follows such a trend and provides you with the below specifications for buttons. They are not really Material buttons. They do not use elevation changes or ripples. Reusing a Material button would require us to manually disable such features, which is bug-prone and may require maintenance on each Compose update. You then decide to implement a button from scratch. How would you go about it?

Your designer following new trends provided you with these specs for a button

 

You might be tempted at first to write a single and huge Composable function that implements the button and encompasses all of its aspects. That might result in very complex code that will be pretty hard to maintain. Is there a structured way in which we can implement the button?

When implementing the button we have four major concerns:

  1. We need to draw the button;
  2. The way we draw the button will change according to the animation parameters;
  3. Animations are triggered in response to state changes;
  4. We should be able to customize the representation of the different states of a button;

Could we implement each of those concerns in an independent way? Looks like each concern affects the other. Could we arrange them in a pipeline so all the data flows among them in a single direction as required by Compose? That is what I am proposing here:

The button rendering pipeline

Each concern will be a stage in our data pipeline. Let’s describe each stage.

Drawing

In the drawing stage, our only concern is ensuring we correctly draw the button. Everything like colors, sizes, and text styles are passed as parameters so it can be affected by the upper stages.

The Compose naming conventions say “MUST name any function that returns Unit and bears the @Composable annotation using PascalCase, and the name MUST be that of a noun”. However, I will be breaking them for the name of a @Composable function that implements a stage, using camelCase instead of PascalCase. The rationale for that is that those functions do not implement a full and self-sufficient Component, whose View counterpart would be a class, and hence the usage of PascalCase. Those functions are more like methods of a class with very well-defined objectives that together will build the component. Hence camelCase like it is for any method of a class. Besides they are file private and they will not be part of the public API.

@SuppressLint("ComposableNaming")
@Composable
private fun drawButton(
    text: String,
    icon: ImageVector?,
    backgroundColor: Color,
    foregroundColor: Color,
    borderColor: Color,
    shape: Shape,
    iconSize: Dp,
    borderSize: Dp,
    spacing: Dp,
    minWidth: Dp,
    minHeight: Dp,
    paddings: PaddingValues,
    textStyle: TextStyle,
    modifier: Modifier = Modifier
) {
    Row(
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.Center,
        modifier = modifier
            .border(
                width = borderSize,
                color = borderColor,
                shape = shape
            )
            .background(
                color = backgroundColor,
                shape = shape
            )
            .padding(paddings)
            .defaultMinSize(minWidth = minWidth, minHeight = minHeight)
    ) {
        if (icon != null) {
            Icon(
                imageVector = icon,
                contentDescription = null,
                tint = foregroundColor,
                modifier = Modifier.size(iconSize)
            )
            Spacer(modifier = Modifier.width(spacing))
        }
        Text(text = text, color = foregroundColor, style = textStyle)
    }
}

We can then write several previews for drawButton to verify the rendering for various situations like when the button has no icon, the text is very long, or even when it is used with RTL scripts like the old Phoenician alphabet.

Several previews to ensure we are drawing our button correctly.

OUR VIDEO RECOMMENDATION

,

Meet Jewel:Create IDE plugins in Compose

Jetpack Compose is the declarative UI toolkit for Android that makes it easy to create beautiful, responsive apps. However, until recently, there was no easy way to use Compose to create IDE plugins without too…
Watch Video

Meet Jewel:Create IDE plugins in Compose

Sebastiano Poggi & Chris Sinco
UX Engineer & UX Design Lead
Google

Meet Jewel:Create IDE plugins in Compose

Sebastiano Poggi & ...
UX Engineer & UX Des ...
Google

Meet Jewel:Create IDE plugins in Compose

Sebastiano Poggi ...
UX Engineer & UX Design L ...
Google

Jobs

No results found.

Animation

This is the second lowest stage in the pipeline and it is responsible for setting up the animations. You can clearly see that animateButton is organized into three sections:

  1. Receiving parameters: some will be used by the animations, others will pass through reaching the drawing stage;
  2. Setting up animations: in this case, we want to animate the colors and content size changes in case the text changes or whenever we hide or show the icon;
  3. Passing data to the next stage in the pipeline.
@SuppressLint("ComposableNaming")
@Composable
private fun animateButton(
    text: String,
    icon: ImageVector?,
    backgroundColor: Color,
    foregroundColor: Color,
    borderColor: Color,
    shape: Shape,
    iconSize: Dp,
    borderSize: Dp,
    spacing: Dp,
    minWidth: Dp,
    minHeight: Dp,
    paddings: PaddingValues,
    textStyle: TextStyle,
    animationDuration: Int,
    animationEasing: Easing,
    modifier: Modifier = Modifier
) {
    val colorAnimationSpec =
        tween<Color>(durationMillis = animationDuration, easing = animationEasing)
    val animatedBorderColor by animateColorAsState(
        animationSpec = colorAnimationSpec,
        targetValue = borderColor,
        label = "border"
    )
    val animatedBackgroundColor by animateColorAsState(
        animationSpec = colorAnimationSpec,
        targetValue = backgroundColor,
        label = "background"
    )
    val animatedForegroundColor by animateColorAsState(
        animationSpec = colorAnimationSpec,
        targetValue = foregroundColor,
        label = "foreground"
    )

    val localModifier = modifier.animateContentSize(
        animationSpec = tween(
            durationMillis = animationDuration,
            easing = animationEasing
        )
    )
    drawButton(
        text = text,
        icon = icon,
        backgroundColor = animatedBackgroundColor,
        foregroundColor = animatedForegroundColor,
        borderColor = animatedBorderColor,
        shape = shape,
        iconSize = iconSize,
        borderSize = borderSize,
        spacing = spacing,
        minWidth = minWidth,
        minHeight = minHeight,
        paddings = paddings,
        textStyle = textStyle,
        modifier = localModifier
    )
}

Luckily again, Compose preview tooling can help us verify if we are on the right track. Note that setting the labels for the animations in the code above became really handy with the Animation Preview, where one can pick any colors he or she wants for the tests.

State

This stage will manage the button state: when it is pressed, clicked, focused, and whatnot, and the consequences to other parameters such as the colors. It is very loosely inspired by the Material Button implementation.

The number of parameters in the stages is growing considerably. To tidy things up let’s create some data structures to group them.

object ButtonInteractionState {
    @JvmStatic
    val HOVER = 1.shl(0)

    @JvmStatic
    val PRESSED = 1.shl(1)

    @JvmStatic
    val FOCUSED = 1.shl(2)
}

interface ButtonColors {
    @Stable
    @Composable
    fun borderColor(interactionState: Int, enabled: Boolean): State<Color>

    @Stable
    @Composable
    fun foregroundColor(interactionState: Int, enabled: Boolean): State<Color>

    @Stable
    @Composable
    fun backgroundColor(interactionState: Int, enabled: Boolean): State<Color>
}

@Immutable
interface ButtonSizes {
    val iconSize: Dp
    val borderSize: Dp
    val contentPadding: PaddingValues
    val spacing: Dp
    val minWidth: Dp
    val minHeight: Dp
}

@Immutable
interface ButtonAnimation {
    val duration: Int
    val easing: Easing
}

ButtonInteractionState defines constants in a bitfield manner because a button can focused, hovered, and pressed, all at the same time.

ButtonColors defines the signature for methods that will give the color to be used with the current button state provided by the parameters. The @Stable annotation tells the Compose compiler that those functions will return the same result if the same parameters are passed in, so it can do some optimizations.

I believe that ButtonSizes and ButtonAnimation are self-explanatory.

Similarly to animateButtonstateButton is divided into sections:

  1. Figuring out the current state using the interaction source;
  2. Defining our button as clickable, and passing the enabled state and the onClick callback;
  3. Calculating the colors to be used according to the state

There are some important things to note when setting the clickable modifier:

  • We are forwarding the MutableInteractionSource;
  • We are not using Indication;
  • We set the role to Role.Button, which will help Compose accessibility, focus management, etc.

Discussing Indication is out of the scope of this article. Indication “represents visual effects that occur when certain interactions happen”. For instance, the Surface component always sets the characteristic Material ripple effect using Indication. For a really deep dive into Interaction Sources and Indication read this article published in Android Developers.

@SuppressLint("ComposableNaming")
@Composable
private fun stateButton(
    text: String,
    onClick: () -> Unit,
    icon: ImageVector?,
    colors: ButtonColors,
    sizes: ButtonSizes,
    shape: Shape,
    textStyle: TextStyle,
    animation: ButtonAnimation,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) {
    val isHovered by interactionSource.collectIsHoveredAsState()
    val isPressed by interactionSource.collectIsPressedAsState()
    val isFocused by interactionSource.collectIsFocusedAsState()

    var interactionState = 0
    if (isHovered) interactionState = interactionState.or(ButtonInteractionState.HOVER)
    if (isPressed) interactionState = interactionState.or(ButtonInteractionState.PRESSED)
    if (isFocused) interactionState = interactionState.or(ButtonInteractionState.FOCUSED)

    val currentModifier = modifier.clickable(
        interactionSource = interactionSource,
        indication = null,
        enabled = enabled,
        onClick = onClick
    )

    val backgroundColor = colors.backgroundColor(interactionState, enabled).value
    val foregroundColor = colors.foregroundColor(interactionState, enabled).value
    val borderColor = colors.borderColor(interactionState, enabled).value
    
    animateButton(
        text = text,
        icon = icon,
        backgroundColor = backgroundColor,
        foregroundColor = foregroundColor,
        borderColor = borderColor,
        shape = shape,
        iconSize = sizes.iconSize,
        borderSize = sizes.borderSize,
        spacing = sizes.spacing,
        minWidth = sizes.minWidth,
        minHeight = sizes.minHeight,
        paddings = sizes.contentPadding,
        textStyle = textStyle,
        animationDuration = animation.duration,
        animationEasing = animation.easing,
        modifier = currentModifier
    )
}
Theming

In this stage, we are finally implementing the specifications given by our designer. Considering that we are using a custom font, Monstserrat, we need to define a FontFamily:

val MontserratFont = FontFamily(
    Font(R.font.montserrat_thin, weight = FontWeight.Thin, style = FontStyle.Normal),
    Font(R.font.montserrat_thin_italic, weight = FontWeight.Thin, style = FontStyle.Italic),
    Font(R.font.montserrat_extra_light, weight = FontWeight.ExtraLight, style = FontStyle.Normal),
    Font(R.font.montserrat_extra_light_italic, weight = FontWeight.ExtraLight, style = FontStyle.Italic),
    Font(R.font.montserrat_light, weight = FontWeight.Light, style = FontStyle.Normal),
    Font(R.font.montserrat_light_italic, weight = FontWeight.Light, style = FontStyle.Italic),
    Font(R.font.montserrat_regular, weight = FontWeight.Normal, style = FontStyle.Normal),
    Font(R.font.montserrat_italic, weight = FontWeight.Normal, style = FontStyle.Italic),
    Font(R.font.montserrat_medium, weight = FontWeight.Medium, style = FontStyle.Normal),
    Font(R.font.montserrat_medium_italic, weight = FontWeight.Medium, style = FontStyle.Italic),
    Font(R.font.montserrat_semi_bold, weight = FontWeight.SemiBold, style = FontStyle.Normal),
    Font(R.font.montserrat_semi_bold_italic, weight = FontWeight.SemiBold, style = FontStyle.Italic),
    Font(R.font.montserrat_bold, weight = FontWeight.Bold, style = FontStyle.Normal),
    Font(R.font.montserrat_bold_italic, weight = FontWeight.Bold, style = FontStyle.Italic),
    Font(R.font.montserrat_extra_bold, weight = FontWeight.ExtraBold, style = FontStyle.Normal),
    Font(R.font.montserrat_extra_bold_italic, weight = FontWeight.ExtraBold, style = FontStyle.Italic),
    Font(R.font.montserrat_black, weight = FontWeight.Black, style = FontStyle.Normal),
    Font(R.font.montserrat_black_italic, weight = FontWeight.Black, style = FontStyle.Italic),
)

In ui.theme.Colors we define constants for the colors we will use:

val ButtonBorderFocused = Color(0xFF00FFFF)
val ButtonBorderNormal = Color(0xFF1C4587)
val ButtonForegroundNormal = Color.White
val ButtonForegroundHovered = Color(0xFF1C4587)
val ButtonForegroundDisabled = Color(0xFF666666)
val ButtonBackgroundNormal = Color(0XFF3C78D8)
val ButtonBackgroundHovered = Color(0xFFC9DAF8)
val ButtonBackgroundPressed = Color(0xFF1C4587)
val ButtonBackgroundDisabled = Color(0xFFEFEFEF)

ObjectDefaults will group all the specs together into a single object:

private infix fun Int.has(bit: Int) = this.and(bit) != 0

object ButtonDefaults {
    val colors = object : ButtonColors {
        @Composable
        override fun borderColor(interactionState: Int, enabled: Boolean): State<Color> {
            return rememberUpdatedState(
                when {
                    !enabled -> ButtonForegroundDisabled
                    interactionState has ButtonInteractionState.FOCUSED -> ButtonBorderFocused
                    interactionState has ButtonInteractionState.HOVER -> ButtonForegroundHovered
                    else -> ButtonBorderNormal
                }
            )
        }

        @Composable
        override fun foregroundColor(interactionState: Int, enabled: Boolean): State<Color> {
            return rememberUpdatedState(
                when {
                    !enabled -> ButtonForegroundDisabled
                    interactionState has ButtonInteractionState.HOVER -> ButtonForegroundHovered
                    else -> ButtonForegroundNormal
                }
            )
        }

        @Composable
        override fun backgroundColor(interactionState: Int, enabled: Boolean): State<Color> {
            return rememberUpdatedState(
                when {
                    !enabled -> ButtonBackgroundDisabled
                    interactionState has ButtonInteractionState.PRESSED -> ButtonBackgroundPressed
                    interactionState has ButtonInteractionState.HOVER -> ButtonBackgroundHovered
                    else -> ButtonBackgroundNormal
                }
            )
        }
    }

    val sizes = object : ButtonSizes {
        override val iconSize = 32.dp
        override val borderSize = 3.dp
        override val contentPadding = PaddingValues(all = 16.dp)
        override val spacing = 8.dp
        override val minWidth = 60.dp
        override val minHeight = 48.dp

    }

    val animation = object : ButtonAnimation {
        override val duration = 250
        override val easing = EaseInCirc
    }

    val shape = CutCornerShape(topEndPercent = 30, bottomStartPercent = 30)

    val textStyle = TextStyle(
        fontFamily = MontserratFont,
        fontWeight = FontWeight.SemiBold,
        fontSize = 24.sp
    )
}
Putting everything together

We can now finally put everything together and create our Button component. We use the specs defined in ButtonDefaults as our default values, so we still allow some local customization:

@Composable
fun Button(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    icon: ImageVector? = null,
    enabled: Boolean = true,
    colors: ButtonColors = ButtonDefaults.colors,
    sizes: ButtonSizes = ButtonDefaults.sizes,
    shape: Shape = ButtonDefaults.shape,
    textStyle: TextStyle = ButtonDefaults.textStyle,
    animation: ButtonAnimation = ButtonDefaults.animation,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
) {
    stateButton(
        text,
        onClick,
        icon,
        colors,
        sizes,
        shape,
        textStyle,
        animation,
        modifier,
        enabled,
        interactionSource
    )
}

Let’s write some previews:

Enabled state
@Preview(name = "Enabled", group = "Button", showBackground = true)
@Composable
fun ButtonPreview() {
    ComposeButtonTheme {
        var showIcon by remember { mutableStateOf(true) }
        Box(modifier = Modifier.padding(24.dp)) {
            Button(
                text = "Button Text",
                onClick = { showIcon = !showIcon },
                icon = if (showIcon) Icons.Default.Home else null
            )
        }
    }
}

Static preview for the enabled state

Animated preview to show the animation and state changes.

 

Disabled State
@Preview(name = "Disabled", group = "Button", showBackground = true)
@Composable
fun ButtonDisabledPreview() {
    ComposeButtonTheme {
        var showIcon by remember { mutableStateOf(true) }
        Box(modifier = Modifier.padding(24.dp)) {
            Button(
                text = "Button Text",
                onClick = { showIcon = !showIcon },
                icon = if (showIcon) Icons.Default.Home else null,
                enabled = false
            )
        }
    }
}

Static preview for the disabled state

 

Source Code

The entire source code for this article is available at:

https://github.com/aoriani/ComposeButton

References

 

 

This article was previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Compose is a relatively young technology for writing declarative UI. Many developers don’t even…
READ MORE
blog
When it comes to the contentDescription-attribute, I’ve noticed a couple of things Android devs…
READ MORE
blog
Compose is part of the Jetpack Library released by Android last spring. Create Android…
READ MORE
blog
Welcome to part 5 of “Building a Language Learning App with Compose “ series.…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu