Companion to DESIGN.md — the framework-neutral spec. This file ports LINE's visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, Modifiers, 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 (LINE's periwinkle chat backdrop, pale-green outgoing bubbles, the bubble-less oversized sticker with a bounce-in, the official-account badge, the super-app tab bar) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar, a custom tailShape,sp/dpinstead ofpt. (LINE's real Android app is itself Material; this maps the iOS spec onto modern Compose.)
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for avatars and stickers.
// ui/theme/LineColors.kt
import androidx.compose.ui.graphics.Color
object LineColors {
// Chat backdrop & canvas
val Backdrop = Color(0xFF8CABD9) // periwinkle (themeable) — NOT white
val BackdropDk = Color(0xFF1A2230)
val Canvas = Color(0xFFFFFFFF)
val Surface = Color(0xFFF7F7F7)
val Divider = Color(0xFFE5E5E5)
// Text
val TextPrimary = Color(0xFF000000)
val TextSecondary = Color(0xFF8C8C8C)
val TextTertiary = Color(0xFFB3B3B3)
// Brand
val Green = Color(0xFF06C755)
val GreenPressed = Color(0xFF05A647)
val GreenWash = Color(0xFFF1FBF3)
// Message
val Incoming = Color(0xFFFFFFFF)
val Outgoing = Color(0xFFC5F0B1) // pale chat-green — NOT the brand green
// Semantic
val Notif = Color(0xFFFF334B)
val Link = Color(0xFF1F8FFF)
// Dark (system-dark users)
val DarkCanvas = Color(0xFF1E1E1E)
val DarkSurface = Color(0xFF2A2A2A)
val DarkIncoming = Color(0xFF2A2A2A)
val DarkTextPrimary = Color(0xFFEDEDED)
val DarkTextSecondary = Color(0xFF9A9A9A)
}Wire it into Material 3 schemes so ripples, dividers, and stock component colors inherit the brand. LINE is light/themeable-first; provide a dark scheme for system-dark users.
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.foundation.isSystemInDarkTheme
private val LineLight = lightColorScheme(
primary = LineColors.Green,
onPrimary = Color.White,
background = LineColors.Canvas,
onBackground = LineColors.TextPrimary,
surface = LineColors.Canvas,
onSurface = LineColors.TextPrimary,
surfaceVariant = LineColors.Surface,
outline = LineColors.Divider,
error = LineColors.Notif,
)
private val LineDark = darkColorScheme(
primary = LineColors.Green,
onPrimary = Color.White,
background = LineColors.DarkCanvas,
onBackground = LineColors.DarkTextPrimary,
surface = LineColors.DarkSurface,
onSurface = LineColors.DarkTextPrimary,
surfaceVariant = LineColors.DarkSurface,
outline = Color(0xFF3A3A3A),
error = LineColors.Notif,
)
@Composable
fun LineTheme(content: @Composable () -> Unit) =
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) LineDark else LineLight,
typography = LineTypography,
content = content,
)LINE uses Noto Sans at weights 400/700 (no semibold tier). Drop the TTFs in res/font/; Noto Sans CJK covers East-Asian glyphs.
// ui/theme/LineType.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 NotoSans = FontFamily(
Font(R.font.noto_sans_regular, FontWeight.Normal), // 400
Font(R.font.noto_sans_bold, FontWeight.Bold), // 700
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object LineText {
val LargeTitle = TextStyle(NotoSans, fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 29.sp)
val FriendName = TextStyle(NotoSans, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 21.sp)
val ThreadTitle = TextStyle(NotoSans, fontWeight = FontWeight.Bold, fontSize = 17.sp, lineHeight = 21.sp)
val MessageBody = TextStyle(NotoSans, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 23.sp) // CJK-friendly 1.45
val Status = TextStyle(NotoSans, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 17.sp)
val Preview = TextStyle(NotoSans, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 18.sp)
val Section = TextStyle(NotoSans, fontWeight = FontWeight.Bold, fontSize = 13.sp, lineHeight = 16.sp, letterSpacing = 0.2.sp)
val Timestamp = TextStyle(NotoSans, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 14.sp)
val ReadLabel = TextStyle(NotoSans, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 13.sp)
val Button = TextStyle(NotoSans, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp)
val ButtonText = TextStyle(NotoSans, fontWeight = FontWeight.Bold, fontSize = 15.sp, lineHeight = 15.sp)
val Tab = TextStyle(NotoSans, fontWeight = FontWeight.Normal, fontSize = 10.sp, lineHeight = 12.sp)
val Badge = TextStyle(NotoSans, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 11.sp)
val StickerPx = TextStyle(NotoSans, fontWeight = FontWeight.Bold, fontSize = 13.sp, lineHeight = 13.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val LineTypography = Typography(
headlineSmall = LineText.LargeTitle,
titleLarge = LineText.ThreadTitle,
bodyLarge = LineText.MessageBody,
titleMedium = LineText.FriendName,
labelSmall = LineText.Tab,
)import androidx.compose.animation.core.*
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
@Composable
fun StickerMessage(
stickerUrl: String,
time: String,
isOutgoing: Boolean,
read: Boolean,
) {
// bounce-in: 0.6 → 1.08 → 1.0
val scale = remember { Animatable(0.6f) }
LaunchedEffect(Unit) {
scale.animateTo(1.08f, spring(dampingRatio = 0.55f, stiffness = 240f))
scale.animateTo(1f, spring(dampingRatio = 0.7f))
}
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp), // needs air
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom,
) {
@Composable fun Meta() = Column(
horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start,
) {
if (isOutgoing && read) Text("Read", style = LineText.ReadLabel, color = LineColors.TextSecondary)
Text(time, style = LineText.Timestamp, color = LineColors.TextSecondary)
}
if (isOutgoing) { Meta(); Spacer(Modifier.width(6.dp)) }
// bubble-less, oversized — directly on the backdrop
AsyncImage(
model = stickerUrl,
contentDescription = "Sticker", // give a meaningful label in production
modifier = Modifier.size(140.dp).scale(scale.value),
)
if (!isOutgoing) { Spacer(Modifier.width(6.dp)); Meta() }
}
}import androidx.compose.foundation.background
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.material3.Text
@Composable
fun TextBubble(
text: String,
time: String,
isOutgoing: Boolean,
read: Boolean,
) {
// 16dp radius with a 4dp notch on the avatar-side top corner
val shape = RoundedCornerShape(
topStart = if (isOutgoing) 16.dp else 4.dp,
topEnd = if (isOutgoing) 4.dp else 16.dp,
bottomStart = 16.dp, bottomEnd = 16.dp,
)
Row(
Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom,
) {
@Composable fun Meta() = Column(
horizontalAlignment = if (isOutgoing) Alignment.End else Alignment.Start,
) {
if (isOutgoing && read) Text("Read", style = LineText.ReadLabel, color = LineColors.TextSecondary)
Text(time, style = LineText.Timestamp, color = LineColors.TextSecondary)
}
if (isOutgoing) { Meta(); Spacer(Modifier.width(6.dp)) }
Text(
text,
style = LineText.MessageBody,
color = LineColors.TextPrimary,
modifier = Modifier
.widthIn(max = 260.dp)
.shadow(1.dp, shape, spotColor = Color.Black.copy(alpha = 0.06f)) // lift off periwinkle
.clip(shape)
.background(if (isOutgoing) LineColors.Outgoing else LineColors.Incoming)
.padding(horizontal = 12.dp, vertical = 9.dp),
)
if (!isOutgoing) { Spacer(Modifier.width(6.dp)); Meta() }
}
}import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.Icon
@Composable
fun FriendRow(
name: String,
status: String,
time: String,
official: Boolean,
) {
Row(
Modifier
.fillMaxWidth()
.height(68.dp)
.background(LineColors.Canvas)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.size(52.dp).clip(CircleShape).background(LineColors.Surface))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Text(name, style = LineText.FriendName, color = LineColors.TextPrimary)
if (official) {
// LINE's verified/business trust primitive
Icon(Icons.Filled.Verified, contentDescription = "Official account, verified",
tint = LineColors.Green, modifier = Modifier.size(14.dp))
}
}
Text(status, style = LineText.Status, color = LineColors.TextSecondary, maxLines = 1)
}
Text(time, style = LineText.Timestamp, color = LineColors.TextSecondary)
}
}import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.border
@Composable
fun StickerShopTile(
previewUrl: String,
title: String,
author: String,
price: String, // "120" or "Free"
) {
Column(
Modifier
.clip(RoundedCornerShape(12.dp))
.background(LineColors.Surface)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
AsyncImage(
model = previewUrl, contentDescription = title,
modifier = Modifier.fillMaxWidth().height(96.dp)
.clip(RoundedCornerShape(8.dp)).background(Color.White),
)
Text(title, style = LineText.Section, color = LineColors.TextPrimary)
Text(author, style = LineText.Timestamp, color = LineColors.TextSecondary)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
Box(
Modifier
.clip(CircleShape)
.border(BorderStroke(1.dp, LineColors.Green), CircleShape)
.padding(horizontal = 14.dp, vertical = 6.dp),
) {
Text(if (price == "Free") "Free" else "🪙 $price",
style = LineText.StickerPx, color = LineColors.Green)
}
}
}
}import androidx.compose.foundation.clickable
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.outlined.EmojiEmotions
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun ComposerBar() {
var text by remember { mutableStateOf("") }
val hasText = text.trim().isNotEmpty()
val haptics = LocalHapticFeedback.current
Row(
Modifier.fillMaxWidth().background(LineColors.Backdrop).padding(8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Icon(Icons.Filled.Add, contentDescription = "More", tint = Color.White,
modifier = Modifier.size(24.dp))
Row(
Modifier
.weight(1f)
.heightIn(min = 36.dp)
.clip(RoundedCornerShape(18.dp))
.background(Color.White)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
BasicTextField(
value = text, onValueChange = { text = it },
textStyle = LineText.MessageBody.copy(color = LineColors.TextPrimary),
modifier = Modifier.weight(1f).padding(vertical = 8.dp),
decorationBox = { inner ->
if (text.isEmpty()) Text("Aa", style = LineText.MessageBody,
color = LineColors.TextTertiary)
inner()
},
)
// sticker keyboard — a primary input mode in LINE
Icon(Icons.Outlined.EmojiEmotions, contentDescription = "Stickers",
tint = LineColors.TextSecondary, modifier = Modifier.size(22.dp))
Icon(Icons.Outlined.PhotoCamera, contentDescription = "Camera",
tint = LineColors.TextSecondary, modifier = Modifier.size(20.dp))
}
Box(
Modifier.size(30.dp).clickable {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
text = ""
},
contentAlignment = Alignment.Center,
) {
if (hasText) {
Box(Modifier.size(30.dp).clip(CircleShape).background(LineColors.Green),
contentAlignment = Alignment.Center) {
Icon(Icons.Filled.ArrowUpward, contentDescription = "Send",
tint = Color.White, modifier = Modifier.size(16.dp))
}
} else {
Icon(Icons.Filled.Mic, contentDescription = "Voice message",
tint = Color.White, modifier = Modifier.size(22.dp))
}
}
}
}@Composable
fun ChatScreen(content: @Composable ColumnScope.() -> Unit) {
androidx.compose.foundation.lazy.LazyColumn(
Modifier.fillMaxSize().background(LineColors.Backdrop), // periwinkle — never white
contentPadding = PaddingValues(vertical = 12.dp),
) {
item { Column(content = content) }
}
}Material 3 NavigationBar with five destinations. Android has no live blur; use an opaque white surface with a hairline. Active tint is LINE Green.
import androidx.compose.material3.*
import androidx.compose.material.icons.filled.*
@Composable
fun LineBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = LineColors.Canvas, tonalElevation = 0.dp) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Talk" to Icons.Filled.ChatBubble,
"VOOM" to Icons.Filled.PlayCircle,
"News" to Icons.Filled.Article,
"Wallet" to Icons.Filled.AccountBalanceWallet,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp)) },
label = { Text(label, style = LineText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = LineColors.Green,
selectedTextColor = LineColors.Green,
unselectedIconColor = LineColors.TextSecondary,
unselectedTextColor = LineColors.TextSecondary,
indicatorColor = Color.Transparent, // LINE has no Material pill
),
)
}
}
}| Moment | Compose recipe |
|---|---|
| Sticker bounce-in (signature) | Animatable 0.6 → 1.08 (spring(dampingRatio = 0.55f)) → 1.0 |
| Animated sticker replay | reset Animatable to 0.6f and re-run the sequence on tap |
| Text bubble enter | AnimatedVisibility + fadeIn() + slideInVertically { 6 }, 220ms |
| Send press | animateFloatAsState scale 1 → 0.92, HapticFeedbackType.TextHandleMove |
| Sticker keyboard present | AnimatedVisibility + slideInVertically { it }, 280ms |
| Tab switch | Crossfade |
// Animated sticker replay on tap
@Composable
fun ReplayableSticker(url: String) {
val scale = remember { Animatable(1f) }
val scope = rememberCoroutineScope()
AsyncImage(
model = url, contentDescription = "Sticker",
modifier = Modifier
.size(140.dp)
.scale(scale.value)
.clickable {
scope.launch {
scale.snapTo(0.6f)
scale.animateTo(1.08f, spring(dampingRatio = 0.55f))
scale.animateTo(1f, spring(dampingRatio = 0.7f))
}
},
)
}Haptics: prefer LocalHapticFeedback. For the send tap use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) to approximate iOS's light impact.
The closest first-party set is androidx.compose.material:material-icons-extended. Stickers are remote image assets (Coil), not icon fonts.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Send (text present) | arrow.up |
Icons.Filled.ArrowUpward |
| Voice (empty composer) | mic.fill |
Icons.Filled.Mic |
| Sticker keyboard | face.smiling |
Icons.Outlined.EmojiEmotions |
| Plus / more | plus |
Icons.Filled.Add |
| Camera | camera |
Icons.Outlined.PhotoCamera |
| Official-account badge | checkmark.seal.fill |
Icons.Filled.Verified |
| Audio call | phone |
Icons.Filled.Call |
| Video call | video |
Icons.Filled.Videocam |
| Menu (chat) | line.3.horizontal |
Icons.Filled.Menu |
| Search | magnifyingglass |
Icons.Filled.Search |
| Home (tab) | house.fill |
Icons.Filled.Home |
| Talk (tab) | bubble.left.fill |
Icons.Filled.ChatBubble |
| VOOM (tab) | play.rectangle.fill |
Icons.Filled.PlayCircle |
| News (tab) | newspaper.fill |
Icons.Filled.Article |
| Wallet (tab) | wallet.pass.fill |
Icons.Filled.AccountBalanceWallet |
- minSdk 24 (Compose floor is 21; comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(). The chat backdrop is colored (periwinkle), so pick system-bar icon contrast against the header (white) rather than the backdrop. ApplyScaffoldinsets so the composer clears gesture nav and rises with the IME (Modifier.imePadding()). - Bubble-less stickers: render the sticker as a plain
AsyncImage— never wrap it in aSurface/bubble or clamp it to the 70% bubble width. - Periwinkle backdrop is user-themeable: if you support custom chat backgrounds, re-check bubble + meta contrast per theme; the faint 1dp bubble shadow exists specifically so white/green read on periwinkle.
- Font scaling:
sphonors the user's font scale — keep it on names and message bodies. Pin layout-sensitive text (tab labels, badge counts, sticker prices) via a fixed-density wrapper; stickers are image assets and don't scale with type. - TalkBack: a sticker MUST have a meaningful
contentDescription(its name/emotion) — there is no text fallback. Announce the official-account badge as "Official account, verified" after the name. - Touch targets: Material guidance is 48.dp minimum. Give the 30.dp send and the composer sticker glyph a 48.dp effective hit via padding; the full 140.dp sticker is tappable to replay.
- Contrast:
#000000text on#C5F0B1and#FFFFFFpasses WCAG AAA.#8C8C8Cmeta floats on the#8CABD9backdrop — validate per theme and add a subtle plate behind meta for low-contrast custom backgrounds. - Reduce motion: gate the sticker bounce-in behind the system animator scale and fall back to a quick fade-to-scale-1.
- Dynamic color: do not enable Material You
dynamicColorScheme()— LINE Green and the pale-green bubble are fixed brand colors regardless of wallpaper.