Ink Custom Brush Builder
Android (Google)OfficialCreated by Google LLCDistributed by Android (Google)
- Skill
- Tool integration
- Software development
- Apache-2.0
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
The ink-custom-brush-builder skill provides a structured approach to creating programmatic custom brushes for Android applications utilizing the Jetpack Ink library (version 1.1.0-alpha03 and higher). It enables developers to define complex brush behaviors, multi-coat layers, and custom textures using Kotlin constructors rather than manual protocol buffer builders. The skill covers the entire brush hierarchy, including BrushFamily, BrushCoat, BrushTip, BrushPaint, and BrushBehavior node graphs, ensuring developers can implement responsive, high-fidelity drawing tools with features like pressure, tilt, and speed sensitivity.
What it does
- Generates complete Kotlin implementations for BrushFamily, BrushCoat, BrushTip, BrushPaint, and BrushBehavior.
- Configures dynamic stylus and touch responses using BrushBehavior node graphs.
- Applies tiling or particle stamping textures to brushes.
- Sets up input smoothing via SlidingWindowModel.
- Provides guidance on encoding and decoding custom BrushFamily definitions.
- Ensures correct dependency versions for Jetpack Ink libraries.
When to use it
- When you need to create custom brush styles beyond the provided stock brushes.
- When implementing brushes that respond dynamically to stylus pressure, tilt, or speed.
- When building multi-coat layered brushes with specific texture or color functions.
- When you need to serialize and deserialize custom brush definitions.
- When configuring specific input smoothing for drawing strokes.
Advantages
- Uses public Kotlin constructors for type-safe brush definition.
- Enables complex, organic stroke behaviors through procedural noise and response nodes.
- Supports advanced texture mapping without manual bitmap shader implementation.
- Provides a consistent, documented workflow for brush structure and behavior.
- Avoids common antipatterns like manual Bézier smoothing or raw proto builders.
Usage examples
- “How do I create a custom pressure-sensitive pen brush?”
- The agent will provide a Kotlin code snippet using BrushFamily, BrushCoat, and a BrushBehavior node graph that maps NORMALIZED_PRESSURE to SIZE_MULTIPLIER.
- “Help me add a grain texture to my custom brush.”
- The agent will guide you through implementing a BrushPaint.TilingTexture, identifying the necessary drawable resource, and registering it in the TextureBitmapStore.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/android/ink-custom-brush-builder/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip ink-custom-brush-builder.zip -d .agents/skills
Documentation by Google LLC
Custom brushes in the Android Ink API are built around the BrushFamily
hierarchy:
BrushFamily
├── InputModel (BrushFamily.InputModel.SlidingWindowModel or PASSTHROUGH_MODEL)
└── BrushCoat[] (one or more rendering layers)
├── BrushTip (shape: scaleX, scaleY, cornerRounding, rotationDegrees, slantDegrees, pinch)
│ └── BrushBehavior[] (dynamic response to stylus or touch input)
│ └── Tree: TargetNode(input = [Operators](input = SourceNode))
└── BrushPaint (visual appearance)
├── SelfOverlap (ANY, ACCUMULATE, or DISCARD)
├── TextureLayer[] (BrushPaint.TilingTexture or BrushPaint.StampingTexture)
└── BrushPaint.ColorFunction[] (OpacityMultiplier or ReplaceColor)Create a Brush by combining a custom BrushFamily with a Compose Color,
size, and epsilon:
val brush = Brush.createWithComposeColor(
family = myCustomFamily,
color = Color.Black,
size = 10f,
epsilon = 0.1f,
)<br />
Version and experimental API requirements :
Requires
androidx.ink1.1.0-alpha03and higher : In1.0.0(Stable) and1.1.0-alpha01-alpha02,BrushFamily(...),BrushCoat,BrushTip,BrushPaint,BrushBehavior, andandroidx.ink.brush.behavior.*nodes were@RestrictTo(LIBRARY_GROUP)(in1.0.0stable, custom brushes can only be loaded from binary proto streams usingBrushFamily.decode). Starting in1.1.0-alpha03and higher , programmatic custom brush constructors graduated to public API (BrushPaint.TilingTexture,BrushPaint.StampingTexture,BrushPaint.ColorFunction,BrushFamily.InputModel.SlidingWindowModel, and all behavior nodes).No
@OptInrequired in1.1.0-alpha03and higher : All public custom brush classes andink-storageBrushFamilyserialization APIs (BrushFamily.encodeanddecode,AndroidBrushFamilySerialization,BrushFamilyDecodeCallback) are unannotated in1.1.0-alpha03and higher (ExperimentalInkCustomBrushApiis@RestrictTo(LIBRARY_GROUP)in1.1.0-alpha05and higher, and is only required onBrushFamilyserialization in1.0.0stable).
Workflow and task routing
Follow these conditional instructions based on the requested custom brush task:
Step 1 (Dependencies) : When setting up an Android project for programmatic custom brushes, ask user permission before editing build files and verify that
androidx.ink:ink-brush,androidx.ink:ink-brush-compose,androidx.ink:ink-storage, andandroidx.ink:ink-nativeloaderuse version1.1.0-alpha03or higher ingradle/libs.versions.tomlandapp/build.gradle.kts.Step 2 (Hierarchy, tip shape, paint, and input smoothing) : When defining
BrushFamilyand multi-coatBrushCoatlayers, configuringBrushTipgeometry (scaleX,scaleY,cornerRounding,rotationDegrees,slantDegrees,pinch), settingBrushPaintproperties (SelfOverlap,ColorFunction), or smoothing stroke input withBrushFamily.InputModel.SlidingWindowModel, you MUST follow brush-structure.md. For baselineBrushcreation or1.0.0resource loading, review Brush APIs.Step 3 (Dynamic stylus and touch behaviors) : When making a brush respond dynamically to stylus pressure, tilt, speed, direction, or procedural noise using
BrushBehaviornode trees (SourceNode,DampingNode,NoiseNode,ResponseNode,TargetNode,PolarTargetNode), you MUST follow brush-behaviors.md.Step 4 (Textures and serialization) : When adding
BrushPaint.TilingTextureorBrushPaint.StampingTexturelayers, implementingTextureBitmapStore, or encoding and decoding customBrushFamilydefinitions (BrushFamily.encodeanddecode,AndroidBrushFamilySerialization,BrushFamilyDecodeCallback), you MUST follow textures-and-serialization.md. Inform the user which texture image files (such asapp/src/main/res/drawable-nodpi/pencil_grain.png) to add to their project and register inTextureBitmapStore.Step 5 (Complete brush recipes) : When building a complete pressure pen, calligraphy nib, shading pencil, watercolor wash, or multi-coat shadow brush from scratch, start from the copy-ready implementations in examples.md.
Compose drawing surface, eraser, or persistence : For Compose drawing surface setup,
InProgressStrokeswet and dry rendering, geometry-based erasing, or Room stroke persistence, suggest theink-app-builderskill.
Antipatterns
NEVER use raw
ink.proto.*builders to construct brushes---always use the public Kotlin constructors (BrushFamily,BrushCoat,BrushTip,BrushPaint,BrushBehaviorfromandroidx.ink.brush.*).NEVER manually scale stroke width in a touch listener for pressure or tilt sensitivity---use
BrushBehaviormappingSourceNode.Source.NORMALIZED_PRESSUREorTILT_IN_RADIANStoTargetNode.Target.SIZE_MULTIPLIERorWIDTH_MULTIPLIER.NEVER use manual
Canvasbitmap stamping orBitmapShaderfor textured brushes---useBrushPaint.TilingTextureorBrushPaint.StampingTexturewith aTextureBitmapStore.NEVER use
BlendMode.SRC_OVERon the final (or only)TextureLayer---useBlendMode.MODULATE,DST_IN,DST_OUT,SRC_ATOP, orSRC_INso edge anti-aliasing andOPACITY_MULTIPLIERbehaviors aren't overridden.NEVER serialize
BrushFamilyas JSON, Gson, Moshi, orParcelable---always useBrushFamily.encodeandBrushFamily.decodeorAndroidBrushFamilySerializationfromandroidx.ink.storage.NEVER implement manual Bézier (
quadTo,cubicTo) smoothing ---configureBrushFamily.InputModel.SlidingWindowModelfor input smoothing andDampingNodefor behavior smoothing.NEVER use
Random.nextFloatin draw loops for organic stroke wobble---attach aNoiseNodevaried over stroke distance inBrushBehavior.
Files
- ATTRIBUTION.mdAdded by AutoGineer1009 B
- LICENSELicence file added by AutoGineer11.1 KiB
- SKILL.md7.2 KiB
- references/android/develop/ui/compose/touch-input/stylus-input/ink-api-brush-apis.md5.0 KiB
- references/brush-behaviors.md10.8 KiB
- references/brush-structure.md9.9 KiB
- references/examples.md11.8 KiB
- references/textures-and-serialization.md7.6 KiB
references/brush-structure.md
Reference for defining BrushFamily and BrushCoat hierarchies, configuring
BrushTip geometry and BrushPaint blending, and tuning InputModel stroke
smoothing (androidx.ink 1.1.0-alpha03 and higher).
BrushFamily and BrushCoat hierarchy
graph TD
BF["BrushFamily"]
C0["BrushCoat[0]"]
C1["BrushCoat[1] ..."]
T0["BrushTip"]
P0["BrushPaint[]"]
BB["BrushBehavior[]"]
TP["Tip Properties (scaleX, scaleY, cornerRounding, slantDegrees, pinch, rotationDegrees)"]
SO["SelfOverlap"]
CF["BrushPaint.ColorFunction[]"]
TL["TextureLayer[] (TilingTexture / StampingTexture)"]
BF --> C0
BF --> C1
C0 --> T0
C0 --> P0
T0 --> TP
T0 --> BB
P0 --> SO
P0 --> CF
P0 --> TL| Level | Type | Role |
|---|---|---|
| BrushFamily | BrushFamily | Top-level immutable brush definition containing one or more BrushCoat layers and an InputModel. |
| BrushCoat | BrushCoat | Single rendering pass with one BrushTip and either paint: BrushPaint or a prioritized fallback list paintPreferences: List<BrushPaint>. Multiple BrushCoat entries in BrushFamily.coats composite from index 0 (bottom) upward. |
| BrushTip | BrushTip | Geometric mark shape at each point (scaleX, scaleY, cornerRounding, slantDegrees, pinch, rotationDegrees) plus dynamic BrushBehavior node trees. |
| BrushPaint | BrushPaint | Visual appearance (SelfOverlap, BrushPaint.ColorFunction list, and BrushPaint.TilingTexture or StampingTexture layers). |
Construct and copy BrushFamily and BrushCoat
Use the multi-coat BrushFamily(coats = listOf(...)) constructor or the
single-coat BrushFamily(tip = ..., paint = ...) shortcut, and derive variants
with .copy(...) (BrushPaint doesn't provide copy):
val singleCoatFamily = BrushFamily(
tip = BrushTip(scaleX = 1f, scaleY = 1f, cornerRounding = 1f),
paint = BrushPaint(selfOverlap = SelfOverlap.ANY),
)
val multiCoatFamily = BrushFamily(
coats = listOf(
// Coat 0: Wider semi-transparent shadow coat rendered underneath
BrushCoat(
tip = BrushTip(scaleX = 1.3f, scaleY = 1.3f, cornerRounding = 1f),
paint = BrushPaint(
selfOverlap = SelfOverlap.DISCARD,
colorFunctions = listOf(BrushPaint.ColorFunction.OpacityMultiplier(0.15f)),
),
),
// Coat 1: Main opaque stroke coat rendered on top
BrushCoat(
tip = BrushTip(scaleX = 1f, scaleY = 1f, cornerRounding = 1f),
paint = BrushPaint(selfOverlap = SelfOverlap.DISCARD),
),
),
)
val roundedVariant = singleCoatFamily.copy(
coats = listOf(
singleCoatFamily.coats.first().let { coat ->
coat.copy(tip = coat.tip.copy(cornerRounding = 0.5f))
}
),
)<br />
BrushTip geometry configuration
| Property | Type and default | Range and description |
|---|---|---|
scaleX, scaleY | Float = 1f | Horizontal and vertical tip scale multiplied by Brush.size (>= 0f; at least one must be > 0f). |
cornerRounding | Float = 1f | 0f = sharp rectangle or parallelogram; 1f = fully rounded circle or pill. |
slantDegrees | Float = 0f | Static shear angle in degrees ([-90f, 90f]) applied before rotationDegrees. |
pinch | Float = 0f | Pinches top corners together (0f = rectangle, 1f = triangle). |
rotationDegrees | Float = 0f | Static rotation offset in degrees (note: BrushTip static angles use degrees, whereas BrushBehavior dynamic angles use radians). |
particleGapDistanceScale | Float = 0f | Distance between discrete stamp particles as a multiple of brush size (0f = continuous stroke). |
particleGapDurationMillis | Long = 0L | Time interval in ms between discrete stamp particles (0L = continuous stroke). |
behaviors | List<BrushBehavior> = emptyList() | Dynamic BrushBehavior node graphs attached to this tip. |
BrushTip shape examples (calligraphy nib and flat marker)
// Narrow angled calligraphy nib (static angle in degrees)
val calligraphyTip = BrushTip(
scaleX = 0.3f,
scaleY = 1.0f,
cornerRounding = 0.2f,
rotationDegrees = 45f,
)
// Wide flat marker tip
val markerTip = BrushTip(
scaleX = 2.0f,
scaleY = 0.5f,
cornerRounding = 0.4f,
)<br />
BrushPaint blending and ColorFunction
SelfOverlap modes
SelfOverlap controls how regions where a single stroke crosses itself
blend during rendering:
| Mode | Behavior and renderer compatibility |
|---|---|
SelfOverlap.ANY (default) | Uses ACCUMULATE (CanvasMeshRenderer) on Android 14 (API level 34) and higher hardware Canvas, or DISCARD (CanvasPathRenderer) on Android 13 (API level 33) and lower or software Canvas(bitmap). Required whenever a coat uses per-vertex opacity (OPACITY_MULTIPLIER) or color BrushBehavior instances. |
SelfOverlap.ACCUMULATE | Accumulates opacity where a translucent stroke crosses itself. Requires CanvasMeshRenderer (Android 14 (API level 34) and higher hardware Canvas); rejected by CanvasPathRenderer unless a fallback BrushPaint is provided in BrushCoat.paintPreferences. |
SelfOverlap.DISCARD | Renders a flat uniform-opacity outline (CanvasPathRenderer). Incompatible with BrushPaint.StampingTexture and per-vertex opacity or color BrushBehavior instances on the same BrushCoat. |
BrushPaint.ColorFunction (OpacityMultiplier and ReplaceColor)
Apply static coat opacity or replace a coat's color (such as a fixed black
shadow coat) using BrushPaint.ColorFunction. Never call the internal
ReplaceColor.withComposeColor---use withColorLong or withColorIntArgb:
val translucentPaint = BrushPaint(
selfOverlap = SelfOverlap.ANY,
colorFunctions = listOf(
BrushPaint.ColorFunction.OpacityMultiplier(0.7f),
),
)
val fixedBlackShadowPaint = BrushPaint(
selfOverlap = SelfOverlap.DISCARD,
colorFunctions = listOf(
BrushPaint.ColorFunction.ReplaceColor.withColorLong(Color.Black.value.toLong()),
BrushPaint.ColorFunction.OpacityMultiplier(0.2f),
),
)<br />
InputModel and stroke smoothing (SlidingWindowModel)
BrushFamily.inputModel smooths raw touch and stylus points before they reach
the behavior graph:
BrushFamily.InputModel.SlidingWindowModel: Takes(windowDurationMillis, upsamplingFrequencyHz), applying a moving-average window (windowDurationMillis, default20Lms; typical range10Lto50Lms) and temporal upsampling (upsamplingFrequencyHz, default180Hz, or0to disable).BrushFamily.InputModel.DEFAULT_INPUT_MODELuses20Lms and180Hz.BrushFamily.InputModel.PASSTHROUGH_MODEL: Bypasses smoothing and upsampling; reserve strictly for synthetic or pre-modeled input streams.
Combine SlidingWindowModel (for path smoothing) with a DampingNode in
BrushBehavior (for dynamic property smoothing such as pressure):
/**
* Creates a custom [BrushFamily] configured for smooth, low-jitter strokes.
*
* @param windowDurationMillis Sliding-window duration in milliseconds (default 30ms)
* controlling input path smoothing versus latency.
* @param upsamplingFrequencyHz Target upsampling frequency in Hz (default 180Hz)
* for smooth curve subdivision.
*/
fun createSmoothBrushFamily(
windowDurationMillis: Long = 30L,
upsamplingFrequencyHz: Int = 180,
): BrushFamily {
return BrushFamily(
coats = listOf(
BrushCoat(
tip = BrushTip(
scaleX = 1f,
scaleY = 1f,
cornerRounding = 1f,
behaviors = listOf(
BrushBehavior(
terminalNode = TargetNode(
target = TargetNode.Target.SIZE_MULTIPLIER,
targetModifierRangeStart = 0.5f,
targetModifierRangeEnd = 1.5f,
input = DampingNode(
dampOver = ProgressDomain.TIME_IN_SECONDS,
strength = 0.15f,
input = SourceNode(
source = SourceNode.Source.NORMALIZED_PRESSURE,
sourceValueRangeStart = 0f,
sourceValueRangeEnd = 1f,
sourceOutOfRangeBehavior = OutOfRange.CLAMP,
),
),
),
),
),
),
paint = BrushPaint(selfOverlap = SelfOverlap.ANY),
),
),
inputModel = BrushFamily.InputModel.SlidingWindowModel(
windowDurationMillis = windowDurationMillis,
upsamplingFrequencyHz = upsamplingFrequencyHz,
),
)
}<br />
Common pitfalls
Confusing
BrushFamily.coatswithBrushCoat.paintPreferences: MultipleBrushCoatentries render on top of each other as layers; multipleBrushPaintentries inpaintPreferencesare fallback alternatives where the renderer draws only the first compatibleBrushPaint.Mixing degrees and radians :
BrushTipstatic angles (rotationDegrees,slantDegrees) use degrees (45f), whileBrushBehaviordynamic targets (ROTATION_OFFSET_IN_RADIANS,SLANT_OFFSET_IN_RADIANS) and sources (TILT_IN_RADIANS) use radians ((Math.PI / 4).toFloat()).Combining
SelfOverlap.DISCARDwith opacity or colorBrushBehaviorinstances :DISCARDforcesCanvasPathRenderer, which ignores per-vertex opacity and color behaviors. UseSelfOverlap.ANYinstead.Setting
windowDurationMillisover50Lms: Excessive smoothing windows introduce noticeable stylus lag.
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs Node.js
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Created by
- Google LLC
- Publisher
- Android (Google) (Official)
- Distributed by
- Android (Google)
- Source
- android/skills · jetpack-compose/ink/ink-custom-brush-builder · commit bf68e2d
- Licence
- Apache License 2.0 (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE.txt)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Google LLC or Android (Google).
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download ink-custom-brush-builder.zip