Zalo (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Zalo’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, the asymmetric chat bubble (custom Shape), the chat thread, the mini-app launcher grid, and the blue header with motion and haptics.
Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (Zalo’s single blue anchor, asymmetric clipped-tail bubbles, calm chat backdrop, super-app launcher grid, Be Vietnam Pro typography) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar, a customShapefor the bubble tail,LazyVerticalGridfor the launcher,sp/dpinstead ofpt. Zalo is huge on Android in Vietnam, so this is a first-class target.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for avatars/media. Light-first; a full dark scheme is provided. No Material You dynamic color — the single Zalo Blue must hold regardless of wallpaper.
1. Color Tokens
// ui/theme/ZaloColors.kt
import androidx.compose.ui.graphics.Color
object ZaloColors {
// Brand (single anchor)
val Blue = Color(0xFF0068FF)
val BluePressed = Color(0xFF0052CC)
val BlueDeep = Color(0xFF0047B3)
val BubbleOut = Color(0xFFDBEBFF)
val BubbleOutInk = Color(0xFF14223A)
// Canvas & Surfaces (Light)
val Canvas = Color(0xFFFFFFFF)
val ChatBg = Color(0xFFE8ECF1)
val Surface1 = Color(0xFFF4F5F7)
val Surface2 = Color(0xFFEBEDF0)
val Divider = Color(0xFFE4E6EB)
// Canvas & Surfaces (Dark)
val DarkCanvas = Color(0xFF15171A)
val DarkChatBg = Color(0xFF101418)
val DarkBubbleIn = Color(0xFF1F2329)
val DarkBubbleOut = Color(0xFF0A3A7A)
val DarkDivider = Color(0xFF262A30)
// Text
val Ink = Color(0xFF1A1A1A)
val TextSecondary = Color(0xFF6B7280)
val TextTertiary = Color(0xFF9AA0AA)
val TextPrimaryDk = Color(0xFFECEDEF)
// Semantic
val Notify = Color(0xFFF5325B)
val Success = Color(0xFF18A957)
val Warning = Color(0xFFFF9500)
// Mini-app tile colors (contained to the launcher)
val TileGreen = Color(0xFF18A957)
val TileOrange = Color(0xFFFF9500)
val TileRed = Color(0xFFF5325B)
val TileViolet = Color(0xFF7B5CFF)
val TileCyan = Color(0xFF00B8D4)
}
Wire it into both schemes. Zalo is light-first; the dark scheme keeps the blue brand.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
private val ZaloLight = lightColorScheme(
primary = ZaloColors.Blue,
onPrimary = Color.White,
background = ZaloColors.Canvas,
onBackground = ZaloColors.Ink,
surface = ZaloColors.Canvas,
onSurface = ZaloColors.Ink,
surfaceVariant = ZaloColors.Surface1,
outline = ZaloColors.Divider,
error = ZaloColors.Notify,
)
private val ZaloDark = darkColorScheme(
primary = ZaloColors.Blue,
onPrimary = Color.White,
background = ZaloColors.DarkCanvas,
onBackground = ZaloColors.TextPrimaryDk,
surface = ZaloColors.DarkCanvas,
onSurface = ZaloColors.TextPrimaryDk,
surfaceVariant = ZaloColors.DarkChatBg,
outline = ZaloColors.DarkDivider,
error = ZaloColors.Notify,
)
@Composable
fun ZaloTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) ZaloDark else ZaloLight,
typography = ZaloTypography,
content = content,
)
2. Typography
Zalo uses Be Vietnam Pro — a sans designed for Vietnamese diacritics (SIL OFL). Drop the TTFs in res/font/. Give stacked tone marks (ậ ệ ọ ữ) generous line height; never clip the line box.
// ui/theme/ZaloType.kt
import androidx.compose.material3.Typography
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.FontWeight
import androidx.compose.ui.unit.sp
val BeVietnamPro = FontFamily(
Font(R.font.bevietnampro_regular, FontWeight.Normal),
Font(R.font.bevietnampro_medium, FontWeight.Medium),
Font(R.font.bevietnampro_semibold, FontWeight.SemiBold),
Font(R.font.bevietnampro_bold, FontWeight.Bold),
Font(R.font.bevietnampro_extrabold, FontWeight.ExtraBold),
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt -> sp 1:1), generous line heights for diacritics
object ZaloText {
val Display = TextStyle(BeVietnamPro, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = (-0.5).sp)
val ScreenTitle = TextStyle(BeVietnamPro, fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 31.sp, letterSpacing = (-0.3).sp)
val Section = TextStyle(BeVietnamPro, fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = (-0.2).sp)
val ListTitle = TextStyle(BeVietnamPro, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 23.sp)
val Body = TextStyle(BeVietnamPro, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp) // body + bubble
val CellTitle = TextStyle(BeVietnamPro, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 19.sp)
val Preview = TextStyle(BeVietnamPro, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 18.sp)
val Button = TextStyle(BeVietnamPro, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 15.sp)
val Timestamp = TextStyle(BeVietnamPro, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 14.sp)
val Tab = TextStyle(BeVietnamPro, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 10.sp)
val Badge = TextStyle(BeVietnamPro, fontWeight = FontWeight.Bold, fontSize = 9.sp, lineHeight = 9.sp)
val MiniLabel = TextStyle(BeVietnamPro, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 14.sp)
}
val ZaloTypography = Typography(
headlineLarge = ZaloText.Display,
headlineMedium = ZaloText.ScreenTitle,
titleLarge = ZaloText.Section,
titleMedium = ZaloText.ListTitle,
bodyMedium = ZaloText.Body,
labelSmall = ZaloText.Tab,
)
3. Signature Components
Asymmetric Bubble Shape
RoundedCornerShape takes per-corner radii directly — the tail is just one small bottom corner.
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp
fun bubbleShape(outgoing: Boolean) = RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = if (outgoing) 16.dp else 5.dp, // tail clipped for incoming
bottomEnd = if (outgoing) 5.dp else 16.dp, // tail clipped for outgoing
)
Chat Bubble
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
data class Msg(val text: String, val outgoing: Boolean, val time: String, val showAvatar: Boolean)
@Composable
fun ChatBubble(msg: Msg) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp),
horizontalArrangement = if (msg.outgoing) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom,
) {
if (!msg.outgoing) {
if (msg.showAvatar) {
Box(
Modifier.size(28.dp).clip(CircleShape)
.background(Brush.linearGradient(listOf(Color(0xFF4D9FFF), ZaloColors.BlueDeep)))
)
} else {
Spacer(Modifier.width(28.dp))
}
Spacer(Modifier.width(8.dp))
}
Column(horizontalAlignment = if (msg.outgoing) Alignment.End else Alignment.Start) {
val shape = bubbleShape(msg.outgoing)
Box(
Modifier
.widthIn(max = 260.dp)
.then(if (msg.outgoing) Modifier else Modifier.shadow(2.dp, shape, spotColor = Color.Black.copy(alpha = 0.06f)))
.clip(shape)
.background(if (msg.outgoing) ZaloColors.BubbleOut else ZaloColors.Canvas)
.padding(horizontal = 13.dp, vertical = 9.dp)
) {
Text(msg.text, style = ZaloText.Body, color = if (msg.outgoing) ZaloColors.BubbleOutInk else ZaloColors.Ink)
}
Text(msg.time, style = ZaloText.Timestamp.copy(fontSize = 10.sp), color = ZaloColors.TextTertiary,
modifier = Modifier.padding(top = 3.dp))
}
}
}
Chat Thread
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@Composable
fun ChatThread(messages: List<Msg>) {
LazyColumn(
Modifier.fillMaxSize().background(ZaloColors.ChatBg),
contentPadding = PaddingValues(vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
item {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
"Hôm nay · Today",
style = ZaloText.Timestamp,
color = ZaloColors.TextSecondary,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(Color.White.copy(alpha = 0.7f))
.padding(horizontal = 12.dp, vertical = 3.dp),
)
}
}
items(messages) { ChatBubble(it) }
item {
Text("Đã xem · Seen 09:27", style = ZaloText.Timestamp, color = ZaloColors.TextTertiary,
modifier = Modifier.fillMaxWidth().padding(end = 16.dp, top = 2.dp), textAlign = TextAlign.End)
}
}
}
Chat Header (Zalo Blue bar)
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.Icon
@Composable
fun ChatHeader(name: String, presence: String) {
Row(
Modifier.fillMaxWidth().background(ZaloColors.Blue).padding(start = 16.dp, end = 16.dp, top = 6.dp, bottom = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Filled.ArrowBack, "Back", tint = Color.White, modifier = Modifier.size(22.dp))
Box(Modifier.size(38.dp).clip(CircleShape).background(Brush.linearGradient(listOf(Color(0xFF4D9FFF), ZaloColors.BlueDeep))))
Column(Modifier.weight(1f)) {
Text(name, style = ZaloText.Body.copy(fontWeight = FontWeight.SemiBold), color = Color.White)
Text(presence, style = ZaloText.Timestamp.copy(fontWeight = FontWeight.Normal), color = Color.White.copy(alpha = 0.85f))
}
Icon(Icons.Filled.Call, "Call", tint = Color.White, modifier = Modifier.size(20.dp))
Icon(Icons.Filled.Videocam, "Video call", tint = Color.White, modifier = Modifier.size(20.dp))
}
}
Message Composer
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.outlined.EmojiEmotions
import androidx.compose.runtime.*
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun Composer(onSend: (String) -> Unit) {
var text by remember { mutableStateOf("") }
val haptics = LocalHapticFeedback.current
Row(
Modifier
.fillMaxWidth()
.background(ZaloColors.Canvas)
.padding(start = 14.dp, end = 14.dp, top = 9.dp, bottom = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(Icons.Outlined.EmojiEmotions, "Sticker", tint = ZaloColors.Blue, modifier = Modifier.size(23.dp))
Box(
Modifier
.weight(1f).height(38.dp)
.clip(RoundedCornerShape(19.dp))
.background(ZaloColors.Surface1)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterStart,
) {
BasicTextField(
value = text, onValueChange = { text = it },
textStyle = ZaloText.Preview.copy(color = ZaloColors.Ink),
decorationBox = { inner ->
if (text.isEmpty()) Text("Tin nhắn", style = ZaloText.Preview, color = ZaloColors.TextTertiary)
inner()
},
)
}
if (text.isEmpty()) {
Icon(Icons.Filled.PhotoCamera, "Camera", tint = ZaloColors.Blue, modifier = Modifier.size(22.dp))
} else {
Box(
Modifier.size(38.dp).clip(CircleShape).background(ZaloColors.Blue)
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // soft impact analog
onSend(text); text = ""
},
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.Send, "Send", tint = Color.White, modifier = Modifier.size(16.dp)) }
}
}
}
Mini-App Launcher Grid
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.ui.graphics.vector.ImageVector
data class MiniApp(val icon: ImageVector, val tile: Color, val label: String)
@Composable
fun LauncherGrid(apps: List<MiniApp>) {
LazyVerticalGrid(
columns = GridCells.Fixed(4),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
modifier = Modifier.background(ZaloColors.Canvas),
) {
items(apps) { a ->
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(7.dp)) {
Box(
Modifier.size(52.dp).clip(RoundedCornerShape(15.dp)).background(a.tile),
contentAlignment = Alignment.Center,
) { Icon(a.icon, a.label, tint = Color.White, modifier = Modifier.size(24.dp)) }
Text(a.label, style = ZaloText.MiniLabel, color = ZaloColors.Ink, maxLines = 2, textAlign = TextAlign.Center)
}
}
}
}
Chat List Row
@Composable
fun ChatListRow(name: String, preview: String, time: String, unread: Int, online: Boolean) {
Row(
Modifier
.fillMaxWidth().height(72.dp).padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box {
Box(Modifier.size(48.dp).clip(CircleShape).background(Brush.linearGradient(listOf(Color(0xFF4D9FFF), ZaloColors.BlueDeep))))
if (online) {
Box(
Modifier.size(10.dp).align(Alignment.BottomEnd).clip(CircleShape)
.background(ZaloColors.Success).border(2.dp, Color.White, CircleShape)
)
}
}
Column(Modifier.weight(1f)) {
Text(name, style = ZaloText.ListTitle.copy(fontWeight = if (unread > 0) FontWeight.Bold else FontWeight.SemiBold), color = ZaloColors.Ink)
Text(preview, style = ZaloText.Preview, color = if (unread > 0) ZaloColors.Ink else ZaloColors.TextSecondary, maxLines = 1)
}
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(time, style = ZaloText.Timestamp, color = ZaloColors.TextTertiary)
if (unread > 0) {
Box(
Modifier.defaultMinSize(minWidth = 16.dp, minHeight = 16.dp).clip(RoundedCornerShape(999.dp))
.background(ZaloColors.Notify).padding(horizontal = 5.dp),
contentAlignment = Alignment.Center,
) { Text("$unread", style = ZaloText.Badge, color = Color.White) }
}
}
}
HorizontalDivider(color = ZaloColors.Divider, thickness = 0.5.dp, modifier = Modifier.padding(start = 76.dp))
}
4. Navigation
Zalo’s bottom bar has 5 Vietnamese-labeled tabs; active is Zalo Blue (filled icon), no Material tint pill.
@Composable
fun ZaloBottomBar(selected: Int, onSelect: (Int) -> Unit, unread: Int) {
NavigationBar(containerColor = ZaloColors.Canvas, tonalElevation = 0.dp) {
val items = listOf(
"Tin nhắn" to Icons.Filled.ChatBubble,
"Danh bạ" to Icons.Filled.People,
"Khám phá" to Icons.Filled.GridView,
"Nhật ký" to Icons.Filled.Schedule,
"Cá nhân" to Icons.Filled.AccountCircle,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = {
if (i == 0 && unread > 0) {
BadgedBox(badge = { Badge(containerColor = ZaloColors.Notify) { Text("$unread", style = ZaloText.Badge, color = Color.White) } }) {
Icon(icon, label, modifier = Modifier.size(23.dp))
}
} else {
Icon(icon, label, modifier = Modifier.size(23.dp))
}
},
label = { Text(label, style = ZaloText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = ZaloColors.Blue,
selectedTextColor = ZaloColors.Blue,
unselectedIconColor = ZaloColors.TextTertiary,
unselectedTextColor = ZaloColors.TextTertiary,
indicatorColor = Color.Transparent, // no Material pill — Zalo has none
),
)
}
}
}
The chat top bar is the solid blue ChatHeader above; it persists across destinations (keep the blue background on the scaffold top region so there’s no color flash on navigation).
5. Motion
Zalo motion is functional and quick — it’s a dense utility super-app.
| Moment | Compose recipe |
|---|---|
| Outgoing bubble send | AnimatedVisibility scaleIn(initialScale = 0.9f, tween(200)) + fadeIn(tween(200)); soft haptic |
| Incoming bubble | AnimatedVisibility slideInVertically { it / 4 } + fadeIn tween(200); typing = 3 dots looped infiniteRepeatable |
| Read receipt | AnimatedVisibility fadeIn(tween(150)) under the last outgoing bubble |
| Tab change | instant content swap; icon outline→filled + tint handled by NavigationBarItem colors |
| Header transition | Nav3/NavHost slide push tween(300); keep the blue scaffold region so the bar doesn’t flash |
| Launcher tile press | Modifier.scale 1.0 → 0.94 via animateFloatAsState(tween(100)) on press, springs back |
| Reaction picker | long-press bubble -> emoji row scaleIn(tween(180)) + fadeIn; soft haptic |
| Zalo Pay confirm | Success checkmark scaleIn(tween(200)) + snackbar |
// Outgoing bubble — the canonical Zalo send motion
AnimatedVisibility(
visible = true,
enter = scaleIn(initialScale = 0.9f, animationSpec = tween(200)) + fadeIn(tween(200)),
) { ChatBubble(msg) }
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft send impact and reaction-picker open; HapticFeedbackConstants.CLOCK_TICK (via LocalView) for tab change; HapticFeedbackConstants.CONFIRM for Zalo Pay / friend-add. The typing indicator loops via rememberInfiniteTransition.
6. Icons
The closest first-party set is androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Tin nhắn (tab) | message / message.fill |
Icons.Filled.ChatBubble |
| Danh bạ (tab) | person.2 / person.2.fill |
Icons.Filled.People |
| Khám phá (tab) | square.grid.2x2 |
Icons.Filled.GridView |
| Nhật ký (tab) | clock / clock.fill |
Icons.Filled.Schedule |
| Cá nhân (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
| Back | chevron.left |
Icons.Filled.ArrowBack |
| Voice call | phone.fill |
Icons.Filled.Call |
| Video call | video.fill |
Icons.Filled.Videocam |
| Send | paperplane.fill |
Icons.Filled.Send |
| Sticker / emoji | face.smiling |
Icons.Outlined.EmojiEmotions |
| Camera | camera.fill |
Icons.Filled.PhotoCamera |
| Attachment | plus / paperclip |
Icons.Filled.AttachFile |
| Search | magnifyingglass |
Icons.Filled.Search |
| Like (timeline) | heart.fill |
Icons.Filled.Favorite (#F5325B) |
| Verified (OA) | checkmark.seal.fill |
Icons.Filled.Verified (#0068FF) |
| Settings | gearshape.fill |
Icons.Filled.Settings |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor 21; Zalo’s audience is overwhelmingly Android in Vietnam — keep
minSdklow but 24 is comfortable for modern motion).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. Coil2.6+. - Edge-to-edge: call
enableEdgeToEdge(); the Zalo Blue header should draw under the status bar — keep light-content system-bar icons even in dark mode. The composer and tab bar respect the navigation-bar inset; the thread usesModifier.imePadding()so it scrolls clear of the keyboard. - Font scaling:
sphonors the user’s font scale — keep it on display, titles, body, bubbles, previews. Pin layout-sensitive text (timestamps, 9sp badges, 10sp tab labels, mini-app captions) but never clip Vietnamese diacritics — let chat rows and bubbles grow vertically; the generouslineHeights above protect stacked tone marks. - TalkBack: label bubbles “{incoming/outgoing} message: {text}, {time}”; chat rows “{name}, {preview}, {unread} unread messages”; the send button “Send message”; launcher tiles “{label}, opens mini-app”; expose day separators as headings via
Modifier.semantics { heading() }. - Touch targets: Material guidance is 48.dp — the 20-22dp header/composer icons and 23dp tab icons get 48dp hit areas; chat list rows are 72dp; launcher tiles are 52dp with a 64dp effective hit; the send circle is 38dp + padding.
- Contrast: white on
#0068FFpasses AA;#1A1A1Aon#FFFFFFand#14223Aon#DBEBFFpass AA for bubble text; the red#F5325Bbadge uses white text and passes AA. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable the bubble scale-in (plain fade) and the reaction-picker zoom; replace the looping typing dots with a static “Đang soạn tin…” label; keep selection states. - Dark mode: light-first; the dark scheme flips the chat backdrop to
#101418, incoming bubbles to#1F2329, outgoing to#0A3A7A(white text); the asymmetric clipped tail and the blue header/active tab are preserved; drop the incoming-bubble shadow on dark. Do not enabledynamicColorScheme()— the single Zalo Blue must hold regardless of wallpaper.