0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutterでのネオ・ブルータリズムUIの実装について

0
Last updated at Posted at 2026-06-02

はじめに

開発したプロダクトでのUI設計についてネオ・ブルータリズムを実装したので記事を書いていこうと思います。

image.png

目次

1. ネオ・ブルータリズムとは

一言で言うと

太い枠線 + ぼかしゼロの影 + ビビッドな色 = ネオ・ブルータリズム

ネオ・ブルータリズム(Neo-Brutalism)は、「素材感をそのまま見せる」 というブルータリズム建築の思想をデジタルUIに再解釈したデザインパターンです。

特徴的な印象

  • 力強さ:太い線が「存在感」を生む
  • 明快さ:要素の境界が曖昧にならない
  • 遊び心:派手な色使いと大胆なレイアウト

なぜゲームアプリに採用したのか

このプロダクトはシミュレーションゲームです。UIでは、ゲームとしての ワクワク感やインパクト が不足していました。ネオ・ブルータリズムの力強さは、ゲームUIが求める「触りたくなる感覚」だと感じて採用しました。

他のデザインパターンとの比較

項目 Material Design 3 Glassmorphism Neumorphism Neo-Brutalism
思想 物理的な「紙」 透明な「ガラス」 柔らかい「凹凸」 デジタルの「ブロック」
elevation + blur なし or 軽い 2方向(凹凸) offset + blur:0
枠線 なし or 極薄 なし なし 2.5-3px の太線
角丸 12-28px 大きめ 大きめ 2-4px
GPU負荷 高い(BackdropFilter) 低い
アクセシビリティ 高い やや低い 低い 高い

image.png

2. ビジュアル原則について

ネオ・ブルータリズムのUIは、以下の 5つの原則 で構成されます。

原則一覧

原則 Material Design Politownでの値
1 ソリッドシャドウ elevation + blur blurRadius: 0, Offset(4, 4)
2 太いボーダー なし or 1px borderWidth: 3.0
3 鮮やかな配色 テーマに沿った穏やかな色 #FFB300(ゴールド)
4 控えめな角丸 12-16px borderRadius: 4.0
5 太いタイポグラフィ Regular/Medium FontWeight.w900

原則1: ソリッドシャドウ — blurRadius: 0 が全ての核心

通常の box-shadow は blurRadius を使ってぼかします。ネオ・ブルータリズムでは blurRadius を 0 にして、くっきりとした塗りつぶしの影を作ります。

BoxShadow(
  color: Colors.black,     // 枠と同じ色
  offset: Offset(4, 4),    // 右下に4pxずらす
  blurRadius: 0,           // ★ これがネオ・ブルータリズムの核心
)

image.png

影のサイズは要素の重要度に応じて使い分けます。

用途 offset 印象
小さいバッジ Offset(2, 2) 控えめ
アイコンボタン Offset(3, 3) 標準
カード・ボタン Offset(4, 4) 力強い
ヒーロー要素 Offset(6, 6) 大胆

image.png

原則2: 太いボーダー

Border.all(
  width: 3.0,        // 通常の3倍
  color: cs.onSurface,
)

鉄則:枠線と影は同じ色にする。 別の色にすると「ソリッドなブロック感」が崩れます。

final effectiveBorder = borderColor ?? cs.onSurface;

decoration: BoxDecoration(
  border: Border.all(width: 3, color: effectiveBorder),
  boxShadow: [
    BoxShadow(color: effectiveBorder, offset: ..., blurRadius: 0),
    //              ↑ 枠線と同じ色 → 一体感のあるブロック表現
  ],
)

原則3: 鮮やかな配色

ネオ・ブルータリズムでは パステル寄りのビビッドカラー を多用します。Politownでは用途ごとに意味を持たせています。

class AppColors {
  static const Color primary = Color(0xFFFFB300);       // ゴールド — CTA
  static const Color secondary = Color(0xFF2563EB);     // ブルー — サブ要素
  static const Color publicOpinion = Color(0xFF2563EB); // 世論
  static const Color economy = Color(0xFFF59E0B);       // 経済
  static const Color welfare = Color(0xFF10B981);       // 福祉
  static const Color education = Color(0xFF8B5CF6);     // 教育
}

image.png

原則4: 控えめな角丸

borderRadius: BorderRadius.circular(4),  // たった4px

Material Design の borderRadius: 12 と比べて極めて小さいです。理由は3つあります。

  1. 直線的な力強さを保つ:角丸が大きいと「優しい」印象になる
  2. ブロック感:建築ブルータリズムのコンクリートブロックを想起させる
  3. 完全な0ではない:4px の角丸は「デジタルであること」の最低限の許容

原則5: 太いタイポグラフィ

ネオ・ブルータリズムのテキストは Bold 以上 が基本です。

// タイトル: w900 (Black) — 最大のインパクト
fontWeight: FontWeight.w900

// ボタンラベル: bold — 十分目立つが主張しすぎない
fontWeight: FontWeight.bold

// ステータスバー数値: w900 — ゲージとの対比で読みやすい
fontWeight: FontWeight.w900

3. インタラクション設計

影の位置に沈み込むボタン

ネオ・ブルータリズムの 最も重要なインタラクション は、ボタンが「影の位置に沈み込む」アニメーションです。Material Design の InkWell(波紋エフェクト)とは根本的に異なります。

アニメーションの3状態

状態1: 通常(t = 0.0)
┌─────────┐
│ Button  │█     ← shadow offset: (4, 4)
└─────────┘█       ボタン移動: (0, 0)
 ██████████

状態2: 半押し(t = 0.5)
   ┌─────────┐
   │ Button  │█   ← shadow offset: (2, 2)
   └─────────┘█     ボタン移動: (2, 2)
    █████████

状態3: 完全押し込み(t = 1.0)
      ┌─────────┐
      │ Button  │ ← shadow offset: (0, 0) = 影消滅
      └─────────┘   ボタン移動: (4, 4)

数学的な記述

t = アニメーション値 (0.0 → 1.0)
shadow = 初期オフセット (例: Offset(4, 4))

ボタンの移動量 = shadow * t
残りの影       = shadow * (1 - t)

合計位置は常に一定:
  shadow * t + shadow * (1 - t) = shadow

ボタンの移動量と残りの影の合計が常に一定 であることが、「影の位置に沈む」自然な感覚を生みます。

Flutter での実装

// 1. 80msの超高速アニメーション
final ctrl = useAnimationController(
  duration: const Duration(milliseconds: 80),
);

// 2. easeOutで自然な減速
final press = useMemoized(
  () => CurvedAnimation(parent: ctrl, curve: Curves.easeOut),
);

// 3. GestureDetectorでタッチイベントを捕捉
GestureDetector(
  onTapDown: (_) => ctrl.forward(),    // 指が触れた → 沈む
  onTapUp: (_) => handleTap(),         // 指が離れた → 戻る + 実行
  onTapCancel: () => ctrl.reverse(),   // キャンセル → 戻るだけ
  child: ...
)

// 4. AnimatedBuilderで毎フレーム再描画
AnimatedBuilder(
  animation: press,
  builder: (context, child) {
    final t = press.value;
    return Transform.translate(
      offset: Offset(shadow.dx * t, shadow.dy * t),  // ボタンが影方向へ移動
      child: NeoBrutalContainer(
        shadowOffset: Offset(
          shadow.dx * (1 - t),  // 影が縮む
          shadow.dy * (1 - t),
        ),
        child: child,
      ),
    );
  },
)

なぜ 80ms なのか

時間 印象
50ms 速すぎて「沈む」感覚がない
80ms 瞬間的だが「沈んだ」と認知できる
150ms やや遅い、UIの反応性が落ちる
300ms Material標準だがネオブルには遅すぎる

80ms + Curves.easeOut の組み合わせが、ネオ・ブルータリズムの「パキッ」とした操作感を生みます。

非同期安全性 — 実プロダクトで必須の3つのガード

Future<void> handleTap() async {
  ctrl.reverse();               // まず見た目を戻す
  try {
    isLoading.value = true;     // ① 連打防止ロック
    await onPressed?.call();    // 非同期処理を実行
  } finally {
    if (context.mounted) {      // ② Widgetが生きているか確認
      isLoading.value = false;  // ③ ロック解除(finallyで確実に)
    }
  }
}
  1. isLoading で連打防止: API呼び出し中に2回タップされるのを防ぐ
  2. context.mounted チェック: ナビゲーションで Widget が消えた後に setState しない
  3. try-finally: エラーが発生してもロックが必ず解除される

4. 実際の実装

ここからは Politown で実装した5つのコンポーネントを、コード付きで解説します。

ディレクトリ構成

lib/core/widgets/neo_brutal/
├── neo_brutal_container.dart      # 基盤コンテナ(61行)
├── neo_brutal_button.dart         # テキストボタン(116行)
├── neo_brutal_icon_button.dart    # アイコンボタン(104行)
├── neo_brutal_map_tile.dart       # グリッドタイル(120行)
└── neo_brutal_status_bar.dart     # ゲージバー(90行)

全て100行前後と非常に小さいのが特徴です。

コンポーネント体系の設計判断

コンポーネント ベースクラス Container使用 判断理由
Container StatelessWidget - 純粋な表示のみ
Button HookWidget 内部で使用 hooks で宣言的に状態管理
IconButton HookWidget 内部で使用 Button と同じ設計方針
MapTile StatefulWidget 不使用 大量生成のため hooks オーバーヘッドを回避
StatusBar StatelessWidget 不使用 Row内の3カラムレイアウトが Container に収まらない

4-1. NeoBrutalContainer — 全ての基盤(61行)

全てのネオ・ブルータリズムウィジェットの土台となる 純粋な表示用コンテナ です。

class NeoBrutalContainer extends StatelessWidget {
  const NeoBrutalContainer({
    super.key,
    this.child,
    this.color,
    this.borderColor,
    this.shadowOffset = const Offset(4, 4),
    this.borderWidth = 3.0,
    this.borderRadius = 4.0,
    this.padding,
    this.width,
    this.height,
  });

  final Widget? child;
  final Color? color;
  final Color? borderColor;
  final Offset shadowOffset;
  final double borderWidth;
  final double borderRadius;
  final EdgeInsetsGeometry? padding;
  final double? width;
  final double? height;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    final effectiveBorder = borderColor ?? cs.onSurface;
    return Container(
      width: width,
      height: height,
      padding: padding,
      decoration: BoxDecoration(
        color: color ?? cs.surface,
        borderRadius: BorderRadius.circular(borderRadius),
        border: Border.all(width: borderWidth, color: effectiveBorder),
        boxShadow: shadowOffset != Offset.zero
            ? [
                BoxShadow(
                  color: effectiveBorder,
                  offset: shadowOffset,
                  blurRadius: 0, // ★ ネオ・ブルータリズムの核心
                ),
              ]
            : null,
      ),
      child: child,
    );
  }
}

image.png

設計ポイント

  1. shadowOffset != Offset.zero での条件分岐: 押し込みアニメーションの完了時に boxShadow: null とすることで不要な描画を省く
  2. colorScheme へのフォールバック: color ?? cs.surface, borderColor ?? cs.onSurface によりテーマ変更に自動追従
  3. StatelessWidget: 表示専用なので状態を持たない

使用例

// 基本的な使い方
NeoBrutalContainer(
  padding: const EdgeInsets.all(16),
  child: Text('Hello Neo-Brutalism!'),
)

// ネストしてタグを表現
NeoBrutalContainer(
  padding: const EdgeInsets.all(16),
  child: Column(
    children: [
      Text('カードタイトル', style: TextStyle(fontWeight: FontWeight.w900)),
      NeoBrutalContainer(
        color: cs.primaryContainer,
        borderColor: cs.primary,
        shadowOffset: const Offset(2, 2),  // 親より小さい影
        padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6),
        child: Text('タグ'),
      ),
    ],
  ),
)

4-2. NeoBrutalButton — へこむボタン(116行)

タップ時に影の位置に「へこむ」アニメーションを持つボタンです。

class NeoBrutalButton extends HookWidget {
  const NeoBrutalButton({
    super.key,
    required this.label,
    required this.onPressed,
    this.sublabel,
    this.color,
    this.borderColor,
    this.labelStyle,
    this.shadowOffset = const Offset(4, 4),
    this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
    this.enabled = true,
  });

  final String label;
  final String? sublabel;
  final Future<void> Function()? onPressed;
  final Color? color;
  final Color? borderColor;
  final TextStyle? labelStyle;
  final Offset shadowOffset;
  final EdgeInsetsGeometry padding;
  final bool enabled;

  @override
  Widget build(BuildContext context) {
    // 80msのアニメーションコントローラー
    final ctrl = useAnimationController(
      duration: const Duration(milliseconds: 80),
    );
    final press = useMemoized(
      () => CurvedAnimation(parent: ctrl, curve: Curves.easeOut),
    );
    final isLoading = useState(false);

    final isInteractive = enabled && onPressed != null && !isLoading.value;
    final cs = Theme.of(context).colorScheme;
    final shadow = shadowOffset;

    Future<void> handleTap() async {
      ctrl.reverse();
      try {
        isLoading.value = true;
        await onPressed?.call();
      } finally {
        if (context.mounted) isLoading.value = false;
      }
    }

    return AnimatedBuilder(
      animation: press,
      builder: (context, child) {
        final t = press.value;
        return Transform.translate(
          // ボタンが影の方向に移動
          offset: Offset(shadow.dx * t, shadow.dy * t),
          child: GestureDetector(
            onTapDown: isInteractive ? (_) => ctrl.forward() : null,
            onTapUp: isInteractive ? (_) => handleTap() : null,
            onTapCancel: isInteractive ? () => ctrl.reverse() : null,
            child: NeoBrutalContainer(
              color: isInteractive
                  ? (color ?? cs.primary)
                  : cs.surfaceContainerHighest,
              borderColor: borderColor ?? cs.onSurface,
              // 影が縮む(移動量 + 残り影 = 常に一定)
              shadowOffset: Offset(
                shadow.dx * (1 - t),
                shadow.dy * (1 - t),
              ),
              padding: padding,
              child: child,
            ),
          ),
        );
      },
      child: Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
    );
  }
}

ポイント

  • HookWidget ベースで useAnimationController を使い宣言的に記述
  • 非活性時cs.surfaceContainerHighest(グレー系)に自動変化
  • isLoading で連打防止 + context.mounted でアンマウント後の安全性確保

4-3. NeoBrutalIconButton — アイコン専用ボタン(104行)

NeoBrutalButton と同じ押し込みアニメーションを持ちますが、アイコン表示 に特化しています。

class NeoBrutalIconButton extends HookWidget {
  const NeoBrutalIconButton({
    super.key,
    required this.icon,
    required this.onPressed,
    this.color,
    this.borderColor,
    this.shadowOffset = const Offset(3, 3),  // Buttonより控えめ
    this.size = 48,
    this.iconSize = 20,
  });

  final IconData icon;
  final Future<void> Function()? onPressed;
  // ... 省略(アニメーション部分はButtonと同じ)

  // ローディング中はアイコンがスピナーに差し替わる
  child: isLoading.value
      ? Center(child: SizedBox(
          width: iconSize - 2,
          height: iconSize - 2,
          child: CircularProgressIndicator(strokeWidth: 2),
        ))
      : Center(child: Icon(icon, size: iconSize)),
}

image.png

Button との違い

項目 NeoBrutalButton NeoBrutalIconButton
内容 テキスト IconData
サイズ 可変(padding制御) 固定(48px)
ローディング なし CircularProgressIndicator に差し替え
shadowOffset (4, 4) (3, 3) — 小さい要素なので控えめ

4-4. NeoBrutalMapTile — グリッドタイル(120行)

マップグリッドのタイルです。他のコンポーネントとは異なり、StatefulWidget で実装しています。

class NeoBrutalMapTile extends StatefulWidget {
  const NeoBrutalMapTile({
    super.key,
    required this.child,
    required this.color,
    this.onTap,
    this.shadowOffset = const Offset(4, 4),
    this.borderWidth = 3.0,
    this.margin = const EdgeInsets.all(3),
    this.badge,    // ★ バッジオーバーレイ
  });
  // ...
}

class _NeoBrutalMapTileState extends State<NeoBrutalMapTile>
    with SingleTickerProviderStateMixin {
  // ...
  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _press,
      builder: (context, child) {
        // ... 押し込みアニメーション(Button と同じ原理)
        return Stack(
          clipBehavior: Clip.none,  // ★ バッジのはみ出しを許可
          children: [
            Container(
              margin: widget.margin,
              decoration: BoxDecoration(
                color: widget.color,
                borderRadius: BorderRadius.circular(4),
                border: Border.all(width: widget.borderWidth, color: cs.onSurface),
                boxShadow: [
                  BoxShadow(color: cs.onSurface, offset: remainingShadow, blurRadius: 0),
                ],
              ),
              child: child,
            ),
            if (widget.badge != null)
              Positioned(top: 0, right: 0, child: widget.badge!),
          ],
        );
      },
    );
  }
}

image.png

なぜ HookWidget ではないのか

MapTile はグリッド内で 数十〜数百個 同時にインスタンス化されます。HookWidget は内部でフック登録リストを管理するため、大量生成時に微小なオーバーヘッドが蓄積します。StatefulWidget + SingleTickerProviderStateMixinAnimationController の生成コストを最小化しました。

バッジのポイント

clipBehavior: Clip.none でバッジがタイルの外にはみ出せるようにしています。バッジ自体もネオ・ブルータリズムの原則に従い、太い枠線 + ソリッドシャドウを持ちます(ただしサイズに応じて控えめに)。


4-5. NeoBrutalStatusBar — ゲージバー(90行)

ステータスバーは NeoBrutalContainer を 使わず、自前で BoxDecoration を構築しています。

class NeoBrutalStatusBar extends StatelessWidget {
  const NeoBrutalStatusBar({
    super.key,
    this.label,
    required this.value,
    required this.maxValue,
    this.color,
    this.height = 28.0,
  });

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    final gaugeColor = color ?? cs.primary;
    final ratio = (maxValue > 0 ? value / maxValue : 0.0).clamp(0.0, 1.0);

    return Row(
      children: [
        // ラベル(52px固定幅)
        if (label != null) ...[
          SizedBox(width: 52, child: Text(label!, style: TextStyle(fontWeight: FontWeight.bold))),
          const SizedBox(width: 8),
        ],
        // ゲージ本体
        Expanded(
          child: Container(
            height: height,
            decoration: BoxDecoration(
              color: cs.surface,
              borderRadius: BorderRadius.circular(4),
              border: Border.all(width: 2.5, color: cs.onSurface),
              boxShadow: [
                BoxShadow(color: cs.onSurface, offset: const Offset(3, 3), blurRadius: 0),
              ],
            ),
            clipBehavior: Clip.antiAlias,  // ★ ゲージが枠からはみ出さない
            child: FractionallySizedBox(
              alignment: Alignment.centerLeft,
              widthFactor: ratio,           // 0.0 〜 1.0
              child: Container(
                decoration: BoxDecoration(
                  color: gaugeColor,
                  borderRadius: BorderRadius.circular(2),
                ),
              ),
            ),
          ),
        ),
        const SizedBox(width: 8),
        // 数値(40px固定幅)
        SizedBox(
          width: 40,
          child: Text('$value', textAlign: TextAlign.right,
            style: TextStyle(fontWeight: FontWeight.w900)),
        ),
      ],
    );
  }
}

image.png

なぜ Container を使わなかったのか

StatusBar は Row 内にラベル + ゲージ + 数値を横並びにする独自レイアウトを持ちます。NeoBrutalContainer は単一の child を受け取る Box なので、この構造には対応できません。ただし、BoxDecoration のルール(太枠 + blur:0 の影)は同じ です。


実践パターン:ネスト・バッジ・階層設計

ネストのルール

コンテナをネストする場合の3つのルールです。

  1. 外側の影 > 内側の影: カード Offset(4, 4) > タグ Offset(2, 2)
  2. 内側は異なる色で差別化: カード cs.surface / タグ cs.primaryContainer
  3. 内側の枠色は親テーマ色に合わせる: タグ borderColor: cs.primary

階層設計のガイドライン

階層 要素例 shadowOffset borderWidth
Lv1 ページカード (4, 4) 3.0
Lv2 カード内セクション (3, 3) 3.0
Lv3 セクション内タグ (2, 2) 2.5
Lv4 タグ内バッジ (2, 2) 2.0

原則:階層が深くなるほど影とボーダーは小さくなる。


カラーシステムとの統合

ネオ・ブルータリズムだからといって Material 3 の ColorScheme を捨てる必要はありません。

ThemeData buildAppTheme(GameFont font) {
  return ThemeData(
    useMaterial3: true,
    colorScheme: const ColorScheme.light(
      primary: AppColors.primary,         // ボタンの背景色
      onPrimary: AppColors.textOnPrimary, // ボタンのテキスト色
      surface: AppColors.surface,         // Container の背景色
      onSurface: AppColors.textPrimary,   // ★ 枠線・影の色になる
    ),
  );
}

コンポーネントは Theme.of(context).colorScheme を参照するので、テーマを変えるだけで全コンポーネントの見た目が変わります

  • ライトテーマ: onSurface ≒ 黒 → コントラスト強い枠線・影
  • ダークテーマ: onSurface ≒ 白 → テーマに自然に適応

5. まとめ

ネオ・ブルータリズムUIの実装に必要なのは、突き詰めれば 3つの数値 だけです。

blurRadius: 0        // ソリッドシャドウ
Border.all(width: 3) // 太い枠線
borderRadius: 4      // 控えめな角丸

Politown では、この原則を NeoBrutalContainer に定数化し、そこから Button / IconButton / MapTile / StatusBar の 5コンポーネント を派生させました。全て100行前後と小さく、アプリ全体の視覚的統一感を担っています。

この記事で押さえてほしいこと

# ポイント
1 blurRadius: 0 がネオ・ブルータリズムの核心
2 枠線と影は 同じ色 にする
3 押し込みアニメーションは shadow * t + shadow * (1-t) で合計一定
4 80ms + Curves.easeOut がパキッとした操作感を生む
5 階層が深くなるほど影とボーダーは小さくする

やってはいけない5つのこと

// NG 1: blurRadiusを0以外にする → もはやネオブルではない
BoxShadow(offset: Offset(4, 4), blurRadius: 8)

// NG 2: 枠線を省略する → アイデンティティの喪失
BoxDecoration(color: Colors.yellow, boxShadow: [...]) // borderが無い!

// NG 3: 影と枠線の色を変える → 統一感が崩れる
border: Border.all(color: Colors.black),
boxShadow: [BoxShadow(color: Colors.blue)] // 色が違う!

// NG 4: 角丸を大きくしすぎる → Material Designに見える
borderRadius: BorderRadius.circular(16)

// NG 5: 押し込み方向を影と逆にする → 不自然な動き
Transform.translate(offset: Offset(-shadow.dx * t, ...)) // 逆!

ぜひ5つのコンポーネントを自分のプロジェクトにコピーして、Material Design とは異なる「パキッ」とした触り心地を体験してみてください。


参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?