Back to blog
Devlog

90 CustomPainters: Why BloomCard Ditched Every Single Emoji

bloomcardfluttercustom-painterrendering

The first bug report came from a Samsung Galaxy user. "Why does the sunflower look different from my friend's phone?" At the time, I assumed it was a minor rendering glitch — maybe a font caching issue. I told the user to restart the app and moved on.

The second report came a week later. Then the third. Then the screenshots started rolling in.

The same flashcard, viewed on three different devices, showed three visually distinct flowers. On iOS, the sunflower emoji was detailed and colorful. On a Pixel, it was flat and cartoonish. On Samsung, it was a completely different style. Our garden-themed flashcard app — BloomCard — was supposed to feel like a cohesive, beautiful experience. Instead, it looked like three different apps depending on which phone you bought.

That was the moment I decided: every single emoji had to go.

The emoji problem nobody warns you about

If you have never built a cross-platform app that relies on emoji for visual identity, here is the core issue: emoji are fonts, not images. Each operating system ships its own emoji font, and each vendor interprets the Unicode specification differently.

Apple's emoji are highly detailed, three-dimensional illustrations. Google's (used on stock Android) went through a major redesign from blob-style to flat circles. Samsung created their own set that frequently looks nothing like either. Then there are Xiaomi, Huawei, and LG, each with their own interpretations.

For a note-taking app, this is a cosmetic annoyance. For BloomCard, it was a design-breaking problem.

BloomCard is a garden-themed flashcard app. When you study and review cards, your flowers grow through four stages: seed, sprout, bud, and bloom. You collect 21 different flower species across four rarity tiers — Common, Uncommon, Rare, and Legendary. You adopt virtual pets, decorate your garden, and share screenshots of your progress with friends.

Every single one of these visual elements was originally an emoji. The tulip, the sunflower, the cat, the crystal ball, the snowman — all emoji. And every single one of them rendered differently on every phone.

The inconsistency did not just look bad. It broke the game mechanics. Rarity tiers lost their visual distinction when a Legendary lotus looked nearly identical to a Common daisy on certain devices. Pet species became unrecognizable. Garden decorations that were charming on one phone looked like abstract shapes on another.

I had three options: bundle image assets, use an icon library, or draw everything myself.

Why not images? Why not icon fonts?

Bundled PNGs or SVGs were the obvious first choice. But BloomCard has 21 flower species, each with four growth stages (seed, sprout, bud, bloom), plus wilt states. That is potentially 84+ flower assets before you even count pets, cosmetics, and decorations. At multiple resolutions for different screen densities, the asset bundle would balloon quickly.

Icon fonts like Material Icons or FontAwesome cover generic use cases, but they do not have "tulip at bud stage" or "fox wearing a tiny hat." The visual identity of BloomCard is too specific for any existing icon library.

That left CustomPainter — Flutter's API for drawing directly to a canvas using Dart code. No external assets. No font dependencies. Pure math and paint commands.

I chose the nuclear option: replace every emoji in the entire app with hand-coded CustomPainter classes.

The scope: 90+ painters and counting

Here is the full breakdown of what I built:

FlowerPainter — 21 species

  • 8 Common flowers: daisy, dandelion, clover, buttercup, marigold, pansy, petunia, violet
  • 5 Uncommon flowers: tulip, sunflower, lily, iris, hydrangea
  • 4 Rare flowers: orchid, rose, cherry blossom, camellia
  • 3 Legendary flowers: lotus, blue rose, golden lily

Each flower has four growth stages (seed, sprout, bud, bloom), five petal shapes (round, oval, pointed, heart, star, layered), and three detail levels that adapt based on render size.

PetSpeciesPainter — 5 species

  • Cat, rabbit, fox, owl, squirrel
  • Each with a sparkle overlay variant for master evolution stage

CosmeticPainter — 34 items

  • Hats, scarves, glasses, accessories — everything your virtual pet can wear

Decoration painters — 15 garden decorations

  • Candle, cat figurine, photo frame, watering can, wind chime, snow globe, crystal, herb pot, music box, dragon figurine, enchanted mirror, sakura lamp, seashell, pumpkin, snowman
  • Each placed across 6 decoration zones in the garden

Grand total: over 90 CustomPainter classes, all written by hand.

Anatomy of a painter

Let me walk through a simplified version of the FlowerPainter to show what this looks like in practice. The real implementation uses mixins for stage-specific rendering (SeedRenderer, LeafRenderer, BudRenderer), but the core structure looks like this:

class FlowerPainter extends CustomPainter {
  final FlowerDef flower;
  final FlowerGrowthStage stage;
  final double animationProgress;

  FlowerPainter({
    required this.flower,
    this.stage = FlowerGrowthStage.bloom,
    this.animationProgress = 1.0,
  });

  @override
  void paint(Canvas canvas, Size size) {
    final center = Offset(size.width / 2, size.height / 2);
    final radius = min(size.width, size.height) / 2;

    switch (stage) {
      case FlowerGrowthStage.seed:
        _paintSeed(canvas, center, radius, size);
      case FlowerGrowthStage.sprout:
        _paintSprout(canvas, center, radius, size);
      case FlowerGrowthStage.bud:
        _paintBud(canvas, center, radius, size);
      case FlowerGrowthStage.bloom:
        _paintBloom(canvas, center, radius, size);
    }
  }
}

The FlowerDef data object carries all the variety-specific parameters: primaryColor, centerColor, petalCount, petalWidth, petalShape. The painter reads these parameters and uses them to calculate bezier curves, gradients, and shadows.

For the bloom stage, petals are drawn using quadratic bezier curves:

final angle = (2 * pi / petalCount) * i - pi / 2;
final tipX = center.dx + cos(angle) * petalRadius;
final tipY = center.dy + sin(angle) * petalRadius;

petalPath = Path()
  ..moveTo(center.dx, center.dy)
  ..quadraticBezierTo(ctrl1.dx, ctrl1.dy, tipX, tipY)
  ..quadraticBezierTo(ctrl2.dx, ctrl2.dy, center.dx, center.dy)
  ..close();

Each petal gets a radial gradient fill that transitions from a lighter center to the full color at the edges, plus a subtle drop shadow underneath. The flower center gets its own radial gradient with a white highlight offset to simulate lighting.

The key insight: the same painter class renders all 21 flower species. The visual difference comes entirely from the FlowerDef parameters. A daisy has 13 round petals with a yellow center. A cherry blossom has 5 heart-shaped petals with a pink center. A sunflower has 21 pointed petals with a brown center. Same code path, completely different flowers.

Adaptive detail levels

One thing I am particularly proud of is the adaptive detail system. When a flower renders at less than 36 pixels (like in a dense garden grid), it skips shadows and gradients entirely — just flat colors. Between 36 and 64 pixels, it adds gradients and simple shadows. Above 64 pixels, it renders the full effect with glow layers and highlights.

enum FlowerDetailLevel {
  compact,   // < 36px — no shadow, no gradient
  standard,  // 36-64px — gradient + simple shadow
  detailed,  // 64px+ — full effects
}

This was not just an optimization. The compact level actually looks better at small sizes because gradients and shadows at 24 pixels just look like visual noise. Stripping them away gives you clean, crisp icons that read clearly at any size.

The decoration painters: 15 miniature artworks

The garden decoration system was the most fun to build. Each decoration is essentially a tiny painting — a snow globe with floating particles, a wind chime that sways, a crystal with shimmer effects.

The decorations use a router function to map IDs to painters:

CustomPainter? getDecorationPainter(String decorationId, {double progress = 0}) {
  switch (decorationId) {
    case 'candle':
      return _CandlePainter(progress: progress);
    case 'snow_globe':
      return _SnowGlobePainter(progress: progress);
    case 'wind_chime':
      return _WindChimePainter(progress: progress);
    // ... 15 total
  }
}

The progress parameter drives animations — flame flicker for the candle, gentle sway for the wind chime, snow falling inside the globe. The snow globe painter is a favorite: it clips the canvas to a circle, draws a tiny tree and snow ground inside, then sprinkles animated snowflake particles that loop endlessly.

The candle painter creates a convincing flame flicker using a simple sine wave:

final flameFlicker = sin(progress * pi * 6) * 0.08;
final flameH = h * (0.18 + flameFlicker);

That single line of trigonometry, combined with a teardrop-shaped bezier path and an outer glow blur, creates a surprisingly convincing candlelight effect.

The isolate problem: Taylor series for trigonometry

This is where things got interesting. BloomCard generates shareable garden images — 1080x1080 PNG cards that users can post to social media. The ShareCardGenerator class uses raw dart:ui Canvas to render these images without the Flutter widget layer, making it suitable for background processing.

The share card includes a small decorative flower icon in the watermark bar. Drawing that flower requires cos() and sin() for petal positioning. Simple enough — except that the share card generator needed to be isolate-safe.

In Dart, isolates have restrictions on what libraries they can access. While dart:math is technically available in isolates, I wanted the share card generator to have zero dependencies on anything that might cause issues in background processing contexts. The solution was absurdly simple: Taylor series approximations.

static double _cos(double radians) {
  final x = radians % (2 * 3.14159265);
  final x2 = x * x;
  return 1 - x2 / 2 + x2 * x2 / 24 - x2 * x2 * x2 / 720;
}

static double _sin(double radians) {
  final x = radians % (2 * 3.14159265);
  final x2 = x * x;
  return x - x2 * x / 6 + x2 * x2 * x / 120 - x2 * x2 * x2 * x / 5040;
}

Six terms of the Taylor series expansion. Accurate to several decimal places, which is more than sufficient for positioning five flower petals. The entire share card — background gradient, display name, flower grid, stats row, brand watermark with flower icon — is rendered purely through canvas operations with zero external dependencies.

The wilt system: desaturation through color matrices

One feature that would have been impossible with emoji is the flower wilt system. When a user neglects their flashcard reviews, their flowers start wilting. The FlowerWidget applies a progressive visual degradation:

  1. A luminance-weighted desaturation matrix strips color as wilt increases
  2. A brownish tint overlay simulates dying foliage
  3. A slight rotation droops the flower
  4. A scale reduction shrinks it

The desaturation uses a ColorFilter.matrix with luminance coefficients (0.2126, 0.7152, 0.0722) — the same weights used in the Rec. 709 standard for converting color to grayscale. By interpolating the saturation multiplier based on wilt level, the flower smoothly transitions from vibrant to grey-brown.

Try doing that with an emoji.

No emoji in i18n either

One rule I enforce strictly: no emoji in internationalization strings. BloomCard supports English and Korean with approximately 1,178 translation keys. None of them contain emoji characters. All visual elements are rendered in the UI layer through painters, Material Icons, or custom widgets.

This is not just aesthetic consistency. It is a practical separation of concerns. The translation files contain text. The UI layer contains visuals. Mixing them creates maintenance headaches and makes it impossible to update visuals without touching every localization file.

The trade-offs

Let me be honest about what this approach costs.

90+ classes to maintain. Every new flower species, pet cosmetic, or garden decoration means writing a new painter from scratch. There is no drag-and-drop asset pipeline. Each painter is hand-crafted code — bezier curves, gradient definitions, shadow parameters — that needs to be correct and performant.

Development time. Drawing a single decoration painter takes 30 minutes to an hour. The snow globe alone is 80 lines of carefully tuned coordinates. Multiply that across 90+ painters and you are looking at weeks of cumulative work.

No designer handoff. A traditional asset pipeline lets designers export PNGs or SVGs and developers drop them in. With CustomPainter, I am the designer. Every visual element starts as mental geometry, gets translated to coordinates, and is refined through iteration. There is no Figma-to-code shortcut.

Testing complexity. You cannot visually diff a painter output in a unit test. I use widget tests that verify painters render without errors and produce expected semantics labels, but catching visual regressions requires actual visual inspection.

The unexpected benefits

Despite the costs, several benefits surprised me.

Smaller binary. Zero bundled image assets means a significantly smaller APK/IPA. No PNGs at 1x, 2x, 3x densities. No SVG parsing library. The painters are just Dart code — they compile down to the same binary as the rest of the app.

Perfect scaling. Because everything is vector math, painters look crisp at any size. The adaptive detail system means they also perform well at any size. A flower at 24 pixels is just as sharp as one at 200 pixels, with appropriate detail for each.

Animatability. Since painters accept parameters, animation is trivial. The AnimatedFlowerWidget smoothly transitions between growth stages using a simple AnimationController. The wind chime sways. The candle flickers. The crystal shimmers. All driven by a single progress parameter fed through painters that already know how to draw at any state.

Pixel-perfect consistency. This was the original goal, and it delivered completely. A BloomCard garden looks identical on every device that runs Flutter. Samsung, iOS, Pixel, Xiaomi — the same bezier curves, the same gradients, the same shadows. No more "why does my flower look different" bug reports.

Theming control. Because colors are defined in code, implementing dark mode or seasonal themes is straightforward. I can tint every flower, adjust every shadow, shift every gradient — all from theme parameters. Try recoloring an emoji.

Would I do it again?

Absolutely — but I would scope it differently.

If I were starting BloomCard today, I would still go all-in on CustomPainter for core identity elements: flowers, pets, and decorations. These are the visual soul of the app. They need to be consistent, animatable, and expressive.

For peripheral UI elements, I would lean more on Material Icons where they exist. Not every icon needs to be a custom painting. The key is identifying which visuals define your brand and which are just functional affordances.

The 90-painter approach works because BloomCard's entire identity is its visuals. The garden, the flowers, the pets — these are not decorations on top of a flashcard app. They are the app's personality. When that personality renders differently on every device, the app loses its soul.

So we drew our own soul. One bezier curve at a time.


BloomCard is a garden-themed flashcard app built with Flutter. You can learn more at bloomcard.9-87.org.

90 CustomPainters: Why BloomCard Ditched Every Single Emoji