Signal (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Signal’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 (Signal’s privacy-minimal calm, white/soft-black canvas, single-blue accent, blue outgoing bubbles, slide-up send) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar, per-cornerRoundedCornerShapefor the same-sender tail,sp/dpinstead ofpt. (Signal’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.
1. Color Tokens
// ui/theme/SignalColors.kt
import androidx.compose.ui.graphics.Color
object SignalColors {
// Canvas & Surface
val Canvas = Color(0xFFFFFFFF)
val CanvasDark = Color(0xFF1B1B1B)
val Surface = Color(0xFFF5F5F5)
val SurfaceDark = Color(0xFF2A2A2A)
val Divider = Color(0xFFE5E5E5)
val DividerDark = Color(0xFF3A3A3A)
// Text
val TextPrimary = Color(0xFF000000)
val TextPrimaryD = Color(0xFFFFFFFF)
val TextSecondary = Color(0xFF6B6B6B)
val TextSecondaryD = Color(0xFF9A9A9A)
val TextTertiary = Color(0xFF9A9A9A)
// Brand
val Blue = Color(0xFF3A76F0)
val BluePressed = Color(0xFF2F5FCC)
val BlueTint = Color(0xFFE7EEFD)
// Message
val Incoming = Color(0xFFE9E9EB)
val IncomingDark = Color(0xFF2A2A2A)
val OutMeta = Color(0xFFCBD9F9)
// Semantic
val Error = Color(0xFFD7263D)
val Success = Color(0xFF3AB54A)
}
Wire it into Material 3 schemes so ripples, dividers, and stock component colors inherit the brand. Signal follows the system; the dark scheme uses a soft #1B1B1B, never pure black.
// 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 SignalLight = lightColorScheme(
primary = SignalColors.Blue,
onPrimary = Color.White,
background = SignalColors.Canvas,
onBackground = SignalColors.TextPrimary,
surface = SignalColors.Canvas,
onSurface = SignalColors.TextPrimary,
surfaceVariant = SignalColors.Surface,
outline = SignalColors.Divider,
error = SignalColors.Error,
)
private val SignalDark = darkColorScheme(
primary = SignalColors.Blue,
onPrimary = Color.White,
background = SignalColors.CanvasDark,
onBackground = SignalColors.TextPrimaryD,
surface = SignalColors.CanvasDark,
onSurface = SignalColors.TextPrimaryD,
surfaceVariant = SignalColors.SurfaceDark,
outline = SignalColors.DividerDark,
error = SignalColors.Error,
)
@Composable
fun SignalTheme(content: @Composable () -> Unit) =
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) SignalDark else SignalLight,
typography = SignalTypography,
content = content,
)
2. Typography
Signal uses Inter everywhere. Drop the TTFs in res/font/ (lowercase, snake_case); the safety number uses a monospace family.
// ui/theme/SignalType.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 Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal), // 400
Font(R.font.inter_medium, FontWeight.Medium), // 500
Font(R.font.inter_semibold, FontWeight.SemiBold),// 600
Font(R.font.inter_bold, FontWeight.Bold), // 700
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object SignalText {
val LargeTitle = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp, letterSpacing = (-0.3).sp)
val ConvoName = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 17.sp, lineHeight = 22.sp)
val ThreadTitle = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 21.sp)
val MessageBody = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 22.sp)
val Preview = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 20.sp)
val SectionHeader = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp)
val Timestamp = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 16.sp)
val BubbleMeta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 14.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 16.sp)
val ButtonText = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 16.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 12.sp, letterSpacing = 0.1.sp)
val TimerChip = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 12.sp)
val SystemNote = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 13.sp, lineHeight = 18.sp)
val SafetyNumber = TextStyle(FontFamily.Monospace, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = 0.5.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val SignalTypography = Typography(
headlineMedium = SignalText.LargeTitle,
titleLarge = SignalText.ThreadTitle,
bodyLarge = SignalText.MessageBody,
titleMedium = SignalText.ConvoName,
labelSmall = SignalText.Tab,
)
3. Signature Components
Message Bubble (Outgoing + Incoming with same-sender 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
@Composable
fun MessageBubble(
text: String,
time: String,
isOutgoing: Boolean,
isLastInRun: Boolean,
modifier: Modifier = Modifier,
) {
val tail = if (isLastInRun) 6.dp else 18.dp
val shape = RoundedCornerShape(
topStart = 18.dp, topEnd = 18.dp,
bottomEnd = if (isOutgoing) tail else 18.dp,
bottomStart = if (isOutgoing) 18.dp else tail,
)
Row(
modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 1.dp),
horizontalArrangement = if (isOutgoing) Arrangement.End else Arrangement.Start,
) {
Column(
Modifier
.widthIn(max = 280.dp)
.clip(shape)
.background(if (isOutgoing) SignalColors.Blue else SignalColors.Incoming)
.padding(horizontal = 13.dp, vertical = 9.dp),
horizontalAlignment = Alignment.End,
) {
Text(text, style = SignalText.MessageBody,
color = if (isOutgoing) Color.White else SignalColors.TextPrimary)
Row(horizontalArrangement = Arrangement.spacedBy(3.dp)) {
Text(time, style = SignalText.BubbleMeta,
color = if (isOutgoing) SignalColors.OutMeta else SignalColors.TextSecondary)
if (isOutgoing) {
Text("✓✓", style = SignalText.BubbleMeta, color = SignalColors.OutMeta)
}
}
}
}
}
Slide-Up Send Button (Signature)
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
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.PhotoCamera
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.graphicsLayer
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
val sendOffset by animateDpAsState(
if (hasText) 0.dp else 4.dp,
spring(dampingRatio = 0.8f, stiffness = 600f), label = "sendY",
)
val sendAlpha by animateFloatAsState(if (hasText) 1f else 0.9f, label = "sendA")
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val pressScale by animateFloatAsState(if (pressed) 0.90f else 1f, label = "sendScale")
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
Modifier
.weight(1f)
.heightIn(min = 36.dp)
.clip(RoundedCornerShape(18.dp))
.background(SignalColors.Surface)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(Icons.Filled.Add, contentDescription = "Attach", tint = SignalColors.TextSecondary)
BasicTextField(
value = text, onValueChange = { text = it },
textStyle = SignalText.MessageBody.copy(color = SignalColors.TextPrimary),
modifier = Modifier.weight(1f).padding(vertical = 8.dp),
decorationBox = { inner ->
if (text.isEmpty()) Text("Signal message", style = SignalText.MessageBody,
color = SignalColors.TextTertiary)
inner()
},
)
Icon(Icons.Outlined.PhotoCamera, contentDescription = "Camera", tint = SignalColors.TextSecondary)
}
Box(
Modifier
.offset(y = sendOffset)
.graphicsLayer { alpha = sendAlpha }
.scale(pressScale)
.size(32.dp)
.clip(CircleShape)
.background(if (pressed) SignalColors.BluePressed else SignalColors.Blue)
.clickable(interaction, indication = null) {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
text = ""
},
contentAlignment = Alignment.Center,
) {
// up-arrow when text exists, mic when empty
Icon(if (hasText) Icons.Filled.ArrowUpward else Icons.Filled.Mic,
contentDescription = if (hasText) "Send" else "Voice message",
tint = Color.White, modifier = Modifier.size(16.dp))
}
}
}
Conversation Row
@Composable
fun ConversationRow(
name: String,
preview: String,
time: String,
unread: Int,
disappearing: Boolean,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
Row(
Modifier
.fillMaxWidth()
.height(72.dp)
.background(if (pressed) SignalColors.Surface else SignalColors.Canvas)
.clickable(interaction, indication = null) { }
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(Modifier.size(48.dp).clip(CircleShape).background(SignalColors.Surface))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
Text(name, style = SignalText.ConvoName, color = SignalColors.TextPrimary)
if (disappearing) Icon(Icons.Filled.Timer, contentDescription = null,
tint = SignalColors.TextSecondary, modifier = Modifier.size(13.dp))
}
Text(preview, style = SignalText.Preview, color = SignalColors.TextSecondary, maxLines = 1)
}
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(time, style = SignalText.Timestamp, color = SignalColors.TextSecondary)
if (unread > 0) {
Box(
Modifier.defaultMinSize(20.dp, 20.dp).clip(CircleShape)
.background(SignalColors.Blue).padding(horizontal = 6.dp),
contentAlignment = Alignment.Center,
) { Text("$unread", style = SignalText.BubbleMeta, color = Color.White) }
}
}
}
}
Timer Chip + Encryption Note
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Timer
@Composable
fun TimerChip(duration: String) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
// privacy is quiet — neutral gray, never colored
Icon(Icons.Filled.Timer, contentDescription = "Disappearing messages",
tint = SignalColors.TextSecondary, modifier = Modifier.size(13.dp))
Text(duration, style = SignalText.TimerChip, color = SignalColors.TextSecondary)
}
}
@Composable
fun EncryptionNote() {
Row(
Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Filled.Lock, contentDescription = null, tint = SignalColors.TextSecondary,
modifier = Modifier.size(12.dp))
Spacer(Modifier.width(5.dp))
Text("Messages and calls are end-to-end encrypted.",
style = SignalText.SystemNote, color = SignalColors.TextSecondary)
}
}
4. Privacy-Minimal Stance in Compose
There is intentionally no ad slot, no algorithmic feed surface, no streak counter, and no like-count composable in this design. Every screen is a LazyColumn of rows, a thread of bubbles, or a grouped settings list. Keep it that way — the restraint is the brand. The only “dynamic” surface is the disappearing-timer ring that drains over a message’s lifetime (see §6).
5. Bottom Navigation
Material 3 NavigationBar. Signal’s tab bar is flat; Android has no live blur, so use an opaque canvas surface with a hairline. Active tint is Signal Blue.
import androidx.compose.material3.*
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.Circle
@Composable
fun SignalBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = SignalColors.Canvas, tonalElevation = 0.dp) {
val items = listOf(
"Chats" to Icons.Filled.Chat,
"Calls" to Icons.Filled.Call,
"Stories" to Icons.Outlined.Circle,
"Settings" to Icons.Filled.Settings,
)
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 = SignalText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = SignalColors.Blue,
selectedTextColor = SignalColors.Blue,
unselectedIconColor = SignalColors.TextSecondary,
unselectedTextColor = SignalColors.TextSecondary,
indicatorColor = Color.Transparent, // Signal has no Material pill
),
)
}
}
}
6. Motion
| Moment | Compose recipe |
|---|---|
| Send button slide-up | animateDpAsState offset 4 → 0 + animateFloatAsState alpha 0.9 → 1, spring(dampingRatio = 0.8f) |
| Send press | animateFloatAsState scale 1 → 0.90, HapticFeedbackType.TextHandleMove |
| Outgoing bubble enter | AnimatedVisibility + slideInVertically { it } + fadeIn(), 250ms |
| Reaction picker | AnimatedVisibility + scaleIn(spring(dampingRatio = 0.7f)) above the bubble |
| Disappearing timer ring | rememberInfiniteTransition-free: animateFloatAsState 1 → 0 over the message lifetime driving a Canvas arc sweep |
| Tab switch | Crossfade (no slide) |
// Disappearing timer ring — drains over the message lifetime
@Composable
fun TimerRing(progress: Float) { // 1f full → 0f gone
androidx.compose.foundation.Canvas(Modifier.size(14.dp)) {
drawArc(
color = SignalColors.TextSecondary,
startAngle = -90f,
sweepAngle = 360f * progress,
useCenter = false,
style = androidx.compose.ui.graphics.drawscope.Stroke(width = 3f),
)
}
}
Haptics: prefer LocalHapticFeedback. For a crisper send tick use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) to approximate iOS’s .light impact.
7. Icons
The closest first-party set is androidx.compose.material:material-icons-extended. Export Signal’s exact glyphs as vector drawables for pixel parity if needed.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Send | arrow.up |
Icons.Filled.ArrowUpward |
| Voice (empty composer) | mic.fill |
Icons.Filled.Mic |
| Attach | plus |
Icons.Filled.Add |
| Camera | camera |
Icons.Outlined.PhotoCamera |
| Audio call | phone |
Icons.Filled.Call |
| Video call | video |
Icons.Filled.Videocam |
| Compose | square.and.pencil |
Icons.Filled.Edit |
| Disappearing timer | timer |
Icons.Filled.Timer |
| Sealed sender | lock.fill |
Icons.Filled.Lock |
| Safety shield | checkmark.shield.fill |
Icons.Filled.VerifiedUser |
| Search | magnifyingglass |
Icons.Filled.Search |
| Chats (tab) | message.fill |
Icons.Filled.Chat |
| Calls (tab) | phone.fill |
Icons.Filled.Call |
| Stories (tab) | circle.dashed |
Icons.Outlined.Circle |
| Settings (tab) | gearshape.fill |
Icons.Filled.Settings |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); system-bar icon color follows the canvas (dark icons on white, light on#1B1B1B). ApplyScaffoldinsets so the composer clears the gesture nav and rises with the IME (Modifier.imePadding()). - Dark mode: the dark canvas is
#1B1B1B— never pure black. Do not enable Material YoudynamicDarkColorScheme(); the single-blue identity is fixed regardless of wallpaper. - Font scaling:
sphonors the user’s font scale — keep it on names and message bodies. Pin layout-sensitive text (bubble meta, timestamps, tab labels) via a fixed-density wrapper; keep the safety-number grouping aligned. - TalkBack: announce bubble direction before the text (“You said…” / “<Name> said…”), then time and read state. Hide the send button from accessibility when it is a mic (announce “Voice message” only when it actually sends). Expose the timer chip as “Disappearing messages, <duration>”.
- Touch targets: Material guidance is 48.dp minimum. The 32.dp send circle needs a 48.dp effective hit via padding; conversation rows are a full 72.dp.
- Contrast:
#6B6B6Bon#FFFFFFpasses WCAG AA at 13sp+; outgoing white on#3A76F0passes AA.#CBD9F9bubble meta on#3A76F0is intentionally low-emphasis — bump toward#DCE6FBif targeting strict compliance. - Keep it minimal for assistive tech too: no decorative composables that add TalkBack noise — the absence of clutter is part of the accessibility story.