Dropbox (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Dropbox’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 (Dropbox’s paper-white canvas, single Dropbox-Blue accent, colored file-type rows, determinate upload bar) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,FloatingActionButtoninstead of a custom iOS FAB,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for image thumbnails.
1. Color Tokens
// ui/theme/DropboxColors.kt
import androidx.compose.ui.graphics.Color
object DropboxColors {
// Canvas & Surfaces (Light)
val Canvas = Color(0xFFFFFFFF)
val Surface = Color(0xFFF7F5F2)
val Divider = Color(0xFFE6E1DA)
// Text
val TextPrimary = Color(0xFF1E1919)
val TextSecondary = Color(0xFF6F6A65)
val TextTertiary = Color(0xFFA39E98)
// Brand
val Blue = Color(0xFF0061FF)
val BluePressed = Color(0xFF0050D0)
val BlueTint = Color(0xFFE6F0FF)
// Dark
val DarkCanvas = Color(0xFF1E1919)
val DarkSurface = Color(0xFF2A2424)
val DarkDivider = Color(0xFF3A3331)
val DarkBlue = Color(0xFF3D8BFF)
// File-type icons
val PdfRed = Color(0xFFFA551E)
val DocBlue = Color(0xFF0061FF)
val SheetGreen = Color(0xFF1A8754)
val ImageTeal = Color(0xFF00B2A9)
val FolderSlate = Color(0xFF8C97A8)
// Semantic
val Success = Color(0xFF1A8754)
val Warning = Color(0xFFFFAF00)
val Error = Color(0xFFD1180B)
}
Wire it into Material 3 light and dark schemes so ripples, dividers, and default component colors inherit the brand.
// ui/theme/Theme.kt
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.isSystemInDarkTheme
private val DropboxLight = lightColorScheme(
primary = DropboxColors.Blue,
onPrimary = Color.White,
background = DropboxColors.Canvas,
onBackground = DropboxColors.TextPrimary,
surface = DropboxColors.Canvas,
onSurface = DropboxColors.TextPrimary,
surfaceVariant = DropboxColors.Surface,
outline = DropboxColors.Divider,
error = DropboxColors.Error,
)
private val DropboxDark = darkColorScheme(
primary = DropboxColors.DarkBlue,
onPrimary = Color.White,
background = DropboxColors.DarkCanvas,
onBackground = Color.White,
surface = DropboxColors.DarkSurface,
onSurface = Color.White,
surfaceVariant = DropboxColors.DarkSurface,
outline = DropboxColors.DarkDivider,
error = DropboxColors.Error,
)
@Composable
fun DropboxTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) DropboxDark else DropboxLight,
typography = DropboxTypography,
content = content,
)
2. Typography
Sharp Grotesk is Dropbox’s licensed brand typeface. Drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. The closest free substitute is Inter — load that if Sharp Grotesk is unavailable; otherwise fall back to the system grotesque (Roboto).
// ui/theme/DropboxType.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 Sharp = FontFamily(
Font(R.font.sharp_grotesk_regular, FontWeight.Normal), // 400
Font(R.font.sharp_grotesk_semibold, FontWeight.SemiBold), // 600
Font(R.font.sharp_grotesk_bold, FontWeight.Bold), // 700
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object DropboxText {
val TitleLarge = TextStyle(Sharp, fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 32.sp, letterSpacing = (-0.4).sp)
val Section = TextStyle(Sharp, fontWeight = FontWeight.Bold, fontSize = 22.sp, lineHeight = 26.sp, letterSpacing = (-0.3).sp)
val SheetTitle = TextStyle(Sharp, fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 24.sp, letterSpacing = (-0.2).sp)
val CardTitle = TextStyle(Sharp, fontWeight = FontWeight.SemiBold, fontSize = 17.sp, lineHeight = 22.sp, letterSpacing = (-0.1).sp)
val RowName = TextStyle(Sharp, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 20.sp, letterSpacing = (-0.1).sp)
val Body = TextStyle(Sharp, fontWeight = FontWeight.Normal, fontSize = 15.sp, lineHeight = 22.sp)
val Meta = TextStyle(Sharp, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 18.sp)
val Caption = TextStyle(Sharp, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
val LabelUpper = TextStyle(Sharp, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.6.sp)
val Button = TextStyle(Sharp, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 20.sp)
val Tab = TextStyle(Sharp, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.2.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val DropboxTypography = Typography(
headlineLarge = DropboxText.TitleLarge,
headlineSmall = DropboxText.Section,
titleMedium = DropboxText.RowName,
bodyMedium = DropboxText.Body,
labelSmall = DropboxText.Tab,
)
For tabular figures on file sizes/dates, apply TextStyle(fontFeatureSettings = "tnum") to Meta/Caption usages.
3. Signature Components
Primary Button
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
@Composable
fun DropboxPrimaryButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.98f else 1f, label = "btnScale")
Box(
modifier = modifier
.fillMaxWidth()
.scale(scale)
.graphicsLayer { alpha = if (enabled) 1f else 0.4f }
.clip(RoundedCornerShape(8.dp))
.background(if (pressed) DropboxColors.BluePressed else DropboxColors.Blue)
.clickable(interaction, indication = null, enabled = enabled, onClick = onClick)
.padding(vertical = 14.dp, horizontal = 24.dp),
contentAlignment = Alignment.Center,
) {
Text(text, style = DropboxText.Button, color = Color.White)
}
}
Upload / Create FAB
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Icon
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun DropboxUploadFab(onClick: () -> Unit, modifier: Modifier = Modifier) {
val haptics = LocalHapticFeedback.current
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val scale by animateFloatAsState(if (pressed) 0.92f else 1f, label = "fabScale")
FloatingActionButton(
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // ~iOS soft impact
onClick()
},
modifier = modifier.size(56.dp).scale(scale),
shape = CircleShape,
containerColor = if (pressed) DropboxColors.BluePressed else DropboxColors.Blue,
contentColor = Color.White,
interactionSource = interaction,
) {
Icon(Icons.Filled.Add, contentDescription = "Upload or create", modifier = Modifier.size(24.dp))
}
}
File / Folder Row
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
enum class FileKind { Pdf, Doc, Sheet, Image, Folder }
@Composable
fun DropboxFileRow(
name: String,
meta: String,
kind: FileKind,
selected: Boolean = false,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interaction = remember { MutableInteractionSource() }
val pressed by interaction.collectIsPressedAsState()
val (icon, tint) = when (kind) {
FileKind.Pdf -> Icons.Filled.PictureAsPdf to DropboxColors.PdfRed
FileKind.Doc -> Icons.Filled.Description to DropboxColors.DocBlue
FileKind.Sheet -> Icons.Filled.GridOn to DropboxColors.SheetGreen
FileKind.Image -> Icons.Filled.Image to DropboxColors.ImageTeal
FileKind.Folder -> Icons.Filled.Folder to DropboxColors.FolderSlate
}
val bg = when {
selected -> DropboxColors.BlueTint
pressed -> DropboxColors.Surface
else -> DropboxColors.Canvas
}
Row(
modifier = modifier
.fillMaxWidth()
.height(60.dp)
.background(bg)
.clickable(interaction, indication = null, onClick = onClick)
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (selected) {
Icon(Icons.Filled.CheckCircle, contentDescription = "Selected",
tint = DropboxColors.Blue, modifier = Modifier.size(40.dp))
} else {
Box(
Modifier.size(40.dp).clip(RoundedCornerShape(8.dp))
.background(tint.copy(alpha = 0.14f)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp))
}
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(name, style = DropboxText.RowName, color = DropboxColors.TextPrimary,
maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(meta, style = DropboxText.Meta.copy(fontFeatureSettings = "tnum"),
color = DropboxColors.TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Icon(
if (kind == FileKind.Folder) Icons.Filled.ChevronRight else Icons.Filled.MoreHoriz,
contentDescription = if (kind == FileKind.Folder) "Open" else "More",
tint = DropboxColors.TextSecondary, modifier = Modifier.size(18.dp),
)
}
}
Upload Progress Bar
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.progressSemantics
import androidx.compose.ui.draw.drawBehind
@Composable
fun DropboxUploadBar(
label: String,
progress: Float, // 0f..1f real byte ratio
done: Boolean = false,
modifier: Modifier = Modifier,
) {
val animated by animateFloatAsState(progress, tween(200), label = "uploadProgress")
Column(
modifier
.fillMaxWidth()
.background(DropboxColors.Canvas)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(label, style = DropboxText.Meta.copy(fontWeight = FontWeight.SemiBold),
color = DropboxColors.TextPrimary, modifier = Modifier.weight(1f))
if (done) Icon(Icons.Filled.CheckCircle, "Done", tint = DropboxColors.Success,
modifier = Modifier.size(18.dp))
}
Box(
Modifier
.fillMaxWidth().height(3.dp)
.clip(RoundedCornerShape(2.dp))
.background(DropboxColors.Divider)
.progressSemantics(animated)
.drawBehind {
drawRect(
color = DropboxColors.Blue,
size = size.copy(width = size.width * animated),
)
}
)
}
}
Recent File Card
@Composable
fun DropboxRecentCard(
name: String,
meta: String,
thumbUrl: String?,
modifier: Modifier = Modifier,
) {
Column(
modifier
.size(width = 140.dp, height = 160.dp)
.clip(RoundedCornerShape(12.dp))
.border(1.dp, DropboxColors.Divider, RoundedCornerShape(12.dp))
.background(DropboxColors.Canvas)
.padding(12.dp),
) {
Box(
Modifier.fillMaxWidth().height(96.dp).clip(RoundedCornerShape(4.dp))
.background(DropboxColors.Surface),
contentAlignment = Alignment.Center,
) {
if (thumbUrl != null) {
AsyncImage(thumbUrl, null, Modifier.fillMaxSize())
} else {
Icon(Icons.Filled.Description, null, tint = DropboxColors.Blue,
modifier = Modifier.size(28.dp))
}
}
Spacer(Modifier.height(8.dp))
Text(name, style = DropboxText.CardTitle, color = DropboxColors.TextPrimary,
maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(meta, style = DropboxText.Caption.copy(fontFeatureSettings = "tnum"),
color = DropboxColors.TextSecondary)
}
}
4. Photo Grid (contact-sheet)
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
@Composable
fun DropboxPhotoGrid(urls: List<String>) {
var selected by remember { mutableStateOf(setOf<Int>()) }
LazyVerticalGrid(
columns = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
itemsIndexed(urls) { i, url ->
Box(
Modifier
.aspectRatio(1f)
.combinedClickable(
onClick = {},
onLongClick = {
selected = if (i in selected) selected - i else selected + i
},
),
) {
AsyncImage(url, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
if (i in selected) {
Box(Modifier.matchParentSize().border(4.dp, DropboxColors.Blue))
Icon(Icons.Filled.CheckCircle, "Selected", tint = DropboxColors.Blue,
modifier = Modifier.align(Alignment.TopEnd).padding(6.dp).size(20.dp))
}
}
}
}
}
5. Bottom Navigation (Tab Bar)
Use Material 3 NavigationBar. Dropbox’s iOS tab bar is .regularMaterial blur; Android has no first-class live blur, so use a 94%-opaque canvas surface. Active tint is Dropbox Blue.
@Composable
fun DropboxBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(
containerColor = DropboxColors.Canvas.copy(alpha = 0.94f),
tonalElevation = 0.dp,
) {
val items = listOf(
"Home" to Icons.Filled.Home,
"Files" to Icons.Filled.Folder,
"Photos" to Icons.Filled.PhotoLibrary,
"Offline" to Icons.Filled.CloudDownload,
"Account" to Icons.Filled.AccountCircle,
)
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 = DropboxText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = DropboxColors.Blue,
selectedTextColor = DropboxColors.Blue,
unselectedIconColor = DropboxColors.TextSecondary,
unselectedTextColor = DropboxColors.TextSecondary,
indicatorColor = DropboxColors.BlueTint,
),
)
}
}
}
Anchor DropboxUploadFab in the Scaffold floatingActionButton slot with FabPosition.End so it floats bottom-right above the bar.
6. Motion
| Moment | Compose recipe |
|---|---|
| FAB tap | animateFloatAsState 1 → 0.92 spring; HapticFeedbackType.LongPress (~iOS soft impact) |
| Primary button press | animateFloatAsState 1 → 0.98 |
| Upload progress | animateFloatAsState(progress, tween(200)) — driven by real byte ratio, no shimmer |
| Star toggle | Animatable keyframes 1 → 1.12 → 1 over 240ms; HapticFeedbackType.LongPress; tint flips to Blue |
| Thumbnail decode | Coil crossfade(200) fade-in as tiles load |
| Sheet present | ModalBottomSheet default spring; scrim Color(0xFF1E1919).copy(alpha = 0.40f) |
// Star toggle bounce
@Composable
fun StarToggle(starred: Boolean, onToggle: () -> Unit) {
val scale = remember { Animatable(1f) }
LaunchedEffect(starred) {
if (starred) {
scale.animateTo(1.12f, tween(120)); scale.animateTo(1f, tween(120))
}
}
val haptics = LocalHapticFeedback.current
Icon(
if (starred) Icons.Filled.Star else Icons.Outlined.StarBorder,
contentDescription = "Star",
tint = if (starred) DropboxColors.Blue else DropboxColors.TextSecondary,
modifier = Modifier.size(22.dp).scale(scale.value).clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress); onToggle()
},
)
}
Haptics: prefer LocalHapticFeedback. For richer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK) (API 30+) or a Vibrator VibrationEffect.createOneShot(10, ...) to approximate iOS’s .soft impact.
7. Icons
Closest first-party set is androidx.compose.material:material-icons-extended. For exact parity, export Dropbox’s glyphs as vector drawables and load via ImageVector.vectorResource(R.drawable.…).
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Upload / Create | plus |
Icons.Filled.Add |
| PDF file | doc.richtext.fill |
Icons.Filled.PictureAsPdf |
| Doc file | doc.text.fill |
Icons.Filled.Description |
| Sheet file | tablecells.fill |
Icons.Filled.GridOn |
| Image file | photo.fill |
Icons.Filled.Image |
| Folder | folder.fill |
Icons.Filled.Folder |
| Selected | checkmark.circle.fill |
Icons.Filled.CheckCircle |
| More | ellipsis |
Icons.Filled.MoreHoriz |
| Folder chevron | chevron.right |
Icons.Filled.ChevronRight |
| Star | star / star.fill |
Icons.Outlined.StarBorder / Icons.Filled.Star |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| Download / Offline | arrow.down.circle |
Icons.Filled.CloudDownload |
| Search | magnifyingglass |
Icons.Filled.Search |
| Home (tab) | house |
Icons.Filled.Home |
| Files (tab) | folder |
Icons.Filled.Folder |
| Photos (tab) | photo.on.rectangle |
Icons.Filled.PhotoLibrary |
| Account (tab) | person.crop.circle |
Icons.Filled.AccountCircle |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; modern motion + Coil are comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge()in theActivity; the light canvas wants dark system-bar icons viaWindowCompat. UseScaffoldinsets so the FAB clears the gesture nav and the upload bar clears the bottom bar. - Font scaling:
sphonors the user’s font scale automatically — keep it on file names, metadata, body. Pin layout-sensitive text (tab labels, the 3.dp progress bar, the 56.dp FAB glyph) viadpor a fixedDensityoverride. - Tabular numerals: apply
fontFeatureSettings = "tnum"toMeta/Captionso file sizes and dates align in columns. - TalkBack: set
contentDescriptionon the FAB ("Upload or create"); merge file-row name + metadata withModifier.semantics(mergeDescendants = true)and mark the trailing overflow/chevron as a separate button. AddprogressSemantics()to the upload bar. - Touch targets: Material guidance is 48.dp minimum. The 56.dp FAB and 60.dp rows are clear; give the 18.dp ellipsis a 48.dp hit area via padding.
- Contrast:
#6F6A65on#FFFFFFpasses WCAG AA at 14sp+. On the dark canvas useDarkBlue #3D8BFFfor blue text/icons over#1E1919. Validate 11sp tab/label text and bump toward#5A554Fif targeting strict compliance. - Dynamic color: do not enable Material You
dynamicLightColorScheme()— Dropbox’s brand requires the fixed paper-white canvas and single Dropbox-Blue accent regardless of wallpaper.