9.5 KiB
Custom Icon Assets and ImageVector Patterns
Guide to creating and using custom ImageVector icons in Compose Multiplatform.
Why ImageVector?
ImageVector is the native Compose format for vector graphics:
- Pure Kotlin: No XML, no asset files
- Multiplatform: Works on Android, Desktop, iOS without conversion
- Performant: Lightweight, composable, GPU-accelerated
- Type-safe: Compile-time checking, no resource IDs
Amethyst Pattern: Robohash
Amethyst generates deterministic avatars using ImageVector builders.
Architecture
commons/robohash/
├── RobohashAssembler.kt # Main assembly logic
├── CachedRobohash.kt # Caching layer
└── parts/
├── Face0C3po.kt # Face variants (0-9)
├── Eyes2Single.kt # Eye variants (0-9)
├── Mouth3Grid.kt # Mouth variants (0-9)
├── Body2Thinnest.kt # Body variants (0-9)
└── Accessory7Antenna.kt # Accessory variants (0-9)
Pattern: 10 variants per feature × 5 features = 100,000+ unique combinations
roboBuilder DSL
Custom ImageVector builder with sensible defaults:
fun roboBuilder(block: Builder.() -> Unit): ImageVector {
return ImageVector.Builder(
name = "Robohash",
defaultWidth = 300.dp,
defaultHeight = 300.dp,
viewportWidth = 300f,
viewportHeight = 300f
).apply(block).build()
}
Usage:
@Composable
fun CustomIcon() {
Image(
painter = rememberVectorPainter(
roboBuilder {
// Add paths here
}
),
contentDescription = "Custom icon"
)
}
Path Building Pattern
fun face0C3po(fgColor: SolidColor, builder: Builder) {
builder.addPath(pathData1, fill = fgColor, stroke = Black, strokeLineWidth = 1.5f)
builder.addPath(pathData2, fill = Black, fillAlpha = 0.4f)
builder.addPath(pathData5, fill = Black, fillAlpha = 0.2f)
builder.addPath(pathData6, stroke = Black, strokeLineWidth = 1.0f)
builder.addPath(pathData7, fill = Black, stroke = Black, fillAlpha = 0.2f, strokeLineWidth = 0.75f)
}
private val pathData1 = PathData {
moveTo(144.5f, 87.5f)
reflectiveCurveToRelative(-51.0f, 3.0f, -53.0f, 55.0f)
curveToRelative(0.0f, 0.0f, 0.0f, 27.0f, 5.0f, 42.0f)
reflectiveCurveToRelative(10.0f, 38.0f, 10.0f, 38.0f)
lineToRelative(16.0f, 16.0f)
// ...
close()
}
Key elements:
pathDatavariables for path commandsaddPath()for each layer- Parameterized colors (
fgColor) - Constant colors (
Black) - Alpha for shadows/highlights
PathData DSL
Compose's PathData builder provides SVG-like commands:
| Command | Description | Example |
|---|---|---|
moveTo(x, y) |
Move pen without drawing | moveTo(100f, 100f) |
lineTo(x, y) |
Draw line to point | lineTo(200f, 150f) |
curveToRelative(...) |
Relative cubic Bézier | curveToRelative(10f, 20f, 30f, 40f, 50f, 60f) |
reflectiveCurveToRelative(...) |
Smooth curve | reflectiveCurveToRelative(-51f, 3f, -53f, 55f) |
horizontalLineTo(x) |
Horizontal line | horizontalLineTo(250f) |
verticalLineTo(y) |
Vertical line | verticalLineTo(300f) |
close() |
Close path | close() |
Relative vs Absolute:
moveTo/lineTo- Absolute coordinatesmoveToRelative/lineToRelative- Relative to current position
Creating Custom Icons
Method 1: From SVG (Recommended)
- Export SVG from design tool (Figma, Illustrator)
- Convert to ImageVector using Android Studio's Vector Asset tool
- Extract path data and adapt to roboBuilder pattern
// SVG path: M 10 10 L 20 20 ...
// Becomes:
private val myIconPath = PathData {
moveTo(10f, 10f)
lineTo(20f, 20f)
// ...
}
Method 2: Programmatic
Build paths programmatically for simple shapes:
fun simpleIcon(): ImageVector = roboBuilder {
addPath(
pathData = PathData {
moveTo(50f, 50f)
lineTo(150f, 50f)
lineTo(150f, 150f)
lineTo(50f, 150f)
close()
},
fill = SolidColor(Color.Blue),
stroke = SolidColor(Color.Black),
strokeLineWidth = 2f
)
}
Method 3: Material Icons Extensions
Extend Material Icons when you need platform-consistent icons:
// For standard icons, use Material Icons
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
Icon(Icons.Default.Check, contentDescription = "Success")
Icon(Icons.Default.Close, contentDescription = "Error")
CachedRobohash Pattern
Performance optimization for generated icons:
object CachedRobohash {
private val cache = mutableMapOf<Pair<String, Boolean>, ImageVector>()
fun get(seed: String, isLight: Boolean): ImageVector {
return cache.getOrPut(seed to isLight) {
RobohashAssembler.assemble(seed, isLight)
}
}
}
Pattern:
- Key:
(seed, theme)pair - Value: Assembled ImageVector
- Lifecycle: Application lifetime (never cleared)
Usage:
@Composable
fun RobohashImage(robot: String) {
Image(
imageVector = CachedRobohash.get(robot, isLightTheme()),
contentDescription = "Avatar for $robot"
)
}
Color Management
Dynamic Colors
Pass colors as parameters for theme adaptation:
fun themedIcon(fgColor: SolidColor, bgColor: SolidColor, builder: Builder) {
builder.addPath(pathData1, fill = bgColor)
builder.addPath(pathData2, fill = fgColor)
}
@Composable
fun ThemedIcon() {
val fg = MaterialTheme.colorScheme.primary
val bg = MaterialTheme.colorScheme.surface
Image(
painter = rememberVectorPainter(
roboBuilder {
themedIcon(SolidColor(fg), SolidColor(bg), this)
}
),
contentDescription = null
)
}
Static Colors
Define constants for colors that don't change:
val Black = SolidColor(Color.Black)
val White = SolidColor(Color.White)
val Transparent = SolidColor(Color.Transparent)
Advanced Techniques
Layering
Build complex icons with multiple layers:
fun complexIcon(builder: Builder) {
// Layer 1: Background
builder.addPath(bgPath, fill = SolidColor(Color.White))
// Layer 2: Shadow
builder.addPath(shadowPath, fill = SolidColor(Color.Black), fillAlpha = 0.2f)
// Layer 3: Main shape
builder.addPath(mainPath, fill = SolidColor(Color.Blue))
// Layer 4: Highlight
builder.addPath(highlightPath, fill = SolidColor(Color.White), fillAlpha = 0.3f)
// Layer 5: Stroke
builder.addPath(outlinePath, stroke = SolidColor(Color.Black), strokeLineWidth = 1f)
}
Render order: Bottom to top (first addPath = bottom layer)
Alpha for Visual Effects
// Shadow
builder.addPath(shadowPath, fill = Black, fillAlpha = 0.4f)
// Highlight
builder.addPath(highlightPath, fill = White, fillAlpha = 0.2f)
// Glass effect
builder.addPath(glassPath, fill = White, fillAlpha = 0.1f)
Stroke Styles
// Outline only
builder.addPath(path, stroke = Black, strokeLineWidth = 1.5f)
// Fill + outline
builder.addPath(path, fill = fgColor, stroke = Black, strokeLineWidth = 1f)
// Dashed (not supported directly, use multiple segments)
Composable Icon Pattern
Wrap ImageVector in a Composable for reusability:
@Composable
fun MyCustomIcon(
modifier: Modifier = Modifier,
tint: Color = Color.Unspecified
) {
Image(
painter = rememberVectorPainter(myIconVector()),
contentDescription = "My custom icon",
modifier = modifier,
colorFilter = if (tint != Color.Unspecified) {
ColorFilter.tint(tint)
} else null
)
}
Usage:
MyCustomIcon(
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Best Practices
DO
✅ Cache generated ImageVectors for performance ✅ Use PathData DSL for readability ✅ Parameterize colors for theme support ✅ Use Material Icons for standard icons ✅ Keep viewport size consistent (e.g., 300×300) ✅ Layer paths from back to front ✅ Use alpha for shadows and highlights
DON'T
❌ Generate ImageVectors in @Composable without caching ❌ Hardcode theme-specific colors ❌ Create custom icons for standard Material icons ❌ Use extreme viewport sizes (stay 24-1000dp) ❌ Mix absolute and relative coordinates unnecessarily ❌ Forget to close() paths
Icon Organization
Structure
commons/icons/
├── CustomIcons.kt # Icon collection object
├── icons/
│ ├── Zap.kt # Lightning bolt
│ ├── Relay.kt # Relay indicator
│ └── Bitcoin.kt # Bitcoin symbol
└── builders/
└── IconBuilder.kt # Shared builder utilities
Collection Object
object CustomIcons {
val Zap: ImageVector by lazy { ZapIcon.create() }
val Relay: ImageVector by lazy { RelayIcon.create() }
val Bitcoin: ImageVector by lazy { BitcoinIcon.create() }
}
// Usage
Icon(CustomIcons.Zap, contentDescription = "Zap")
Resources
- Compose ImageVector API
- SVG Path Commands
- Material Icons
- Robohash implementation:
commons/robohash/in AmethystMultiplatform