Android Compose in Action: Developing a Dynamic Bank Card UI
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
andheight
. - 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 inheight
. - The formula for the aspect ratio is
width / height
. So, for a standard bank card, it is85.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 ourCard
composable. This tells Compose to maintain this ratio between the card'swidth
andheight
. - 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!
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 usinghslToColor()
.
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, theCanvas
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
andcolorSaturation50
). These saturation levels were obtained by manipulating thebaseColor
using thesetSaturation
function. - The
center
parameter ofdrawCircle
determines the position of each circle on the canvas. It's calculated as a fraction of the canvas size (size.width
andsize.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.
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.
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 Lato
Font
- Each
Font
inside theFontFamily
represents a different style and weight of theLato
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 thedotRadius
andspaceBetweenDots
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 theBankCardDotGroup()
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.
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.
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.
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.