Site icon JoinAppStudio

Compose a Compose Button by composing Composable functions | by André Oriani | Sep, 2023

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.

In the drawing stage, our only concern is to ensure that we are correctly drawing the button. Everything like colors, sizes, and text styles are passed as parameters so it can be affected by the upper stages. The composable name uses camelCase instead of PascalCase to denote that it is not a component.

@SuppressLint("ComposableNaming")
@Composable
internal 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.

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
internal 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.

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 animateButton, stateButton 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
internal 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
)
}

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:

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
)
}

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

The entire source code for this article is available at:

Source link

Exit mobile version