【Flutter】iOSアイコン風Squircleで柔らかなUIを実装

  • 2024年10月31日
  • 2024年10月31日
  • 小物

対象者

  • FlutterでのUIデザインを向上させたいエンジニア
  • Squircleの概念と実装方法を知りたいデザイナー・開発者
  • アプリのデザインをよりモダンで柔らかい印象にしたい方

はじめに

皆さんは「Squircle(スクワークル)」という形をご存知でしょうか?これは四角形と円の中間に位置する形で、最近のモダンなUIデザインでよく見かけるようになりました。特にFlutterでアプリ開発を行う際、デフォルトの角丸では表現できない柔らかさや滑らかさを実現できます。本記事では、Squircleの概念とFlutterでの実装方法について詳しく解説します。

Squircleとはなにか

Squircleは「Square(四角)」と「Circle(円)」を組み合わせた造語で、四角でも円でもない中間的な形状を指します。この形は直線と曲線が滑らかに融合しており、柔らかく自然な印象を与えます。AppleのiOSアプリアイコンやInstagramのロゴなど、さまざまなデザインで採用されています。

アプリの画面

左:Squircle (四角の隅の、角と円が一致していない)
右:角丸 (四角の隅の、角と円が一致している)

なぜSquircleを使うのか

  • 視覚的な柔らかさ:尖った角や単純な角丸よりも柔らかい印象を与える
  • モダンなデザイン:最新のデザイントレンドに合致
  • ユーザーエクスペリエンスの向上:視覚的な心地よさがユーザーの満足度を高める

SquircleのFlutterでの実装方法

Flutterでは、デフォルトの角丸だけではSquircle特有の滑らかさを再現できません。しかし、いくつかの方法を組み合わせることで、Squircleを実装することが可能です。

ContinuousRectangleBorderを使用する

ContinuousRectangleBorderは、FlutterでSquircleを実現するための標準的なクラスです。しかし、そのまま使用すると期待する形にならない場合があります。

Container(
  height: 64,
  decoration: ShapeDecoration(
        color: Colors.blue,
        shape: ContinuousRectangleBorder(
      		borderRadius: BorderRadius.circular(16),
        ),
  ),
  child: const Center(
      child: Text('ContinuousRectangleBorder circular:16')),
),

半径に係数を掛ける方法

ContinuousRectangleBorderの半径に約2.3529を掛けると、より適切なSquircle形状になります。

Container(
  height: 64,
  decoration: ShapeDecoration(
        color: Colors.blue,
        shape: ContinuousRectangleBorder(
            borderRadius: BorderRadius.circular(16 * 2.3529),
        ),
  ),
  child: const Center(
      child:
          Text('ContinuousRectangleBorder circular:16 * 2.3529')),
),

ClipPathとCustomClipperを使用する

ClipPathShapeBorderClipperを使っても作成することができます。

ClipPath(
  clipper: const ShapeBorderClipper(
        shape: ContinuousRectangleBorder(
            borderRadius: BorderRadius.all(Radius.circular(16 * 2.3529)),
        ),
  ),
  child: Container(
        height: 64,
        decoration: const BoxDecoration(color: Colors.blue),
        child: const Center(child: Text('ClipPath circular:16 * 2.3529')),
  ),
),

カスタムパッケージを使用する

figma_squircleパッケージ

Figmaのコーナースムージングと同様のSquircleを実現できるそうです。ただ3年前の更新が最後で、使用するのには一抹の不安があります。

ウィジェットごとの実装パターン

なんとなく使用方法は分かりましたが、実装がWidgetごとに微妙に異なるので、ひとまずまとめてみました。

  • ボタン系ウィジェット(FilledButtonなど)
    • 該当のStyleShapeプロパティにContinuousRectangleBorderを使用
  • Container
    • decorationShapeDecoration(shape:ContinuousRectangleBorder)を組み合わせる
  • Card, ListTile
    • shapeプロパティにContinuousRectangleBorderを使用し、必要に応じて係数を調整

Q&A

Q1: なぜ角丸ではなくSquircleを使うべきなのでしょうか?

Squircleは角丸よりも滑らかな曲線を持ち、より自然でモダンな印象を与えます。ユーザーエクスペリエンスを向上させ、デザインに一貫性と洗練さをもたらします。

Q2:ContinuousRectangleBorderの係数2.3529はどこから来たのですか?

この係数は、ContinuousRectangleBorderRoundedRectangleBorderの見た目を一致させるための経験的な値です。正確な数学的根拠はありませんが、実際のデザインで視覚的に一致させるために使用されています。

Q3:パフォーマンスへの影響はありますか?

カスタムパッケージやClipPathを多用すると、描画にコストがかかる場合があります。パフォーマンスが重要な場面では、使用するウィジェットや実装方法を慎重に選択してください。

Q4:ContinuousRectangleBorder とは何ですか?

ContinuousRectangleBorder は、Flutterにおけるカスタムシェイプ(形状)を定義するためのクラスの一つです。このクラスは、ウィジェットの境界線に対して連続的な曲線を適用し、滑らかで自然な形状を実現するために使用されます。特に、角が滑らかに曲がる「スクワークル(Squircle)」のような形状を作成する際に有用です。

主な特徴

  • 滑らかな曲線: ContinuousRectangleBorder は、角が単純な円弧ではなく、より滑らかで連続的な曲線を描くため、視覚的に柔らかく自然な印象を与えます。
  • カスタマイズ可能なボーダー半径: borderRadius プロパティを使用して、角の丸み具合を細かく調整できます。
  • シェイプの統一性: ボタンやカードなど、複数のウィジェットで一貫した形状を保つことができます。

参考

Flutter Squricle Study (v1.3.0)
FlutterでのSquircleについて、なんか詳しそう。

まとめ

SquircleをFlutterで実装することで、アプリのデザインに柔らかさとモダンさを加えることができます。デフォルトの角丸では表現できない滑らかな曲線を持つSquircleは、ユーザーエクスペリエンスの向上にも寄与します。さまざまな実装方法がありますので、プロジェクトの要件やパフォーマンスを考慮して最適な方法を選びましょう。

皆さんもぜひFlutterでSquircleを取り入れて、デザインの幅を広げてみてください!

ソース(main.dartにコピペして動作確認用)

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Container(
              height: 64,
              decoration: BoxDecoration(
                color: Colors.blue,
                borderRadius: BorderRadius.circular(16),
              ),
              child: const Center(child: Text('ただの角丸 circular:16')),
            ),
            const SizedBox(height: 8),
            Container(
              height: 64,
              decoration: ShapeDecoration(
                color: Colors.blue,
                shape: ContinuousRectangleBorder(
                  borderRadius: BorderRadius.circular(16),
                ),
              ),
              child: const Center(
                  child: Text('ContinuousRectangleBorder circular:16')),
            ),
            const SizedBox(height: 8),
            Container(
              height: 64,
              decoration: ShapeDecoration(
                color: Colors.blue,
                shape: ContinuousRectangleBorder(
                  borderRadius: BorderRadius.circular(16 * 2.3529),
                ),
              ),
              child: const Center(
                  child:
                      Text('ContinuousRectangleBorder circular:16 * 2.3529')),
            ),
            const SizedBox(height: 8),
            ClipPath(
              clipper: const ShapeBorderClipper(
                shape: ContinuousRectangleBorder(
                  borderRadius: BorderRadius.all(Radius.circular(16 * 2.3529)),
                ),
              ),
              child: Container(
                height: 64,
                decoration: const BoxDecoration(color: Colors.blue),
                child:
                    const Center(child: Text('ClipPath circular:16 * 2.3529')),
              ),
            ),
            const SizedBox(height: 32),
            FilledButton(
              style: FilledButton.styleFrom(
                shape: ContinuousRectangleBorder(
                  borderRadius: BorderRadius.circular(10 * 2.3529),
                ),
              ),
              onPressed: () {},
              child: const Text('Squircle: ボタン'),
            ),
            const SizedBox(height: 32),
            ListTile(
              tileColor: Colors.blue,
              shape: RoundedRectangleBorder(
                borderRadius: BorderRadius.circular(8),
              ),
              title: const Center(child: Text('角丸 ListTile')),
            ),
            const SizedBox(height: 8),
            ListTile(
              tileColor: Colors.blue,
              shape: ContinuousRectangleBorder(
                borderRadius: BorderRadius.circular(10 * 2.3529),
              ),
              title: const Center(child: Text('Squircle: ListTile')),
            ),
            Card(
              color: Colors.blue,
              shape: ContinuousRectangleBorder(
                borderRadius: BorderRadius.circular(10 * 2.3529),
              ),
              child: const SizedBox(
                height: 64,
                child: Center(child: Text('Squircle: Card')),
              ),
            ),
          ],
        ),
      ),
    );
  }
}