Open8

CSSのbackground: linear-gradient(45deg, #FF0000, #0000FF)のようなグラデーションをFlutterのテキストにつけたい

matsuchiyomatsuchiyo

↓ これをFlutterで実装したい。

↓ 上からコピーしてきたstyle。

background: linear-gradient(104deg, #F00 17.74%, #F0F 49.9%, #00F 80.65%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
matsuchiyomatsuchiyo

上記のCSSの104degのように角度をしていするのではなく、左上のスミから右下のスミにグラデーションをつけるのであれば、次のようにできる。これを角度指定でやりたい。

import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

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

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

  final String title;

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

class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Container(
          alignment: Alignment.topLeft,
          padding: const EdgeInsets.all(16),
          child: ShaderMask(
            blendMode: BlendMode.srcIn,
            shaderCallback: (bounds) => const LinearGradient(
              begin: Alignment(-1, -1),
              end: Alignment(1, 1),
              colors: [
                Color(0xFFFF0000),
                Color(0xFFFF00FF),
                Color(0xFF0000FF),
              ],
              stops: [
                0.0,
                0.5,
                1.0,
              ],
            ).createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)),
            child: Text(
              "ABC",
              style: TextStyle(
                fontSize: 48,
                fontWeight: FontWeight.w700,
              ),
            ),
          ),
        ),
      ),
    );
  }
}
matsuchiyomatsuchiyo

ここで質問されていた: How to create a linear gradient with 45 degrees in Flutter?

  • LinearGradientのtransformを使う方法が使えそう: https://stackoverflow.com/a/64732687/8834586
    LinearGradient(
      begin: Alignment(-1.0, 0.0),
      end: Alignment(1.0, 0.0),
      colors: [],
      stops: [],
      transform: GradientRotation(math.pi / 4),
    ),
    
    • 気になる点
      • transform: GradientRotation(math.pi / 4),とあるが、どこを中心にrotationされるのか?
      • begin, endの設定を見ると、左上スミから右上スミへのグラデーションとなっている。pi / 4ラジアンrotationしたら、長さの調節も必要ではないか。
    • 上の「気になる点」を確認してみたところ、領域の中心でrotationし、長さの調節は必要なさそう必要そう(下のコメントへ)。そのためこの方法を使えそう。
      • スクリーンショット(上がtransformなし。下があり。)

      • コード (transform無しの方)
               ShaderMask(
                 blendMode: BlendMode.srcIn,
                 shaderCallback: (bounds) => const LinearGradient(
                   begin: Alignment(-1, 0),
                   end: Alignment(1, 0),
                   colors: [
                     Color(0xFFFF0000),
                     Color(0xFFFF0000),
                     Color(0xFFFFFF00),
                     Color(0xFFFFFF00),
                     Color(0xFF00FF00),
                     Color(0xFF00FF00),
                     Color(0xFF0000FF),
                     Color(0xFF0000FF),
                   ],
                   stops: [
                     0.0,
                     0.249,
                     0.251,
                     0.499,
                     0.501,
                     0.749,
                     0.751,
                     1.0,
                   ],
                 ).createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)),
                 child: Text(
                   "ABC",
                   style: TextStyle(
                     fontSize: 48,
                     fontWeight: FontWeight.w700,
                   ),
                 ),
               ),
        
      • コード (transform有りの方)
        • transform: GradientRotation(pi / 4),をLinearGradientに追加。
matsuchiyomatsuchiyo

上のtransform: GradientRotation(pi / 4)を使って実装してみた。
しかし、以下の通り、見た目がすこしデザイン(Figma)やhtmlと異なる。

Flutter Figma html

↓Flutterのコード

              ShaderMask(
                blendMode: BlendMode.srcIn,
                shaderCallback: (bounds) => const LinearGradient(
                  // transformを適用する前の状態。これは下から上とする(CSSでいう0degreeとする)。
                  begin: Alignment(0, 1),
                  end: Alignment(0, -1),

                  colors: [
                    Color(0xFFFF0000),
                    Color(0xFFFF00FF),
                    Color(0xFF0000FF),
                  ],
                  stops: [
                    0.1774,
                    0.499,
                    0.8065,
                  ],
                  transform: GradientRotation((104 / 360) * (pi * 2)),
                ).createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)),
                child: Text(
                  "ABC",
                  style: TextStyle(
                    fontSize: 48,
                    fontWeight: FontWeight.w700,
                    fontFamily: 'NotoSans'
                  ),
                ),
              ),

↓html(styleはFigmaからコピーしたもの)

<html>
  <head>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Noto+Sans&display=swap" rel="stylesheet">
    <style>
      body {
        background: #424242;
      }
      .label {
        margin: 24px;
        text-align: left;
        font-family: Noto Sans;
        font-size: 48px;
        font-style: normal;
        font-weight: 700;
        line-height: normal;
        background: linear-gradient(104deg, #F00 17.74%, #F0F 49.9%, #00F 80.65%);
        background-clip: text;
        -webkit-background-clip: text;
        -webkit-text-fill-color: transparent;
      }
    </style>
  </head>
  <body>
    <span class="label">ABC</span>
  </body>
</html>
matsuchiyomatsuchiyo

↑のFlutterとhtmlの違いがわかりやすくなるよう、くっきりしたグラデーションにしてみる。
以下の通りサイズはそれほど変わりない。なので同じように表示されるはず。なのに表示されない。

↓ やはり、長さの調整が必要かもしれない

begin, endの設定を見ると、左上スミから右上スミへのグラデーションとなっている。pi / 4ラジアンrotationしたら、長さの調節も必要ではないか。
(上のコメント)

Flutter html

↓Flutter

colors: [
  Color(0xFFFF0000),
  Color(0xFFFF0000),
  Color(0xFFFFFF00),
  Color(0xFFFFFF00),
  Color(0xFF00FF00),
  Color(0xFF00FF00),
  Color(0xFF0000FF),
  Color(0xFF0000FF),
],
stops: [
  0,
  0.25,
  0.25,
  0.5,
  0.5,
  0.75,
  0.75,
  1.0,
],

↓html(css)

background: linear-gradient(135deg, #F00 0%, #F00 25%, #FF0 25%, #FF0 50%, #0F0 50%, #0F0 75%, #0FF 75%, #0FF 100%);
matsuchiyomatsuchiyo

上記の「長さの調整」は、GradientTransformation(abstract class)のカスタムクラスを作ればいけそう。現状implementorはGradientRotationのみ。

  • GradientRotationは、SweepGradient向けであって、LinearGradation向きではないみたい。

    For example, a SweepGradient normally starts its gradation at 3 o'clock and draws clockwise. To have the sweep appear to start at 6 o'clock, supply a GradientRotation of pi/4 radians (i.e. 45 degrees).
    https://api.flutter.dev/flutter/painting/GradientTransform-class.html

  • 参考になりそう: GradientRotation.transform()
    @override
    Matrix4 transform(Rect bounds, {TextDirection? textDirection}) {
      final double sinRadians = math.sin(radians);
      final double oneMinusCosRadians = 1 - math.cos(radians);
      final Offset center = bounds.center;
      final double originX = sinRadians * center.dy + oneMinusCosRadians * center.dx; // (1)
      final double originY = -sinRadians * center.dx + oneMinusCosRadians * center.dy; // (2)
    
      return Matrix4.identity()
        ..translate(originX, originY)
        ..rotateZ(radians);
    }
    
    • (1), (2)の部分で何をしているかわからない。x, yの1x2の行列に、1-cosθ, sinθ, -sinθ, 1-cosθの2x2の行列を適用しているけど、回転しているというわけではなさそう。
      • なぜ、回転する角度に応じて、回転の中心(origin)を変える必要があるのか?
        • 180degreeなら、originXは、2 x centerX。originYは、2 x centerY。
        • 360degreeなら、originXは、0。originYも、0。(まあ、そりゃそうか)
        • 90degreeなら、originXは、centerY + centerX。originYは、-centerX + centerY。
        • 270degreeなら、originXは、-centerY + centerX。originYは、centerX + centerY。
      • translateしなかった場合、どこを中心に回転するのか→Textの領域の左上っぽい。
      • 呼び出し元を見ればわかるかな。→ 呼び出し元を見て、matrixをどう使っているか確認しようとしたところ、nativeに渡しており、追えなかった。
      • Textではなく、いったん、Containerを使って長方形で確認した方がわかりやすそう!
        • やはり、translateしない場合、widgetの左上を中心に回転していた。
matsuchiyomatsuchiyo

以下の通りできた。GradientTransformのカスタムクラスを作った。

Flutter html

↓htmlのコード(styleは、いつものようにFigmaからコピーしたもの)

<html>
  <head>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Noto+Sans&display=swap" rel="stylesheet">
    <style>
      body {
        background: #424242;
      }
      .label {
        margin: 24px;
        text-align: left;
        font-family: Noto Sans;
        font-size: 48px;
        font-style: normal;
        font-weight: 700;
        line-height: normal;
        background: linear-gradient(135deg, #F00 0%, #F00 25%, #FF0 25%, #FF0 50%, #0F0 50%, #0F0 75%, #00F 75%, #00F 100%);
        background-clip: text;
        -webkit-background-clip: text;
        -webkit-text-fill-color: transparent;
      }
    </style>
  </head>
  <body>
    <span class="label">ABC</span>
  </body>
</html>

↓Flutterのコード

// ...
              ShaderMask(
                blendMode: BlendMode.srcIn,
                shaderCallback: (bounds) => const LinearGradient(
                  begin: Alignment(0, 1),
                  end: Alignment(0, -1),
                  colors: [
                    Color(0xFFFF0000),
                    Color(0xFFFF0000),
                    Color(0xFFFFFF00),
                    Color(0xFFFFFF00),
                    Color(0xFF00FF00),
                    Color(0xFF00FF00),
                    Color(0xFF0000FF),
                    Color(0xFF0000FF),
                  ],
                  stops: [
                    0,
                    0.25,
                    0.25,
                    0.5,
                    0.5,
                    0.75,
                    0.75,
                    1.0,
                  ],
                  transform: LinearGradientRotation(degree: 135),
                ).createShader(Rect.fromLTWH(0, 0, bounds.width, bounds.height)),
// ...


import 'dart:math' as math;
import 'package:flutter/rendering.dart';

class LinearGradientRotation extends GradientTransform {
  final double degree;

  const LinearGradientRotation(required this.degree);

  @override
  Matrix4? transform(Rect bounds, {TextDirection? textDirection}) {
    final radians = ((degree % 360) / 360) * (math.pi * 2);

    final radians2 = radians % math.pi;

    // 長方形の頂点を左上から反時計周りにABCD、対角線の交点をOとする。
    final diagonalLength = math.sqrt(math.pow(bounds.height, 2) + math.pow(bounds.width, 2));
    final sinBDC = bounds.width / diagonalLength;
    final cornerBDC = math.asin(sinBDC);
    final cornerDBC = math.pi / 2 - cornerBDC;

    const scaleX = 1.0;
    double scaleY = 1.0;
    if (radians2 == 0.0) {
      scaleY = 1.0;
    } else if (radians2 > 0.0 && radians2 < cornerBDC) {
      final cosOfCornerBDCMinusRadians2 = math.cos(cornerBDC - radians2);
      scaleY = ((diagonalLength / 2) * cosOfCornerBDCMinusRadians2 * 2) / bounds.height;
    } else if (radians2 == cornerBDC) {
      scaleY = diagonalLength / bounds.height;
    } else if (radians2 > cornerBDC && radians2 < (math.pi / 2)) {
      final cosOfRadians2MinusCornerBDC = math.cos(radians2 - cornerBDC);
      scaleY = ((diagonalLength / 2) * cosOfRadians2MinusCornerBDC * 2) / bounds.height;
    } else if (radians2 == (math.pi / 2)) {
      scaleY = bounds.width / bounds.height;
    } else if (radians2 > (math.pi / 2) && radians2 < (math.pi - cornerBDC)) {
      final cosOfCornerDBCPlusHalfPiMinusRadians2 = math.cos(cornerDBC + math.pi / 2 - radians2);
      scaleY = ((diagonalLength / 2) * cosOfCornerDBCPlusHalfPiMinusRadians2 * 2) / bounds.height;
    } else if (radians2 == (math.pi - cornerBDC)) {
      scaleY = diagonalLength / bounds.height;
    } else if (radians2 > (math.pi - cornerBDC)) {
      final cosOfRadians2MinusHalfPiMinusCornerDBC = math.cos(radians2 - math.pi / 2 - cornerDBC);
      scaleY = ((diagonalLength / 2) * cosOfRadians2MinusHalfPiMinusCornerDBC * 2) / bounds.height;
    }

    final Offset center = bounds.center;

    final scale = Matrix4.fromList([
      scaleX, 0.0, 0.0, 0.0,
      0.0, scaleY, 0.0, 0.0,
      0.0, 0.0, 1.0, 0.0,
      0.0, 0.0, 0.0, 1.0,
    ]);

    final translate = Matrix4.fromList([
      1.0, 0.0, 0.0, 0.0,
      0.0, 1.0, 0.0, 0.0,
      0.0, 0.0, 1.0, 0.0,
      -center.dx * scaleX, -center.dy * scaleY, 0.0, 1.0,
    ]);

    final rotate = Matrix4.fromList([
      math.cos(radians), math.sin(radians), 0.0, 0.0,
      -math.sin(radians), math.cos(radians), 0.0, 0.0,
      0.0, 0.0, 1.0, 0.0,
      0.0, 0.0, 0.0, 1.0,
    ]);

    final translate2 = Matrix4.fromList([
      1.0, 0.0, 0.0, 0.0,
      0.0, 1.0, 0.0, 0.0,
      0.0, 0.0, 1.0, 0.0,
      center.dx, center.dy, 0.0, 1.0,
    ]);

    return translate2
        .multiplied(rotate)
        .multiplied(translate)
        .multiplied(scale)
    ;
  }
}
matsuchiyomatsuchiyo

これ、ちゃんと、Containerのdecorationでも使えるか?
→ 以下のように使えた。

              Container(
                height: 50,
                width: 100,
                decoration: BoxDecoration(
                  gradient: LinearGradient(
                    begin: Alignment(0, 1),
                    end: Alignment(0, -1),
                    colors: [
                      Color(0xFFFF0000),
                      Color(0xFFFF0000),
                      Color(0xFFFFFF00),
                      Color(0xFFFFFF00),
                      Color(0xFF00FF00),
                      Color(0xFF00FF00),
                      Color(0xFF0000FF),
                      Color(0xFF0000FF),
                    ],
                    stops: [
                      0,
                      0.25,
                      0.25,
                      0.5,
                      0.5,
                      0.75,
                      0.75,
                      1.0,
                    ],
                    transform: LinearGradientRotation(degree: 135),
                  ),
                ),
              ),