Open6

Flutter でスタイリッシュなボタンが作れる様になりたい

アジャパーアジャパー

Flutter 1ヶ月弱の僕の成果(ださい)

ScreenShot

Code

Center(
  child: GestureDetector(
    onTap: () {
      print("Button Clicked");
    },
    child: Container(
      width: 120.0,
      height: 120.0,
      decoration: BoxDecoration(
        color:  const Color.fromRGBO(60, 60, 60, 1.0),
        gradient: const LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[
            Color.fromRGBO(70, 70, 70, 1.0),
            Color.fromRGBO(70, 70, 70, 0.3),
          ]
        ),
        borderRadius: BorderRadius.circular(60),
        boxShadow: const [
          BoxShadow(
            color: Color.fromRGBO(30, 30, 30, 0.2),
            spreadRadius: 4,
            blurRadius: 8,
            offset: Offset(4, 4)
          ),
          BoxShadow(
            color: Color.fromRGBO(180, 180, 180, 0.1),
            spreadRadius: 2,
            blurRadius: 8,
            offset: Offset(-2, -2)
          ),
        ]
      ),
      child: Center(
        child: Container(
          width: 118,
          height: 118,
          decoration: BoxDecoration(
            color:  const Color.fromRGBO(60, 60, 60, 1.0),
            gradient: const LinearGradient(
              begin: Alignment.topLeft,
              end: Alignment.bottomRight,
              colors: <Color>[
                Color.fromRGBO(50, 50, 50, 1.0),
                Color.fromRGBO(70, 70, 70, 1.0),
              ]
            ),
            borderRadius: BorderRadius.circular(60),
            boxShadow: const [
              BoxShadow(
                color: Color.fromRGBO(30, 30, 30, 0.2),
                spreadRadius: 1,
                blurRadius: 4,
                offset: Offset(2, 2)
              ),
            ]
          ),
          child: Center(
            child: Container(
              width: 100,
              height: 100,
              decoration: BoxDecoration(
                color:  const Color.fromRGBO(60, 60, 60, 1.0),
                gradient: const LinearGradient(
                  begin: Alignment.topLeft,
                  end: Alignment.bottomRight,
                  colors: <Color>[
                    Color.fromRGBO(80, 80, 80, 1.0),
                    Color.fromRGBO(40, 40, 40, 1.0),
                  ]
                ),
                borderRadius: BorderRadius.circular(60),
                border: Border.all(
                  color: const Color.fromRGBO(0, 220, 240, 0.7),
                  width: 1,
                ),
                boxShadow: const [
                  BoxShadow(
                    color: Color.fromRGBO(30, 30, 30, 0.2),
                    spreadRadius: 1,
                    blurRadius: 4,
                    offset: Offset(2, 2)
                  ),
                ]
              ),
              child:  Center(
                child: Text(
                  "283",
                  style: GoogleFonts.getFont(
                    "Bubbler One",
                    textStyle: const TextStyle(
                      fontSize: 36,
                      fontWeight: FontWeight.w100,
                      color: Color.fromRGBO(190, 190, 190, 0.8),
                    ),
                  )
                ),
              ),
            )
          )
        )
      ),
    ),
  ),
),

Container だの BoxShadow だのでモリモリしてるだけのボタンで、animation したりしない。もっといい感じのボタンが作れる様になりたいってのと、canvas, CustomPaint とかもガンガン使っていきたいところ。

アジャパーアジャパー

てか、おんなじ様なこと繰り返してるから内輪のWidget切り出せる様にしておこ。

アジャパーアジャパー

#1 CustomPaint Class 翻訳していく

といった訳で(どーゆー訳で?)でスタイリッシュな UI の作成に CustomPaint 周りの知識も必要そうだってことがわかったので、しばらくの間 CustomPaint を科学していこうと思う。

Inheritance

Object > DiagnosticableTree > Widget > RenderObjectWidget > SingleChildRenderObjectWidget > CustomPaint

Constructor

const CustomPaint({
  Key? key,
  CustomPainter? painter,
  CustomPainter? foregroundPainter,
  Size size = Size.zero,
  bool isComplex = false,
  bool willChange = false,
  Widget? child
})

Properties

Name Type Description Link other
child Widget? ツリー内のこのウィジェットの下のウィジェット child final, inherited
painter CustomPainter? グラフィックを子ウィジェットの背面に描画させる時に使用する painter final
foregroundPainter CustomPainter? グラフィックを子ウィジェットの前面に描画させる時に使用する foregroundPainter final
hashCode int このオブジェクトのハッシュコード hashCode @nonVirtual, read-only, inherited
key Key? ツリー内のあるウィジェットが別のウィジェットを置き換える方法を制御する Key final, inherited
runtimeType Type オブジェクトのランタイムタイプの表現 runtimeType read-only, inherited
size Size child Widgetがない場合、レイアウトの制約を考慮して、このCustomPaintが目指すべきサイズです。 size final
willChange bool 描画したグラフィックが次のフレームで変更される可能性が高いことをラスターキャッシュに伝えるべきかどうか。 willChange final

Methods

*Inherited methods are omitted

Name Return Description Link other
createRenderObject(BuildContext context) RenderCustomPaint このRenderObjectWidgetが表すRenderObjectクラスのインスタンスを、このRenderObjectWidgetによって記述された構成を使用して作成します。 createRenderObject override
didUnmountRenderObject(covariant RenderCustomPaint renderObject) void このウィジェットに以前関連付けられていたレンダー・オブジェクトがツリーから削除されました。与えられたRenderObjectは、このオブジェクトのcreateRenderObjectで返されたものと同じタイプになります。 didUnmountRenderObject override
updateRenderObject(BuildContext context, covariant RenderCustomPaint renderObject) void このRenderObjectWidgetによって記述されたコンフィギュレーションを、このオブジェクトのcreateRenderObjectによって返されたものと同じタイプの、与えられたRenderObjectにコピーします。 updateRenderObject override

概要

paintフェーズで描画するキャンバスを提供するウィジェットです。

CustomPaintは、描画を要求されると、以下の順序で動作します。

  1. painter に現在の canvas に描画するよう要求
  2. child: Widgetを描画 *Optional
  3. foregroundPainterに描画するよう要求 *Optional

painter, foregroundPainter は 子のウィジェットに対する描画順の違いがある。foregroundPainter を指定した場合、子のウィジェットよりも前面にグラフィックを描画し painter を使用した場合は、子のウィジェットより背面にグラフィックを描画する。また、これらは両方同時に使うこともできる。レイヤー構造を想像すればわかり易い。


座標と描画範囲

canvas の座標系は、CustomPaintオブジェクトの座標系と一致します。

painter は原点から始まり、与えられたサイズの領域を包含する矩形内に描画することが期待される。(painter がこれらの境界の外側を描画する場合,描画コマンドをラスタライズするために割り当てられるメモリが不十分で,結果として動作が不定になる可能性があります)。

その範囲内で描画するためには、このCustomPaintをClipRectウィジェットで包むことを検討してください。


CustomPaint の例

painter はCustomPainterのサブクラスで実装されています。

Center(
  child: CustomPaint(
    size: Size(300, 300),
    painter: _SimplePainter()
  ),
)

注意事項

CustomPaintは描画中にその painter を呼び出すため、コールバック中にsetStateやmarkNeedsLayoutを呼び出すことはできません(このフレームのレイアウトはすでに起こっています)。

CustomPaintは通常、自分自身のサイズをその子(child: Widget?)に合わせます。子painterがいない場合は、Size.zeroをデフォルトとするサイズに自分自身のサイズを合わせようとします。

isComplex と willChange は compositor のラスターキャッシュへのヒントであり、NULLであってはならない。


以下の例では、CustomPainterで示したSampleCustomPainterを使い、CustomPaint Widget で、テキストの背景を表示する方法を示します。

CustomPaint(
  painter: Sky(),
  child: const Center(
    child: Text(
      'Once upon a time...',
      style: TextStyle(
        fontSize: 40.0,
        fontWeight: FontWeight.w900,
        color: Color(0xFFFFFFFF),
      ),
    ),
  ),
)

ここまでのまとめ

ここまでの情報では、具体的に図形を描画するための方法はまとまってません。CustomPaint への painter の指示で指定した CustomPainter クラスが描画内容の本体になります。

CustomPainter へは canvas が渡されており、canvas で実際に drawing の指示をします。

CustomPainter や Canvas に関するより詳細な情報は以下を参照。

  • CustomPainter: カスタムペインターを作成する際に拡張するクラス。
  • Canvas: カスタムペインターがペイントするために使用するクラス。
アジャパーアジャパー

#2 CustomPainter Class 翻訳していく

概要

CustomPainter は CustomPaint(ウィジェットライブラリ)とRenderCustomPaint(レンダリングライブラリ)で使用されるインターフェースです。

使い方(ざっくり)

CustomPainter を実装するには、このインターフェイスをサブクラス化(extends)するか実装(implements)して、カスタムペイントデリゲートを定義します。

CustomPaintのサブクラスは、paint メソッドと shouldRepaint メソッドを実装する必要があり、オプションでhitTestおよびshouldRebuildSemanticsメソッド、semanticsBuilderゲッターも実装できます。

paint メソッド概要

paintメソッドは、カスタムオブジェクトの再描画が必要になるたびに呼び出されます。

shouldRepaint メソッド概要

shouldRepaintメソッドは、クラスの新しいインスタンスが提供されたときに呼び出され、新しいインスタンスが実際に異なる情報を表しているかどうかをチェックします。


再描画

リペイントを行う最も効率的な方法は、次のいずれかです。

  • このクラスを拡張し、CustomPainterのコンストラクタにrepaint引数を与えると、オブジェクトはリスナーに再描画のタイミングを通知します。
  • Listenableを(ChangeNotifierなどで)拡張し、CustomPainterを実装することで、オブジェクト自身が直接通知を行うようになります。

いずれの場合も、CustomPaintウィジェットまたはRenderCustomPaintレンダーオブジェクトはListenableをリッスンし、アニメーションがティックするたびに再ペイントし、パイプラインのビルドとレイアウトの両方のフェーズを回避します。


ヒットテスト

hitTestメソッドは、ユーザーが基礎となるレンダリングオブジェクトと対話したときに呼び出され、ユーザーがオブジェクトに当たったか、外れたかを判定します。


状態や属性の構築、更新

semanticsBuilderは、カスタムオブジェクトがそのセマンティクス情報を再構築する必要があるときに、いつでも呼び出されます。

shouldRebuildSemanticsメソッドは、クラスの新しいインスタンスが提供されたときに呼び出され、セマンティクス・ツリーに影響を与える異なる情報が新しいインスタンスに含まれているかどうかをチェックします。

Sample

class Sky extends CustomPainter {
  
  void paint(Canvas canvas, Size size) {
    final Rect rect = Offset.zero & size;
    const RadialGradient gradient = RadialGradient(
      center: Alignment(0.7, -0.6),
      radius: 0.2,
      colors: <Color>[Color(0xFFFFFF00), Color(0xFF0099FF)],
      stops: <double>[0.4, 1.0],
    );
    canvas.drawRect(
      rect,
      Paint()..shader = gradient.createShader(rect),
    );
  }

  
  SemanticsBuilderCallback get semanticsBuilder {
    return (Size size) {
    // 太陽の絵が描かれた四角形に「太陽」というラベルを付けて、注釈を付ける。
    // 端末で音声合成機能が有効になっている場合、ユーザーはこの絵の中の太陽の位置をタッチして確認することができます。太陽をタッチしてください。
      Rect rect = Offset.zero & size;
      final double width = size.shortestSide * 0.4;
      rect = const Alignment(0.8, -0.9).inscribe(Size(width, width), rect);
      return <CustomPainterSemantics>[
        CustomPainterSemantics(
          rect: rect,
          properties: const SemanticsProperties(
            label: 'Sun',
            textDirection: TextDirection.ltr,
          ),
        ),
      ];
    };
  }

  // このスカイペインターにはフィールドがないので、常に同じものを描き、セマンティクス情報も同じです。
  // そのため、ここでは false を返します。もしフィールドがあれば(コンストラクタで設定されている)、oldDelegateの同じフィールドと異なるものがあればtrueを返すことになります。
  
  bool shouldRepaint(Sky oldDelegate) => false;
  
  bool shouldRebuildSemantics(Sky oldDelegate) => false;
}

iPhone 13、Android Pixel 5 で音声関係の設定を入れてみたけど設定の入れ方が悪かったのか、太陽をタッチしても音声が流れなかった。あんまり僕には重要じゃなさそうだったので今のところはシカトしとく。

ってことでその部分をシカトしたコードはこちら。

import 'package:flutter/material.dart';

class Sun extends StatelessWidget {

  const Sun({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Sky')
      ),
      body: Center(
        child: CustomPaint(
          painter: Sky(),
          child: const Center(
            child: Text(
              'Once upon a time...',
              style: TextStyle(
                fontSize: 40.0,
                fontWeight: FontWeight.w900,
                color: Color(0xFFFFFFFF),
              ),
            ),
          ),
        )
      )
    );
  }
}

class Sky extends CustomPainter {
  
  void paint(Canvas canvas, Size size) {
    final Rect rect = Offset.zero & size;
    const RadialGradient gradient = RadialGradient(
      center: Alignment(0.7, -0.6),
      radius: 0.2,
      colors: <Color>[Color(0xFFFFFF00), Color(0xFF0099FF)],
      stops: <double>[0.4, 1.0],
    );
    canvas.drawRect(
      rect,
      Paint()..shader = gradient.createShader(rect),
    );
  }
  
  bool shouldRepaint(Sky oldDelegate) => false;
}


paint

void paint(
  Canvas canvas,
  Size size
)

概要

オブジェクトの描画が必要なときに呼び出されます。与えられたCanvasは、ボックスの左上に原点があるように座標空間が構成されています。ボックスの面積は size 引数のサイズです。


描画領域について

描画操作は、与えられた領域内に留める必要があります。領域外でのグラフィック操作は、静かに無視されたり、クリップされたり、クリップされなかったりします。

ある操作が境界の内側にあることを保証するのが難しい場合があります(例えば、ユーザーの入力によってサイズが決まる矩形を描く場合など)。

そのような場合は、ペイントの最初にCanvas.clipRectを呼び出すことで、その後のすべての描画がクリップされた領域内でのみ行われることが保証されます。


Canvas.save、Canvas.saveLayer、Canvas.restore

実装の際には、Canvas.save/Canvas.saveLayerとCanvas.restoreの呼び出しを正しくペアリングすることに注意する必要があります。そうしないと、このcanvasへの後続のすべての描画が影響を受け、愉快だが混乱を招く結果になる可能性があります。


テキストの描画

Canvasにテキストを描くには、TextPainterを使います。


画像の描画

キャンバスに画像を描くには

  1. 例えば、AssetImageまたはNetworkImageオブジェクトのImageProvider.resolveを呼び出して、ImageStreamを取得します。

  2. ImageStreamの基礎となるImageInfoオブジェクトが変更されるたびに (ImageStream.addListenerを参照)、カスタムペイントデリゲートの新しいインスタンスを作成し、新しいImageInfoオブジェクトを与えます。

  3. デリゲートのペイント メソッドで Canvas.drawImage、Canvas.drawImageRect、Canvas.drawImageNine の各メソッドを呼び出して ImageInfo.image オブジェクトをペイントし、ImageInfo.scale 値を適用して正しいレンダリング サイズにします。


shouldRepaint

bool shouldRepaint(
  covariant CustomPainter oldDelegate
)

概要

RenderCustomPaintオブジェクトに custom painter delegate class の新しいインスタンスが提供されるたびに、または custom painter delegate class の新しいインスタンスで新しい CustomPaint オブジェクトが作成されるたびに呼び出されます。(後者は前者の観点から実装されているので、同じことになります)

新しいインスタンスが古いインスタンスと異なる情報を表している場合は、このメソッドはtrueを返し、そうでない場合はfalseを返す必要があります

このメソッドがfalseを返した場合、paintの呼び出しが最適化される可能性があります。

shouldRepaintがfalseを返した場合でも、paintメソッドが呼び出される可能性があります(例:祖先や子孫が再描画される必要がある場合)。また、shouldRepaintが全く呼ばれずにpaintメソッドが呼ばれることもあります(例:ボックスのサイズが変更された場合など)。

カスタムデリゲートが特に高価なペイント関数を持っていて、リペイントをできるだけ避けたい場合は、RepaintBoundaryまたはRenderRepaintBoundary(またはRenderObject.isRepaintBoundaryがtrueに設定された他のレンダーオブジェクト)が役に立つかもしれません。

oldDelegateの引数は決してnullにはなりません。


CustomPainter Class まとめ

CustomPainter クラスを継承したクラスでは、 paintメソッドと、shouldRepaintメソッドの override を行う必要があり、実際の描画指示は paint 内で定義していく。

shouldRepaint は、グラフィックをアニメーションで動かしたり、何かしらの操作で再描画させる場合は true を返す様にすると、再度 paint が実行されて描画内容が更新される。

アジャパーアジャパー

#3 Canvas Class 翻訳していく

概要

Canvas Class はグラフィカルな操作を記録するためのインターフェースです。

Canvas は Picture、Scene でも使用される

CanvasオブジェクトはPictureオブジェクトの作成に使用され、SceneBuilderでSceneを構築する際に使用されます。しかし、通常の使用では、これらはすべてフレームワークで処理されます。

返還行列とクリップ領域

Canvasには、すべての操作に適用される現在の変換行列があります。初期状態では、変換マトリクスは恒等変換になっています。変換行列は、translate、scale、rotate、skew、transformの各メソッドで変更できます。

また、Canvas には、すべての操作に適用される現在のクリップ領域があります。初期状態では、クリップ領域は無限大です。クリップ領域は、clipRect、clipRRect、clipPathの各メソッドで変更できます。

現在のトランスフォームとクリップは、save、saveLayer、restoreの各メソッドで管理されるスタックを使って保存、復元することができます。


Methods

Canvas には多くのメソッドが用意されていて大きく分けると3系統のメソッド群になる(ってことにした)

Clip 系

Name Description
clipPath(Path path, {bool doAntiAlias = true}) クリップ領域を、カレントクリップと与えられたPathの交点に縮小します。
clipRect(Rect rect, {ClipOp clipOp = ClipOp.intersect, bool doAntiAlias = true}) クリップ領域を、現在のクリップと与えられた矩形との交点に縮小します。
clipRRect(RRect rrect, {bool doAntiAlias = true}) クリップ領域を、現在のクリップと指定された丸みを帯びた矩形との交点に縮小します。

Draw 系

Name Description
drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint) 与えられた矩形内に収まるようにスケールされた円弧を描きます。
drawAtlas(Image atlas, List<RSTransform> transforms, List<Rect> rects, List<Color>? colors, BlendMode? blendMode, Rect? cullRect, Paint paint) 画像の多くの部分(アトラス)をキャンバスに描きます。
drawCircle(Offset c, double radius, Paint paint) 第1引数で指定された点を中心とし、第2引数で指定された半径を持つ円を、第3引数で指定されたPaintで描画します。円が塗りつぶされるかストロークされるか(またはその両方)は、Paint.styleで制御されます。
drawColor(Color color, BlendMode blendMode) 与えられたBlendModeを適用して、与えられたColorをソース、背景をデスティネーションとして、キャンバスにペイントします。
drawDRRect(RRect outer, RRect inner, Paint paint) 与えられたPaintで、2つの丸みを帯びた長方形の差分からなる形状を描画します。この図形が塗りつぶされるかストロークされるか(またはその両方)は、Paint.styleで制御されます。
drawImage(Image image, Offset offset, Paint paint) 与えられた画像を、与えられたOffsetに左上隅を合わせてキャンバスに描画します。画像は与えられたPaintを使ってキャンバスに合成されます。
drawImageNine(Image image, Rect center, Rect dst, Paint paint) 指定されたPaintを使って、指定されたImageをキャンバスに描画します。
drawImageRect(Image image, Rect src, Rect dst, Paint paint) src 引数で指定された画像のサブセットを、dst 引数で指定された軸に沿った長方形のキャンバスに描画します。
drawLine(Offset p1, Offset p2, Paint paint) 指定された点の間に、指定されたペイントを使って線を描きます。この呼び出しでは、Paint.styleの値は無視され、線は描線されます。
drawOval(Rect rect, Paint paint) 与えられた軸合わせの長方形を埋める軸合わせの楕円を、与えられたPaintで描画します。楕円が塗りつぶされるかストロークされるか(またはその両方)は、Paint.styleで制御されます。
drawPaint(Paint paint) 与えられたPaintでキャンバスを埋めます。
drawParagraph(Paragraph paragraph, Offset offset) 指定されたParagraphのテキストを、指定されたOffsetでこのキャンバスに描画します。
drawPath(Path path, Paint paint) 与えられたPaintで与えられたPathを描画します。
drawPicture(Picture picture) 与えられた絵をキャンバスに描きます。絵を作成するには、「PictureRecorder」を参照してください。
drawPoints(PointMode pointMode, List<Offset> points, Paint paint) 与えられた PointMode に従って、一連の点を描画します。
drawRawAtlas(Image atlas, Float32List rstTransforms, Float32List rects, Int32List? colors, BlendMode? blendMode, Rect? cullRect, Paint paint) 画像の多くの部分(アトラス)をキャンバスに描きます。
drawRawPoints(PointMode pointMode, Float32List points, Paint paint) 与えられた PointMode に従って、一連の点を描画します。
drawRect(Rect rect, Paint paint) 指定されたPaintで矩形を描画します。矩形が塗りつぶされるかストロークされるか(またはその両方)は、Paint.styleによって制御されます。
drawRRect(RRect rrect, Paint paint) 指定されたPaintで丸みを帯びた矩形を描画します。矩形が塗りつぶされるかストロークされるか(またはその両方)は、Paint.styleによって制御されます。
drawShadow(Path path, Color color, double elevation, bool transparentOccluder) 与えられたマテリアルの高さを表すPathの影を描画します。
drawVertices(Vertices vertices, BlendMode blendMode, Paint paint) 頂点のセットをキャンバスに描画します。

その他

Name Retern Description
getSaveCount() int 初期状態を含む、保存スタック上のアイテム数を返します。つまり、キャンバスがきれいな状態であれば 1 を返し、save および saveLayer を呼び出すたびに増加し、restore を呼び出すたびに減少します
restore() void ポップするものがあれば、現在の保存スタックをポップします。それ以外は何もしません。
rotate(double radians) void 現在のトランスフォームに回転を追加します。引数は時計回りのラジアン単位です。
save() void 現在のトランスフォームとクリップのコピーを保存スタックに保存します。
saveLayer(Rect? bounds, Paint paint) void 現在のトランスフォームとクリップのコピーを保存スタックに保存して、後続の呼び出しでその一部となる新しいグループを作成します。このグループは、後に保存スタックがポップされたときに、レイヤーとしてフラット化され、指定されたペイントのPaint.colorFilterとPaint.blendModeが適用されます。
scale(double sx, [double? sy]) void 現在のトランスフォームに軸に沿ったスケールを追加し、水平方向に第1引数、垂直方向に第2引数のスケールを行います。
skew(double sx, double sy) void 第1引数には原点を中心に時計回りの上昇単位での水平方向のスキュー、第2引数には原点を中心に時計回りの上昇単位での垂直方向のスキューを指定します。
transform(Float64List matrix4) void 現在のトランスフォームに、列長順の値のリストとして指定された4⨉4変換行列を乗算します。
translate(double dx, double dy) void 現在のトランスフォームに平行移動を追加し、座標空間を第1引数で水平方向に、第2引数で垂直方向にシフトします。