Android Compose in Action: Developing a Dynamic Bank Card UI

Ken Ruiz Inoue
Deuk
Published in
13 min readDec 18, 2023
BankCardUi

Introduction

Welcome, Android traveler! Are you geared up for another exhilarating journey through the Compose universe? In this session, we’re taking a hands-on approach to craft an innovative BankCardUi() composable, a creation we’ve meticulously developed from the ground up.

🎯 Today’s Objectives:

  • Mastering the art of achieving lower saturated colors programmatically.
  • Refining UI aesthetics with the savvy use of Modifier.aspectRatio().
  • Unleashing creativity through Canvas() to draw dynamic, eye-catching circles.
  • Ingeniously spacing elements with our tailor-made SpaceWrapper() composable.

💻 Environment:

  • IDE: Android Studio Hedgehog | Version 2023.1.1
  • Compose Toolkit: androidx.compose:compose-bom | Version 2023.08.00
  • Testing Ground: Pixel 5 Emulator, API 32

Let’s embark on this journey of Android innovation together and unlock new creative possibilities.

Step 1: Aspect Ratio

As always, kick things off by selecting the Empty Activity option in Android Studio. Then create a file named BankCardUi.kt, and let's lay down our first bricks:

// your package com...

// All the needed imports
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

// Our star of the show: BankCardUi
@Composable
fun BankCardUi() {
// Bank Card Aspect Ratio
val bankCardAspectRatio = 1.586f // (e.g., width:height = 85.60mm:53.98mm)
Card(
modifier = Modifier
.fillMaxWidth()
// Aspect Ratio in Compose
.aspectRatio(bankCardAspectRatio),
elevation = CardDefaults.cardElevation(defaultElevation = 16.dp)
) {
Box {
BankCardBackground(baseColor = Color(0xFF1252c8))
}
}
}

// A splash of color for the background
@Composable
fun BankCardBackground(baseColor: Color) {
Canvas(
modifier = Modifier
.fillMaxSize()
.background(baseColor)
) {
}
}

// Take a sneak peek with @Preview
@Composable
@Preview
fun BankCardUiPreview() {
Box (Modifier.padding(16.dp)) {
BankCardUi()
}
}

Bank Card Aspect Ratio

  • The aspect ratio in UI design refers to the proportional relationship between an element’s width and height.
  • For bank cards, the standard dimension is crucial. The universally accepted dimension for bank cards, credit cards, and ID cards is defined by the ISO/IEC 7810 ID-1 standard, which is 85.60mm in width and 53.98mm in height.
  • The formula for the aspect ratio is width / height. So, for a standard bank card, it is 85.60mm / 53.98mm ≈ 1.586. Thus, val bankCardAspectRatio = 1.586f.
  • This aspect ratio ensures that our UI card will mimic a standard bank card regardless of the screen size.

Aspect Ratio in Compose

  • We apply the .aspectRatio(bankCardAspectRatio) modifier to our Card composable. This tells Compose to maintain this ratio between the card's width and height.
  • The .fillMaxWidth() modifier ensures the card stretches to the full width of its parent container, and the height adjusts automatically to maintain the aspect ratio.

Hit the Preview and witness the birth of the bank card’s silhouette!

BankCardUi Implementation Part 1

Step 2: Color Manipulation - Mastering Saturation

It's time for a bit of color science. This step is all about adding depth and vibrancy to our BankCardUi() through color manipulation, particularly by adjusting saturation levels. Create theColorUtils.kt file with the following content:

// your package com...

import androidx.compose.ui.graphics.Color

fun Color.toHsl(): FloatArray {
val redComponent = red
val greenComponent = green
val blueComponent = blue

val maxComponent = maxOf(redComponent, greenComponent, blueComponent)
val minComponent = minOf(redComponent, greenComponent, blueComponent)
val delta = maxComponent - minComponent
val lightness = (maxComponent + minComponent) / 2

val hue: Float
val saturation: Float

if (maxComponent == minComponent) {
// Grayscale color, no saturation and hue is undefined
hue = 0f
saturation = 0f
} else {
// Calculating saturation
saturation = if (lightness > 0.5) delta / (2 - maxComponent - minComponent) else delta / (maxComponent + minComponent)
// Calculating hue
hue = when (maxComponent) {
redComponent -> 60 * ((greenComponent - blueComponent) / delta % 6)
greenComponent -> 60 * ((blueComponent - redComponent) / delta + 2)
else -> 60 * ((redComponent - greenComponent) / delta + 4)
}
}

// Returning HSL values, ensuring hue is within 0-360 range
return floatArrayOf(hue.coerceIn(0f, 360f), saturation, lightness)
}

fun hslToColor(hue: Float, saturation: Float, lightness: Float): Color {
val chroma = (1 - kotlin.math.abs(2 * lightness - 1)) * saturation
val secondaryColorComponent = chroma * (1 - kotlin.math.abs((hue / 60) % 2 - 1))
val matchValue = lightness - chroma / 2

var red = matchValue
var green = matchValue
var blue = matchValue

when ((hue.toInt() / 60) % 6) {
0 -> { red += chroma; green += secondaryColorComponent }
1 -> { red += secondaryColorComponent; green += chroma }
2 -> { green += chroma; blue += secondaryColorComponent }
3 -> { green += secondaryColorComponent; blue += chroma }
4 -> { red += secondaryColorComponent; blue += chroma }
5 -> { red += chroma; blue += secondaryColorComponent }
}

// Creating a color from RGB components
return Color(red = red, green = green, blue = blue)
}

fun Color.setSaturation(newSaturation: Float): Color {
val hslValues = this.toHsl()
// Adjusting the saturation while keeping hue and lightness the same
return hslToColor(hslValues[0], newSaturation.coerceIn(0f, 1f), hslValues[2])
}

Understanding Saturation

  • Saturation refers to the intensity or purity of a color. In a highly saturated color, the hue appears vivid and rich. In contrast, a desaturated color looks more washed out or gray.
  • In UI design, playing with saturation can create visual interest and hierarchy, helping certain elements stand out or blend into the background.

The HSL Color Model

  • HSL stands for Hue, Saturation, and Lightness. It’s a cylindrical color model that represents colors in a way that’s often more intuitive for humans to understand and manipulate than RGB (Red, Green, Blue).
  • In HSL, Hue determines the type of color, Saturation defines the intensity of that color, and Lightness controls the brightness.

Converting to HSL — toHsl()

  • The toHsl() function calculates a color's hue, saturation, and lightness.
  • It starts by finding the maximum and minimum values among the color's red, green, and blue components, which helps determine the hue and saturation.
  • The hue is calculated based on which RGB component (red, green, or blue) is dominant.
  • Saturation is then derived based on the lightness and the difference (delta) between the max and min components.

Converting to Color — hslToColor()

  • This method is used to convert HSL (Hue, Saturation, Lightness) values back to a Color object, typically represented in the RGB (Red, Green, Blue) color model.

Manipulating Saturation — setSaturation()

  • The purpose of setSaturation() is to change the saturation level of a color while keeping its hue and lightness constant.
  • This method first converts the color from the RGB space to HSL using toHsl(), adjusts the saturation value, and then converts it back to an RGB color using hslToColor().

Now, update BankCardBackground() to create two visually distinct circles by varying their saturation levels, adding depth and interest to the card’s background. Let’s use the Color.setSaturation() function with 0.75f and 0.5f respectively.


...

@Composable
fun BankCardBackground(baseColor: Color) {
val colorSaturation75 = baseColor.setSaturation(0.75f)
val colorSaturation50 = baseColor.setSaturation(0.5f)
// Drawing Shapes with Canvas
Canvas(
modifier = Modifier
.fillMaxSize()
.background(baseColor)
) {
// Drawing Circles
drawCircle(
color = colorSaturation75,
center = Offset(x = size.width * 0.2f, y = size.height * 0.6f),
radius = size.minDimension * 0.85f
)
drawCircle(
color = colorSaturation50,
center = Offset(x = size.width * 0.1f, y = size.height * 0.3f),
radius = size.minDimension * 0.75f
)
}
}

...

Drawing Shapes with Canvas

  • The Canvas composable in Jetpack Compose provides a flexible space to draw custom shapes and graphics. It is particularly powerful for creating complex designs or visual effects that are not easily achievable with standard composables.
  • In the BankCardBackground function, the Canvas draws circles with varying saturation levels. This is a practical application of the color manipulation techniques we have implemented.

Drawing Circles

  • The drawCircle function is used to draw circles on the canvas.
  • You are creating two circles with different saturation levels (colorSaturation75 and colorSaturation50). These saturation levels were obtained by manipulating the baseColor using the setSaturation function.
  • The center parameter of drawCircle determines the position of each circle on the canvas. It's calculated as a fraction of the canvas size (size.width and size.height), allowing for responsive positioning relative to the canvas size.
  • The radius of each circle is determined as a fraction of the canvas's smaller dimension (size.minDimension), ensuring that the circles scale appropriately with different screen sizes or canvas dimensions.

Preview this to see a slightly cooler card UI. Feel free to play with the values mentioned above to explore different outcomes.

BankCardUi Implementation Part 2

Step 3: Dots and Digits

This step focuses on infusing the BankCardUi() with essential elements — dots and digits — that give it the authentic feel of a digital bank card. We will create BankCardNumber() and BankCardDotGroup() composables to implement the dots and digits.

First, download the Lato Google font from here. Then under the res folder, right-click and create a new Direcotry called font. Throw all the .ttf files inside the newly created folder and rename them so there are no capital letters.

Lato Fonts

Add the val LatoFont, the BankCardNumber(), the BankCardDotGroup() composables, and update the BankCardUi() content. Update BankCardUi.kt like the following:

// Defining the LatoFont
val LatoFont = FontFamily(
Font(R.font.lato_black, FontWeight.Black),
Font(R.font.lato_black_italic, FontWeight.Black, FontStyle.Italic),
Font(R.font.lato_bold, FontWeight.Bold),
Font(R.font.lato_bold_italic, FontWeight.Bold, FontStyle.Italic),
Font(R.font.lato_italic, FontWeight.Normal, FontStyle.Italic),
Font(R.font.lato_light, FontWeight.Light),
Font(R.font.lato_light_italic, FontWeight.Light, FontStyle.Italic),
Font(R.font.lato_regular, FontWeight.Normal),
Font(R.font.lato_thin, FontWeight.Thin),
Font(R.font.lato_thin_italic, FontWeight.Thin, FontStyle.Italic)
)

@Composable
fun BankCardUi() {
...
) {
Box {
BankCardBackground(baseColor = Color(0xFF1252C8))
BankCardNumber("1234567890123456")
}
}
}

...


@Composable
fun BankCardNumber(cardNumber: String) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalArrangement = Arrangement.SpaceBetween, // Space out the children evenly
verticalAlignment = Alignment.CenterVertically // Center the children vertically
) {
// Draw the first three groups of dots
repeat(3) {
BankCardDotGroup()
}

// Display the last four digits
Text(
text = cardNumber.takeLast(4),
style = TextStyle(
fontFamily = LatoFont,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
letterSpacing = 1.sp,
color = Color.White
)
)
}
}

@Composable
fun BankCardDotGroup() {
Canvas(
modifier = Modifier.width(48.dp),
onDraw = { // You can adjust the width as needed
val dotRadius = 4.dp.toPx()
val spaceBetweenDots = 8.dp.toPx()
for (i in 0 until 4) { // Draw four dots
drawCircle(
color = Color.White,
radius = dotRadius,
center = Offset(
x = i * (dotRadius * 2 + spaceBetweenDots) + dotRadius,
y = center.y
)
)
}
})
}

...

Defining the LatoFont

  • Each Font inside the FontFamily represents a different style and weight of the Lato font. This diversity allows us to stylize different text elements uniquely.

BankCardDotGroup Composable

  • Uses a Canvas() to draw four dots, representing the concealed part of the card number. Play around with the dotRadius and spaceBetweenDots to change the appearance of these dots.

BankCardNumber Composable

  • Designed to display the card number. Typically, bank cards showcase the number in groups, with the last group visible and the others represented as dots for security.
  • Uses a Row() composable for layout, providing even spacing and vertical alignment. Inside it, repeat the BankCardDotGroup() three times for the hidden digits, and then display the last four digits as text.

Feel free to experiment with different font weights or styles or adjust the positioning of the numbers and dots to suit your design preference.

Starting to look nicer.

BankCardUi Implementation Part 3

Step 4: Remaining UI

In this part, we will add the final UI details to our BankCardUi(), such as the card type and the cardholder information.

Add the SpaceWrapper() for convenient spacing and the BankCardLabelAndText() to add label-text sets to the bank card. TheBankCardUi() will now contain all the needed elements:

@Composable
fun BankCardUi() {
...
Box {
BankCardBackground(baseColor = Color(0xFF1252C8))
BankCardNumber("1234567890123456")
// Positioned to corner top left
SpaceWrapper(
modifier = Modifier.align(Alignment.TopStart),
space = 32.dp,
top = true,
left = true
) {
BankCardLabelAndText(label = "card holder", text = "John Doe")
}
// Positioned to corner bottom left
SpaceWrapper(
modifier = Modifier.align(Alignment.BottomStart),
space = 32.dp,
bottom = true,
left = true
) {
Row {
BankCardLabelAndText(label = "expires", text = "01/29")
Spacer(modifier = Modifier.width(16.dp))
BankCardLabelAndText(label = "cvv", text = "901")
}
}
// Positioned to corner bottom right
SpaceWrapper(
modifier = Modifier.align(Alignment.BottomEnd),
space = 32.dp,
bottom = true,
right = true
) {
// Feel free to use an image instead
Text(
text = "WISA", style = TextStyle(
fontFamily = LatoFont,
fontWeight = FontWeight.W500,
fontStyle = FontStyle.Italic,
fontSize = 22.sp,
letterSpacing = 1.sp,
color = Color.White
)
)
}
}
...
}

...

@Composable
fun BankCardLabelAndText(label: String, text: String) {
Column(
modifier = Modifier
.wrapContentSize(),
verticalArrangement = Arrangement.SpaceBetween
) {
Text(
text = label.uppercase(),
style = TextStyle(
fontFamily = LatoFont,
fontWeight = FontWeight.W300,
fontSize = 12.sp,
letterSpacing = 1.sp,
color = Color.White
)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = text,
style = TextStyle(
fontFamily = LatoFont,
fontWeight = FontWeight.W400,
fontSize = 16.sp,
letterSpacing = 1.sp,
color = Color.White
)
)
}
}

@Composable
fun SpaceWrapper(
modifier: Modifier = Modifier,
space: Dp,
top: Boolean = false,
right: Boolean = false,
bottom: Boolean = false,
left: Boolean = false,
content: @Composable BoxScope.() -> Unit
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
.then(if (top) Modifier.padding(top = space) else Modifier)
.then(if (right) Modifier.padding(end = space) else Modifier)
.then(if (bottom) Modifier.padding(bottom = space) else Modifier)
.then(if (left) Modifier.padding(start = space) else Modifier)
) {
content()
}
}

...

BankCardLabelAndText Composable

  • Displays a label and its corresponding text in a vertical arrangement.
  • Used in the BankCardUi() to present various pieces of information on the card, such as the cardholder's name, expiration date, and CVV.

SpaceWrapper Composable

  • Designed to provide convenient spacing around its children's elements. It allows you to add space to specific sides (top, right, bottom, left) and position its children accordingly.

By stitching all the pieces together, you now should see a cool digital bank card on your emulator or physical device.

BankCardUi Implementation Part 4

Part 5: Reusable Composable

The BankCardUi() works as expected. However, it relies on hardcoded values and needs to be customizable; thus, it is a non-working piece of code. To address this, add some parameters and make the BankCardUi() flexible.

@Composable
fun BankCardUi(
// Designing Flexible Composables
modifier: Modifier = Modifier, // Modifier as Parameter
baseColor: Color = Color(0xFF1252C8),
cardNumber: String = "",
cardHolder: String = "",
expires: String = "",
cvv: String = "",
brand: String = ""
) {
// Bank Card Aspect Ratio
val bankCardAspectRatio = 1.586f // (e.g., width:height = 85.60mm:53.98mm)
Card(
modifier = modifier
.fillMaxWidth()
// Aspect Ratio in Compose
.aspectRatio(bankCardAspectRatio),
elevation = CardDefaults.cardElevation(defaultElevation = 16.dp)
) {
Box {
BankCardBackground(baseColor = baseColor)
BankCardNumber(cardNumber = cardNumber)
// Positioned to corner top left
SpaceWrapper(
modifier = Modifier.align(Alignment.TopStart),
space = 32.dp,
top = true,
left = true
) {
BankCardLabelAndText(label = "card holder", text = cardHolder)
}
// Positioned to corner bottom left
SpaceWrapper(
modifier = Modifier.align(Alignment.BottomStart),
space = 32.dp,
bottom = true,
left = true
) {
Row {
BankCardLabelAndText(label = "expires", text = expires)
Spacer(modifier = Modifier.width(16.dp))
BankCardLabelAndText(label = "cvv", text = cvv)
}
}
// Positioned to corner bottom right
SpaceWrapper(
modifier = Modifier.align(Alignment.BottomEnd),
space = 32.dp,
bottom = true,
right = true
) {
// Feel free to use an image instead
Text(
text = brand, style = TextStyle(
fontFamily = LatoFont,
fontWeight = FontWeight.W500,
fontStyle = FontStyle.Italic,
fontSize = 22.sp,
letterSpacing = 1.sp,
color = Color.White
)
)
}
}
}
}

...

@Composable
@Preview
fun BankCardUiPreview() {
Box(Modifier.fillMaxSize().padding(16.dp)) {
BankCardUi(
modifier = Modifier.align(Alignment.Center),
baseColor = Color(0xFFFF9800),
cardNumber = "1234567890123456",
cardHolder = "John Doe",
expires = "01/29",
cvv = "901",
brand = "WISA"
)
}
}

Now that the BankCardUi() composable is ready, update the MainActivity.kt.

// your package com...

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Box(
Modifier
.fillMaxSize()
.padding(16.dp)) {
BankCardUi(
modifier = Modifier.align(Alignment.Center),
baseColor = Color(0xFFFF9800),
cardNumber = "1234567890123456",
cardHolder = "John Doe",
expires = "01/29",
cvv = "901",
brand = "WISA"
)
}
}
}
}
}

Designing Flexible Composables

  • It is always a good idea to create isolated and efficient composables with default parameter values and abstracted lambdas or data sources.
  • Avoid hardcoding dimensions and use external files for styles, dimensions, and strings.

Modifier as Parameter

  • Allowing a modifier parameter adds flexibility, especially for alignment within containers like Box().

Launch the application to witness the results firsthand. It’s remarkable to observe the ease and speed at which we can construct UIs like this using Compose, especially when contrasted with the more traditional XML approach.

BankCardUi Implementation Part 5

Wrapping Up

🔗 Access the Full Code: You’ve just mastered the art of the BankCardUi() composable — congratulations! To keep this valuable resource at your fingertips, visit this repository for the complete code.

👍 Your Feedback and Support Matter: Your insights are crucial in enhancing our tutorials, so if something seems unclear or if you have suggestions for improvement, please share your thoughts. Don’t forget to follow and leave some claps if you find this guide useful — your engagement helps us improve and makes this resource more visible to others in our learning community.

🔍 Elevate Your Android Expertise: Hungry for more knowledge? Embark on a comprehensive journey with my Ultimate Android Development Career Guide. This guide goes beyond mere learning; it’s a catalyst for career transformation.

🌟 Stay in the Loop: Keep an eye out for upcoming articles where we delve deeper into the realms of Android development. Until our paths cross again, embrace the joy of coding and keep innovating!

Deuk Services: Your Gateway to Leading Android Innovation

Are you looking to boost your business with top-tier Android solutions?Partner with Deuk services and take your projects to unparalleled heights.

--

--