Viber (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Viber’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the bubble + three-state receipt + free-call banner, 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 (Viber’s single purple brand, the gray/purple bubble pair, the gray→gray→violet check receipt, the call-first chrome, the sticker market) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar, aPopupfor reactions,sp/dp. Viber is light-first; an aubergine-tinted dark scheme is provided.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for avatars / stickers.
1. Color Tokens
// ui/theme/ViberColors.kt
import androidx.compose.ui.graphics.Color
object ViberColors {
// Brand purple
val Purple = Color(0xFF7360F2)
val PurpleDeep = Color(0xFF665CAC)
val PurplePressed = Color(0xFF5B4ACC)
val Violet = Color(0xFF8F7DF7)
val BannerDeep = Color(0xFF59267C)
// Canvas & Surfaces (Light)
val Canvas = Color(0xFFFFFFFF)
val Incoming = Color(0xFFEDEBF5)
val SurfaceGray = Color(0xFFF4F3F8)
val RowPressed = Color(0xFFE9E7F2)
val Divider = Color(0xFFE4E2EC)
// Canvas & Surfaces (Dark) — aubergine-tinted, NOT neutral
val DarkCanvas = Color(0xFF121118)
val DarkSurface1 = Color(0xFF1C1A24)
val DarkSurface2 = Color(0xFF26232F)
val DarkDivider = Color(0xFF322F3C)
// Text
val TextPrimary = Color(0xFF1A1825)
val TextPrimaryDark = Color(0xFFF3F1F8)
val OnPurple = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFF7D7A8C)
val TextSecondaryDark = Color(0xFF9D98AC)
val TextTertiary = Color(0xFFA8A5B5)
val TextTertiaryDark = Color(0xFF6C6779)
// Semantic
val Green = Color(0xFF46C26A)
val Red = Color(0xFFF0506E)
val Link = Color(0xFF7360F2)
}
Wire it into both schemes. Viber is light-first; dark uses the aubergine-tinted #121118, never neutral gray.
// 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 ViberLight = lightColorScheme(
primary = ViberColors.Purple,
onPrimary = ViberColors.OnPurple,
background = ViberColors.Canvas,
onBackground = ViberColors.TextPrimary,
surface = ViberColors.SurfaceGray,
onSurface = ViberColors.TextPrimary,
surfaceVariant = ViberColors.Incoming,
outline = ViberColors.Divider,
error = ViberColors.Red,
)
private val ViberDark = darkColorScheme(
primary = ViberColors.Purple, // brand purple holds in dark
onPrimary = ViberColors.OnPurple,
background = ViberColors.DarkCanvas,
onBackground = ViberColors.TextPrimaryDark,
surface = ViberColors.DarkSurface1,
onSurface = ViberColors.TextPrimaryDark,
surfaceVariant = ViberColors.DarkSurface2,
outline = ViberColors.DarkDivider,
error = ViberColors.Red,
)
@Composable
fun ViberTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) ViberDark else ViberLight,
typography = ViberTypography,
content = content,
)
2. Typography (M3)
Viber’s brand face is proprietary; bundle Manrope (SIL OFL) in res/font/ as the closest free substitute. Heavy 700/800 titles/buttons give the friendly feel; body stays 400. pt → sp 1:1.
// ui/theme/ViberType.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 Manrope = FontFamily(
Font(R.font.manrope_regular, FontWeight.Normal),
Font(R.font.manrope_medium, FontWeight.Medium),
Font(R.font.manrope_semibold, FontWeight.SemiBold),
Font(R.font.manrope_bold, FontWeight.Bold),
Font(R.font.manrope_extrabold, FontWeight.ExtraBold),
)
object ViberText {
val LargeTitle = TextStyle(Manrope, fontWeight = FontWeight.ExtraBold, fontSize = 32.sp, lineHeight = 38.sp, letterSpacing = (-0.4).sp)
val Title1 = TextStyle(Manrope, fontWeight = FontWeight.ExtraBold, fontSize = 26.sp, lineHeight = 31.sp, letterSpacing = (-0.3).sp)
val Title3 = TextStyle(Manrope, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 27.sp, letterSpacing = (-0.2).sp)
val Headline = TextStyle(Manrope, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 22.sp, letterSpacing = (-0.2).sp)
val Body = TextStyle(Manrope, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 20.sp)
val BodyEmph = TextStyle(Manrope, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 20.sp)
val Preview = TextStyle(Manrope, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 19.sp)
val Footnote = TextStyle(Manrope, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 18.sp)
val Caption = TextStyle(Manrope, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 15.sp, letterSpacing = 0.1.sp)
val Button = TextStyle(Manrope, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp)
val Tab = TextStyle(Manrope, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.1.sp)
val Badge = TextStyle(Manrope, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 11.sp)
}
val ViberTypography = Typography(
headlineLarge = ViberText.LargeTitle,
headlineMedium = ViberText.Title1,
titleMedium = ViberText.Headline,
bodyLarge = ViberText.Body,
labelSmall = ViberText.Tab,
)
3. Signature Components
Message Bubble (in / out, with tail)
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
enum class ReceiptState { Sending, Sent, Delivered, Seen, Failed }
@Composable
fun ViberBubble(text: String, outgoing: Boolean, tailEnd: Boolean, timestamp: String, receipt: ReceiptState, dark: Boolean) {
val shape = RoundedCornerShape(
topStart = 16.dp, topEnd = 16.dp,
bottomStart = if (!outgoing && tailEnd) 5.dp else 16.dp,
bottomEnd = if ( outgoing && tailEnd) 5.dp else 16.dp,
)
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = if (outgoing) Alignment.End else Alignment.Start,
) {
Box(
Modifier
.fillMaxWidth(0.80f)
.wrapContentWidth(if (outgoing) Alignment.End else Alignment.Start)
.clip(shape)
.background(if (outgoing) ViberColors.Purple else if (dark) ViberColors.DarkSurface2 else ViberColors.Incoming)
.padding(horizontal = 14.dp, vertical = 9.dp)
) {
Text(text, style = ViberText.Body,
color = if (outgoing) ViberColors.OnPurple else if (dark) ViberColors.TextPrimaryDark else ViberColors.TextPrimary)
}
if (outgoing) {
Row(Modifier.padding(top = 3.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
Text(timestamp, fontSize = 10.sp, color = if (dark) ViberColors.TextSecondaryDark else ViberColors.TextSecondary)
ReceiptCheck(receipt, dark)
}
}
}
}
Three-State Check Receipt
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Schedule
import androidx.compose.material.icons.filled.ErrorOutline
import androidx.compose.material3.Icon
@Composable
fun ReceiptCheck(state: ReceiptState, dark: Boolean) {
val color = when (state) {
ReceiptState.Sending, ReceiptState.Sent -> if (dark) ViberColors.TextTertiaryDark else ViberColors.TextTertiary
ReceiptState.Delivered -> if (dark) ViberColors.TextSecondaryDark else ViberColors.TextSecondary
ReceiptState.Seen -> ViberColors.Violet
ReceiptState.Failed -> ViberColors.Red
}
val icon = when (state) {
ReceiptState.Sending -> Icons.Filled.Schedule
ReceiptState.Sent -> Icons.Filled.Check
ReceiptState.Delivered, ReceiptState.Seen -> Icons.Filled.DoneAll
ReceiptState.Failed -> Icons.Filled.ErrorOutline
}
Icon(icon, contentDescription = state.name, tint = color, modifier = Modifier.size(if (state == ReceiptState.Delivered || state == ReceiptState.Seen) 16.dp else 13.dp))
}
Free Viber Call Banner
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.material.icons.filled.Call
import androidx.compose.foundation.clickable
@Composable
fun FreeCallBanner(name: String, onCall: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp)
.padding(top = 10.dp)
.shadow(8.dp, RoundedCornerShape(14.dp), spotColor = ViberColors.Purple.copy(alpha = 0.10f))
.clip(RoundedCornerShape(14.dp))
.background(Brush.linearGradient(listOf(ViberColors.Purple, ViberColors.BannerDeep)))
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(Icons.Filled.Call, null, tint = Color(0xFFFFFFFF), modifier = Modifier.size(18.dp))
Column(Modifier.weight(1f)) {
Text("Free Viber Call", color = Color(0xFFFFFFFF), fontSize = 13.sp, fontWeight = FontWeight.Bold)
Text("Call $name free over Viber-to-Viber", color = Color(0xCCFFFFFF), fontSize = 11.sp)
}
Box(
Modifier.clip(RoundedCornerShape(999.dp)).background(Color(0x38FFFFFF)).clickable { onCall() }
.padding(horizontal = 12.dp, vertical = 6.dp)
) { Text("Call", color = Color(0xFFFFFFFF), fontSize = 12.sp, fontWeight = FontWeight.Bold) }
}
}
In-Thread Sticker
@Composable
fun StickerMessage(outgoing: Boolean, timestamp: String, receipt: ReceiptState, dark: Boolean) {
Column(Modifier.fillMaxWidth(), horizontalAlignment = if (outgoing) Alignment.End else Alignment.Start) {
// Replace with Coil GIF / Lottie animated sticker
Box(
Modifier.size(96.dp).clip(RoundedCornerShape(20.dp))
.background(Brush.radialGradient(listOf(ViberColors.Violet, ViberColors.BannerDeep)))
)
if (outgoing) {
Row(Modifier.padding(top = 3.dp, end = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
Text(timestamp, fontSize = 10.sp, color = if (dark) ViberColors.TextSecondaryDark else ViberColors.TextSecondary)
ReceiptCheck(receipt, dark)
}
}
}
}
4. Navigation
Viber is call-first: phone + video in every chat header, a dedicated Calls tab. On Android model the strip as a NavigationBar with no tint pill — active is brand purple.
import androidx.compose.material3.*
import androidx.compose.material.icons.filled.*
@Composable
fun ViberBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = ViberColors.Canvas, tonalElevation = 0.dp) {
val items = listOf(
"Chats" to Icons.Filled.ChatBubble,
"Calls" to Icons.Filled.Call,
"Explore" to Icons.Filled.Mood,
"More" to Icons.Filled.Person,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = {
if (label == "Explore") BadgedBox(badge = { Badge(containerColor = ViberColors.Red) { Text("3") } }) {
Icon(icon, label, Modifier.size(22.dp))
} else Icon(icon, label, Modifier.size(22.dp))
},
label = { Text(label, style = ViberText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = ViberColors.Purple,
selectedTextColor = ViberColors.Purple,
unselectedIconColor = ViberColors.TextTertiary,
unselectedTextColor = ViberColors.TextTertiary,
indicatorColor = Color.Transparent, // no Material pill — Viber has none
),
)
}
}
}
The chat header is a Row { back ChevronLeft tinted Purple, 36.dp gradient avatar, Column(name Headline, “online” Caption tinted Green), spacer, Call 22.dp + Videocam 22.dp tinted Purple } in a TopAppBar — call icons always present (call-first DNA).
5. Motion
Viber motion is soft spring, 0.2–0.32s.
| Moment | Compose recipe |
|---|---|
| Outgoing bubble send | new bubble slideInVertically { it } + scaleIn(initialScale = 0.85f, spring(dampingRatio = 0.78f)) |
| Receipt sent → delivered → seen | Crossfade(receipt, tween(200)); on Seen add animateFloatAsState scale 1 → 1.15 → 1 pulse + recolor to Violet |
| Reactions strip | long-press detectTapGestures(onLongPress) → Popup scaleIn(0.7f) + fadeIn tween(180) |
| Sticker play | Coil ImageDecoderDecoder GIF / Lottie auto-loop ~2s |
| Sticker market open | AnimatedVisibility slideInVertically { it } + fadeIn tween(300) |
| Incoming call | full purple Brush.linearGradient fadeIn(300); accept/decline scaleIn(spring()) |
| Tab switch | instant; icon tint animateColorAsState tween(150) |
// Canonical outgoing-bubble entrance
AnimatedVisibility(
visible = true,
enter = slideInVertically(spring(dampingRatio = 0.78f)) { it / 2 } +
scaleIn(initialScale = 0.85f, animationSpec = spring(dampingRatio = 0.78f)),
) { ViberBubble(/* … */) }
Haptics: LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for send and reaction dock; HapticFeedbackConstants.CONFIRM (via LocalView) on call connect; HapticFeedbackConstants.REJECT on decline. Receipt transitions are silent (visual only).
6. Icons
Viber uses a custom brand glyph set; the closest first-party Compose set is androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Back | chevron.left |
Icons.Filled.ChevronLeft |
| Voice call | phone |
Icons.Filled.Call |
| Video call | video |
Icons.Filled.Videocam |
| Attachment | plus |
Icons.Filled.Add |
| Emoji / sticker | face.smiling |
Icons.Filled.Mood |
| Send | paperplane.fill |
Icons.Filled.Send |
| Voice message | mic.fill |
Icons.Filled.Mic |
| Sent | checkmark |
Icons.Filled.Check |
| Delivered / Seen | checkmark ×2 |
Icons.Filled.DoneAll |
| Sending | clock |
Icons.Filled.Schedule |
| Failed | exclamationmark.circle.fill |
Icons.Filled.ErrorOutline |
| Chats (tab) | bubble.left.fill |
Icons.Filled.ChatBubble |
| Calls (tab) | phone.fill |
Icons.Filled.Call |
| Explore (tab) | smiley |
Icons.Filled.Mood |
| More (tab) | person.crop.circle |
Icons.Filled.Person |
| Search | magnifyingglass |
Icons.Filled.Search |
| Reaction | hand.thumbsup.fill |
Icons.Filled.ThumbUp |
| Secret message | timer |
Icons.Filled.Timer |
7. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor 21;
Popup+ spring comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge:
enableEdgeToEdge(); white canvas wants dark system-bar content (light-content on the aubergine dark canvas). Compose bar above the IME —Modifier.imePadding()on the threadLazyColumn. - Font: Viber’s brand face is proprietary — bundle Manrope (SIL OFL) in
res/font/; keep ExtraBold/Bold for titles/buttons.sphonors the user’s font scale. - Font scaling: scale titles/headline/body/previews; pin layout-sensitive text (timestamps, receipt glyphs, 11sp tab labels, badges, presence, sticker artwork) via a fixed-
fontScaleLocalDensity. - TalkBack: label outgoing bubbles “You said {text}, {receipt}”; the receipt is conveyed by glyph shape (clock / 1 check / 2 checks) AND color, so it survives color-blindness; expose Reactions via
Modifier.semantics { customActions = … }; label stickers by pack/emoji name. - Touch targets: Material guidance 48.dp — give the 22.dp header call icons, 22.dp compose icons, and 28.dp reaction glyphs a 48.dp hit box; the 36.dp send button and list rows are full-press.
- Contrast: white on
#7360F2passes WCAG AA; primary text on#EDEBF5/#26232Fpasses AA; the violet seen-check is paired with the double-check shape. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, disable bubble spring-in (crossfade), stop sticker auto-loop (first frame, play on tap), disable the seen-pulse. - Dark mode: invert via the
Dark*palette — aubergine#121118, NOT neutral gray; brand#7360F2holds; incoming →#26232F, text →#F3F1F8. Shadows fade on dark, so add a 1dpDarkDividerborder to floating menus (reactions, context) as the elevation cue. Do not enable Material YoudynamicColorScheme()— Viber’s single-purple identity must hold regardless of wallpaper.