Zenn
🍡

Flutterでのボタン設計:Dartのクラス設計を活用した実装方法

2025/02/11に公開

ボタンって、ほとんどのアプリで使いますよね。
特に、大きさや色、形の違うバリエーションを持つことも多いと思います。

今回は、大きさ3パターン・色3パターンの塗りつぶしボタンを実装しながら、Dartのクラス設計について改めて学んでみようと思います!

はじめに: この記事のスコープ

この記事では、シンプルながらバリエーションの多いボタンコンポーネントの設計と実装を紹介し、そこから派生してDartのクラス設計の基礎知識について整理していきます。
なお、Widgetの基本的な使い方には触れませんので、ご注意ください。

対象読者

  • Flutterである程度UIを作れる方
  • なんとなく実装はできるけど、おまじないのようにコードを書くことがある方

今回作成するボタン

今回のボタンは、
✅ 大きさ3パターン(Large・Medium・Small)
✅ 色3パターン(Red・Orange・White)
✅ 塗りつぶしデザイン

以下に「Mediumサイズ」のボタンデザインを紹介します。

活性時 押下時 非活性時 ローディング時
デザイン
背景色 #f68711 #f68711 #fdf0e2 12% #fdf0e2 12%
文字&アイコン色 #ffffff #ffffff #3f2d1a #ffffff
オーバーレイ なし #ffffff 12% なし なし

padding: 上下11/左右16/間8
アイコンサイズ:18
テキストスタイル:フォントサイズ14/bold
ボーダーの丸み:100

実装したもの

コード全量

import 'package:flutter/material.dart';

// ボタンの色タイプを定義する列挙型
enum SampleFilledButtonColorType {
  red(
    backgroundColor: Color(0xFFEC5353),
    disabledBackgroundColor: Color(0xFFEC5353),
    iconAndTextColor: Color(0xFF000000),
    disabledIconAndTextColor: Color(0xFF3F1A1A),
  ),
  orange(
    backgroundColor: Color(0xFFF68711),
    disabledBackgroundColor: Color(0xFFF68711),
    iconAndTextColor: Color(0xFF000000),
    disabledIconAndTextColor: Color(0xFF3F2D1A),
  ),
  white(
    backgroundColor: Color(0xFFFFFFFF),
    disabledBackgroundColor: Color(0xFF000000),
    iconAndTextColor: Color(0xFF000000),
    disabledIconAndTextColor: Color(0xFF474747),
  );

  const SampleFilledButtonColorType({
    required this.backgroundColor,
    required this.disabledBackgroundColor,
    required this.iconAndTextColor,
    required this.disabledIconAndTextColor,
  });

  final Color backgroundColor;
  final Color disabledBackgroundColor;
  final Color iconAndTextColor;
  final Color disabledIconAndTextColor;
}

class SampleFilledButton extends StatelessWidget {
  // プライベートコンストラクタ
  const SampleFilledButton._({
    super.key,
    required this.label,
    required this.onPressed,
    required this.icon,
    required this.iconSize,
    required this.padding,
    required this.fontSize,
    required this.isLoading,
    required this.colorType,
  });

  // 大きいボタン用のファクトリコンストラクタ
  factory SampleFilledButton.large({
    Key? key,
    required String label,
    required VoidCallback? onPressed,
    required IconData icon,
    required bool isLoading,
    required SampleFilledButtonColorType colorType,
  }) {
    return SampleFilledButton._(
      key: key,
      label: label,
      onPressed: onPressed,
      icon: icon,
      iconSize: 24,
      padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
      fontSize: 16,
      isLoading: isLoading,
      colorType: colorType,
    );
  }

  // 中くらいのボタン用のファクトリコンストラクタ
  factory SampleFilledButton.medium({
    Key? key,
    required String label,
    required VoidCallback? onPressed,
    required IconData icon,
    required bool isLoading,
    required SampleFilledButtonColorType colorType,
  }) {
    return SampleFilledButton._(
      key: key,
      label: label,
      onPressed: onPressed,
      icon: icon,
      iconSize: 18,
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
      fontSize: 14,
      isLoading: isLoading,
      colorType: colorType,
    );
  }

  // 小さいボタン用のファクトリコンストラクタ
  factory SampleFilledButton.small({
    Key? key,
    required String label,
    required VoidCallback? onPressed,
    required IconData icon,
    required bool isLoading,
    required SampleFilledButtonColorType colorType,
  }) {
    return SampleFilledButton._(
      key: key,
      label: label,
      onPressed: onPressed,
      icon: icon,
      iconSize: 14,
      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
      fontSize: 12,
      isLoading: isLoading,
      colorType: colorType,
    );
  }

  // ボタンのプロパティ
  final String label;
  final VoidCallback? onPressed;
  final IconData icon;
  final double iconSize;
  final EdgeInsets padding;
  final double fontSize;
  final bool isLoading;
  final SampleFilledButtonColorType colorType;

  
  Widget build(BuildContext context) {
    final iconAndTextColor = onPressed == null
        ? colorType.disabledIconAndTextColor
        : colorType.iconAndTextColor;

    return ElevatedButton(
      onPressed: isLoading ? null : onPressed,
      style: ElevatedButton.styleFrom(
        padding: padding,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(100),
        ),
        backgroundColor: isLoading
            ? colorType.disabledBackgroundColor.withOpacity(0.12)
            : colorType.backgroundColor,
        disabledBackgroundColor:
            colorType.disabledBackgroundColor.withOpacity(0.12),
        foregroundColor: colorType.iconAndTextColor.withOpacity(0.12),
      ),
      child: isLoading
          ? SizedBox(
              width: iconSize,
              height: iconSize,
              child: CircularProgressIndicator(
                valueColor: AlwaysStoppedAnimation(iconAndTextColor),
                strokeWidth: 1,
              ),
            )
          : Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Icon(
                  icon,
                  size: iconSize,
                  color: iconAndTextColor,
                ),
                const SizedBox(width: 8),
                Text(
                  label,
                  style: TextStyle(
                    fontSize: fontSize,
                    fontWeight: FontWeight.bold,
                    color: iconAndTextColor,
                  ),
                ),
              ],
            ),
    );
  }
}

実装結果

WidgetBookで確認しました!

コード解説

ファクトリコンストラクタの活用

Flutterでは、ウィジェットのカスタムコンストラクタを定義することで、柔軟にUIコンポーネントを設計できます。
今回のボタン実装では、ファクトリコンストラクタ (Factory Constructor) を活用しました。

ここでは、
✅ ファクトリコンストラクタとは何か
✅ 名前付きコンストラクタ (Named Constructor) との違い
✅ 今回のボタンでファクトリコンストラクタを使った理由

を解説していきます!

ファクトリコンストラクタ (Factory Constructor) とは?

ファクトリコンストラクタとは、新しいインスタンスを作成する方法をカスタマイズできるコンストラクタのことです。
通常のコンストラクタ (SampleFilledButton._) とは異なり、factory キーワードを使うことで、

  • 常に新しいインスタンスを作成する必要がない(キャッシュの再利用などが可能)
  • サブクラスのインスタンスを返すことができる
  • コンストラクタ内でロジックを実行できる(条件に応じたインスタンスの作成)
    といった特徴があります。

例:ファクトリコンストラクタを使った実装

class Sample {
  final int value;

  // プライベートコンストラクタ
  const Sample._(this.value);

  // ファクトリコンストラクタ
  factory Sample.create(int value) {
    if (value < 0) {
      return Sample._(0); // 負の値なら 0 にする
    }
    return Sample._(value);
  }
}

このように、通常の Sample(value) ではなく Sample.create(value) を使うことで、
意図したルールに沿ったインスタンスを作成 できます。

名前付きコンストラクタ (Named Constructor) とは?

Dartでは、通常のコンストラクタ (Sample()) とは別に、
異なる初期化方法を提供するために名前を付けたコンストラクタを定義できます。
これが名前付きコンストラクタ (Named Constructor) です。

例:名前付きコンストラクタを使った実装

class Person {
  final String name;
  final int age;

  // 通常のコンストラクタ
  Person(this.name, this.age);

  // 名前付きコンストラクタ
  Person.withDefault() : name = 'John Doe', age = 30;
}

このように Person.withDefault() を使うと、デフォルト値を持つ Person を簡単に作成できます。
一方で、ファクトリコンストラクタのようにインスタンスのキャッシュや条件分岐はできません。

ファクトリコンストラクタと名前付きコンストラクタの違いは?

ファクトリコンストラクタ 名前付きコンストラクタ
factory キーワードが必要 ✅ 必須 ❌ 不要
return を使える ✅ 可能 ❌ できない (暗黙的に return this)
キャッシュや既存のインスタンスを返せる ✅ 可能 ❌ できない
サブクラスのインスタンスを返せる ✅ 可能 ❌ できない
オブジェクトの初期化のみを行う ❌ しなくてもよい ✅ する

今回はファクトリコンストラクタを利用した理由

今回の SampleFilledButton では、large()medium()small() という3種類のボタンを提供しています。
これを実現するためにファクトリコンストラクタを使いました。

// 大きいボタン
factory SampleFilledButton.large({ ... }) {
  return SampleFilledButton._(
    key: key,
    label: label,
    onPressed: onPressed,
    icon: icon,
    iconSize: 24,
    padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
    fontSize: 16,
    isLoading: isLoading,
    colorType: colorType,
  );
}

// 中くらいのボタン
factory SampleFilledButton.medium({ ... }) {
  return SampleFilledButton._(
    key: key,
    label: label,
    onPressed: onPressed,
    icon: icon,
    iconSize: 18,
    padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
    fontSize: 14,
    isLoading: isLoading,
    colorType: colorType,
  );
}

このように、factory を使うことで、

  • 統一したルールでボタンを作成できる
  • コンストラクタの分岐ロジックを追加しやすい(例:サイズのバリデーション)
  • ボタンのバリエーションを簡潔に管理できる
    といったメリットが得られます。

まとめ

✅ ファクトリコンストラクタは、factory を使ってインスタンスの生成をカスタマイズできる。
✅ 名前付きコンストラクタは、異なる初期化方法を提供するためのシンプルな手段。
✅ 今回のボタンでは、バリエーションを統一的に管理するためにファクトリコンストラクタを活用した!

イミュータブルな設計

イミュータブル(不変)とは?

イミュータブル(不変) とは、オブジェクトが一度作成された後、その状態が変更できないことを指します。
通常、オブジェクトのプロパティや状態を変更することができますが、イミュータブルなオブジェクトでは、状態が変更されることはないため、作成後に値を更新することができません。

例:イミュータブルなクラス

class Person {
  final String name;  // final なので不変
  final int age;      // final なので不変

  // コンストラクタで初期化
  const Person(this.name, this.age);
}

void main() {
  final person = Person('Alice', 25);
  // person.name = 'Bob'; // これはエラーになります
}

イミュータブルな設計のメリット

  1. 予測可能な動作
    イミュータブルなオブジェクトは、作成後に状態が変わることがないため、状態を追跡するのが簡単です。
    例えば、Person の名前が途中で変わることがないので、どこからでも name を参照しても同じ値が得られることが保証されます。

  2. スレッドセーフ
    イミュータブルなオブジェクトは、複数のスレッドから同時にアクセスされても状態が変わることがないため、競合状態が発生しません。
    例えば、複数のウィジェットが同じオブジェクトを共有する場合でも、イミュータブルであれば安心して使えます。

  3. デバッグが容易
    イミュータブルなオブジェクトは、状態が変更されないため、バグの原因を追跡するのが容易です。
    たとえば、ボタンの状態やラベルが途中で変わってしまうと予想外の挙動を引き起こす可能性がありますが、イミュータブルにしておけばその心配はありません。

  4. 副作用がない
    イミュータブルな設計は、副作用がなく、状態変更による予期せぬ影響を避けることができます。
    オブジェクトが変更されることなく、そのまま利用できるので、動作が直感的で安全です。

  5. 関数型プログラミングとの相性が良い
    関数型プログラミング(FP) は、データを変更するのではなく、新しいデータを生成することを重視するプログラミングの考え方です。
    イミュータブルな設計は、関数型プログラミングの考え方と親和性が高いです。関数型プログラミングでは、データを変更するのではなく、新しいデータを作成することが重視されます。
    イミュータブルなオブジェクトは、そのまま新しいオブジェクトを作るため、関数型の思想を取り入れることができます。

まとめ

イミュータブルな設計は、以下のようなメリットを提供します:

✅ 予測可能で簡単にデバッグできる
✅ スレッドセーフで複数のスレッドから安心してアクセスできる
✅ 副作用なしで状態変更による不具合を防ぐ
✅ 関数型プログラミングとの親和性が高く、コードがシンプルになる

SampleFilledButton のようなボタンコンポーネントにおいても、ボタンの状態を変更できないようにすることで、予期せぬ動作やエラーを減らし、より信頼性の高いUIコンポーネントを作ることができます。

コンストラクタの const

const コンストラクタとは?

const コンストラクタとは、オブジェクトのインスタンスをコンパイル時に定数として生成するためのコンストラクタです。
通常のコンストラクタは、オブジェクトが生成されるたびにそのインスタンスがメモリ上に確保されますが、const コンストラクタを使うと、インスタンスがコンパイル時に一度だけ作成され、再利用されるようになります。

const コンストラクタは、コンストラクタ内で使われる引数も全て定数である必要があり、finalconst 修飾子がつけられたフィールドだけを持つことができます。

例:const コンストラクタの使用例

class Person {
  final String name;
  final int age;

  // const コンストラクタ
  const Person(this.name, this.age);
}

void main() {
  // const コンストラクタで生成されたインスタンス
  const person1 = Person('Alice', 25);
  const person2 = Person('Bob', 30);
  
  print(identical(person1, person2));  // true になる
}

この例では、const Person コンストラクタを使用して person1person2 を生成しています。identical 関数を使って、これらのオブジェクトが同じインスタンスを指していることが確認できます。const コンストラクタを使うと、同じ引数のオブジェクトは再利用されるため、メモリの効率が良くなります。

const コンストラクタのメリット

  1. メモリ効率の向上
    const コンストラクタで生成されたオブジェクトはコンパイル時に一度だけ作成され、その後は再利用されます。
    そのため、同じ引数のオブジェクトが何度も生成されることがなく、メモリの使用を最小限に抑えることができます。

  2. パフォーマンスの向上
    const オブジェクトは作成時に一度だけ計算されるため、アプリケーションの実行中にインスタンス化が行われないため、パフォーマンスが向上します。特に、大量に同じデータを扱う場合には効果的です。

  3. より安全な設計
    const コンストラクタを使用すると、オブジェクトが不変(イミュータブル) であることが保証されます。
    これにより、予期しない状態変更や不具合を防ぎ、コードの安全性が向上します。

  4. ウィジェットの最適化
    Flutterでは、UIの描画に使用されるウィジェットは、できる限りconstとして定義することが推奨されています。
    これにより、ウィジェットツリー内で定数のウィジェットが再生成されることなく再利用されるため、UIの描画性能が向上します。

例:const コンストラクタのウィジェット利用例

class MyButton extends StatelessWidget {
  final String label;

  // const コンストラクタ
  const MyButton(this.label);

  
  Widget build(BuildContext context) {
    return ElevatedButton(onPressed: () {}, child: Text(label));
  }
}

void main() {
  // const コンストラクタを使ってウィジェットを作成
  const button1 = MyButton('Click Me');
  const button2 = MyButton('Click Me');
  
  print(identical(button1, button2));  // true になる
}

この例では、MyButton というウィジェットを const コンストラクタを使って生成しています。button1button2 は同じ定数を指しているため、再描画の際にウィジェットが再生成されることなく、効率的に描画されます。

まとめ

const コンストラクタを使うことで、以下のメリットを得ることができます:

✅ メモリ効率の向上:同じインスタンスを再利用するため、無駄なメモリの使用を防ぐ
✅ パフォーマンスの向上:インスタンスがコンパイル時に決定され、実行時の処理が軽くなる
✅ 設計の安全性:オブジェクトがイミュータブルであることが保証される
✅ UIの最適化:定数のウィジェットを再利用することで描画性能が向上

const コンストラクタは、特にFlutterのUIコンポーネントで効率的に利用することができ、アプリケーション全体のパフォーマンス向上に貢献します。

コンストラクタにkeyを持たせる

keyって何?

Key は、Flutterのウィジェットツリー内でウィジェットの一意性を識別するために使われる特別なオブジェクトです。
ウィジェットツリーにおいて、同じタイプのウィジェットが複数存在する場合に、それらを区別するために使用されます。Keyは、ウィジェットが再構築される際に、Flutterがどのウィジェットがどの位置に関連しているかを正しく追跡できるようにします。

例えば、リストやグリッドなどの動的にアイテムが変更される場合、Keyを使うことでFlutterはアイテムの状態を管理しやすくなり、不要な再描画を減らすことができます。

FlutterではKeyを使ったいくつかの種類が用意されていますが、特によく使われるのは以下です:

  • GlobalKey:アプリケーション内で一意のキーを持ち、ウィジェットツリー全体でアクセス可能
  • ValueKey:指定した値に基づいて一意に識別されるキー
  • ObjectKey:オブジェクトのインスタンスをキーとして使う
  • UniqueKey:毎回異なる一意のキーを生成する

例:Keyの使用例

class MyButton extends StatelessWidget {
  final String label;

  // コンストラクタにKeyを追加
  const MyButton({Key? key, required this.label}) : super(key: key);

  
  Widget build(BuildContext context) {
    return ElevatedButton(onPressed: () {}, child: Text(label));
  }
}

void main() {
  // `Key`を使って異なるボタンを識別
  const button1 = MyButton(key: ValueKey('button1'), label: 'Click Me');
  const button2 = MyButton(key: ValueKey('button2'), label: 'Submit');
}

この例では、MyButtonKey を追加し、ボタンを一意に識別しています。これにより、ウィジェットが再描画される際に、どのボタンがどの状態に関連しているかをFlutterが把握できるようになります。

今回コンストラクタにkeyを持たせた理由

今回、SampleFilledButton のコンストラクタに Key を持たせた理由は、以下のようなケースを考慮したからです:

  1. ウィジェットの一意性の確保

ボタンを複数配置する場合、同じ種類のボタンを複数使うことがあります。Keyを使うことで、Flutterがそれぞれのボタンを一意に識別し、正しいボタンに対して状態の更新や再描画を行うことができます。

  1. パフォーマンスの最適化

Key を使うことで、Flutterはウィジェットツリーの再構築時にウィジェットの状態を保持することができます。これにより、ボタンが押された時や状態が変化した時に、不要な再描画を防ぐことができ、アプリのパフォーマンスが向上します。

  1. ウィジェットの再利用性向上

特にリストやグリッドのような動的なUIでボタンを使う場合、Key を設定することでボタンが状態を持ったまま再利用されやすくなります。たとえば、ボタンがローディング中かどうかをトラッキングする場合、Key を使うことで状態を正確に管理することができます。

まとめ

Key はウィジェットツリー内でウィジェットを一意に識別し、状態の管理を効率的に行うために使われます。特に、動的に変化するUIで再描画の最適化や状態のトラッキングに役立ちます。今回の SampleFilledButton のようなボタンコンポーネントにも、key を持たせることで再描画の最適化とパフォーマンス向上が期待できます。

さいごに

今回は、Flutterを使って、さまざまなバリエーションの塗りつぶしボタンを実装し、その過程でDartのクラス設計について学びました。

特に、ファクトリコンストラクタを使用することで、ボタンのバリエーションをシンプルかつ効率的に管理できることがわかりました。また、イミュータブルな設計によって、予測可能で安全な状態を保ちながら、デバッグが容易になることも確認できました。

もし、この記事での実装に関して疑問や改善点があれば、ぜひコメントで教えてください!

Discussion

ログインするとコメントできます