Flutter LinearGradientで透明グラデーションを作る方法|Colors.transparentが濁る原因と対策
FlutterでLinearGradientの終点にColors.transparentを置くと、白から透明へ抜けるはずのグラデーションが途中で灰色っぽく沈みます。これは実装ミスではなく、Colors.transparentが「透明な白」ではなく透明な黒として定義されていることによる必然の結果です。この記事では、Flutter 3.44.9で実際にピクセルを読み戻した値を示しながら原因と回避方法を説明します。基本となるbegin・end・stops・tileModeの指定から、Flutter 3.27で非推奨になったwithOpacityの移行、背景やアイコンへの適用まで一通り扱うので、使い方から確認したい場合は次のまとめのあとの章へ進んでください。
まとめ
透明グラデーションで詰まる原因はほぼ1点に集約されます。Colors.transparentはColor(0x00000000)、つまりRGBが黒のまま不透明度だけ0にした色です。白から透明へ補間すると、途中の色はRGBが黒側へ引っ張られて中間灰色になります。回避策は、色を残したまま不透明度だけ0にすることです。Colors.white.withValues(alpha: 0)またはColor(0x00FFFFFF)と書きます。
不透明度の指定方法そのものもFlutter 3.27で変わりました。withOpacityとopacityは非推奨になり、withValues(alpha:)と.aが正式な書き方です。3.44.9でも旧APIは動きますが、analyzerが警告を出します。
ただしColors.transparentが常に悪いわけではありません。ShaderMaskのマスクとして使う場合は不透明度しか参照されないため、どちらで書いても結果は同一です。以下、実測値とコードで順に見ていきます。
LinearGradientの最小実装と方向・配色の制御
begin・endで決まるグラデーションの方向
LinearGradientはウィジェットではなく、BoxDecorationのgradientに渡す描画指定です。最小構成はcolorsだけで、方向はbeginとendにAlignmentを渡して決めます。既定値はbegin: Alignment.centerLeft、end: Alignment.centerRightの横方向です。
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[Color(0xFF2196F3), Color(0xFF9C27B0)],
),
),
)
Alignmentはx・yとも-1.0から1.0で指定します。Alignment.topCenterからAlignment.bottomCenterなら縦方向、上のように対角を指定すれば斜めです。角度を数値で与えるAPIはないため、任意角度が必要なときはAlignment(-0.8, -1.0)のように座標そのものを調整するか、transformにGradientRotationを渡します。
stopsによる色の配置と件数不一致で出る実行時エラー
stopsを省略すると色は等間隔に配置されます。配分を変えたい場合は0.0から1.0の位置を指定しますが、要素数はcolorsと一致させる必要があります。
const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: <Color>[Colors.red, Colors.yellow, Colors.green],
stops: <double>[0.0, 0.2, 1.0],
tileMode: TileMode.clamp,
)
件数がずれた場合、コンパイルは通ります。Flutter 3.44.9で色3件に対しstops2件を渡してcreateShaderを呼ぶと、実行時に次の例外が出ます。
Invalid argument(s): "colors" and "colorStops" arguments must have equal length.
ビルド時に止まらないため、色数を後から増やしたときに見落としやすい箇所です。
tileModeで決まる描画範囲外の扱い
tileModeは、グラデーションの定義範囲より外側をどう塗るかの指定です。値はclamp・repeated・mirror・decalの4種類で、既定はTileMode.clamp、端の色をそのまま引き延ばします。repeatedは同じグラデーションを繰り返し、mirrorは反転しながら繰り返し、decalは範囲外を透明にします。stopsを[0.0, 0.3]のように途中で終わらせたうえでrepeatedを指定すると、ストライプ状の背景になります。beginとendが矩形全体を覆う既定の使い方では、tileModeの違いは画面に現れません。
Flutter 3.27以降の透明色の書き方とwithOpacityの非推奨
withValues(alpha:)への移行とanalyzerが出す警告
Flutter 3.27でColorの内部表現が8bit整数から浮動小数点に変わり、広色域(Display P3など)に対応しました。これに伴い、8bitを前提としていたAPIが非推奨になっています。3.44.9のソースではwithOpacityに@Deprecated('Use .withValues() to avoid precision loss.')が付いており、内部実装はwithAlpha((255.0 * opacity).round())のまま、つまり8bitへ丸める処理が残っています。これが「precision loss」の中身です。
Flutter 3.44.9で、非推奨APIを4種類含む検証用ファイル(lib/snippets.dart)にflutter analyzeを実行した出力が次のものです。行番号と列番号はそのファイルでの位置なので、手元では読み替えてください。
info • 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss. Try replacing the use of the deprecated member with the replacement • lib/snippets.dart:31:32 • deprecated_member_use
info • 'opacity' is deprecated and shouldn't be used. Use .a. Try replacing the use of the deprecated member with the replacement • lib/snippets.dart:32:57 • deprecated_member_use
info • 'value' is deprecated and shouldn't be used. Use component accessors like .r or .g, or toARGB32 for an explicit conversion. Try replacing the use of the deprecated member with the replacement • lib/snippets.dart:33:35 • deprecated_member_use
info • 'red' is deprecated and shouldn't be used. Use (*.r * 255.0).round().clamp(0, 255). Try replacing the use of the deprecated member with the replacement • lib/snippets.dart:34:33 • deprecated_member_use
置き換えの対応は次のとおりです。
| 旧API(3.27で非推奨) | 新API | 型 |
|---|---|---|
| color.withOpacity(0.5) | color.withValues(alpha: 0.5) | Color |
| color.opacity | color.a | double(0.0〜1.0) |
| color.value | color.toARGB32() | int |
| color.red | (color.r * 255.0).round() | int |
公式の移行ガイドでは旧APIの削除時期は「Not yet」とされており、3.44.9時点でも削除されていません。警告レベルもinfoのままなので、ビルドは止まりません。ただし.aはdouble、.valueの置き換え先toARGB32()はintと型が変わるため、機械的な一括置換では比較演算やMapのキーに使っている箇所が壊れます。移行は型が変わらないwithOpacityから着手してください。Colorを返すまま置き換えられるので、差分がレビューしやすくなります。Flutter本体の版ごとの変更点はFlutter 3.44とは?SPM既定化とHCPPで変わる移行判断【2026年7月時点】にまとめています。
ARGBリテラルで透明を書く場合の0x00FFFFFF
色をリテラルで書く場合、FlutterのColorは0xAARRGGBBの並びです。先頭2桁が不透明度で、0x00が完全透明、0xFFが完全不透明。CSSのrgba(255, 255, 255, 0)に相当するのはColor(0x00FFFFFF)で、RGB部分に白を残したまま先頭を00にします。Color(0x00000000)と書くとRGBが黒になり、次章の濁りが発生します。約50%の白なら0x80FFFFFF(0x80は128、正確には50.2%)。使い分けの基準は単純で、constコンテキストに置くならリテラル、実行時に既存の色から作るならwithValuesです。
白から透明のグラデーションが灰色に濁る原因と実測値
Colors.transparentは透明な黒という定義
Flutter 3.44.9のソースでColors.transparentは次のように定義されています。
/// Completely invisible.
static const Color transparent = Color(0x00000000);
RGBはすべて0、つまり黒です。不透明度が0なので単独で置く分には見えませんが、グラデーションの端点にすると話が変わります。補間はRGBと不透明度をそれぞれ独立に線形補間するため、白と透明な黒の中間は「50%不透明な中間灰色」になります。透明度が下がりながら同時に色が黒へ寄るので、背景に重ねると暗い帯として見えます。
これはFlutter側も認識している挙動で、Color.lerpのドキュメントコメントに「Colors.transparent(const Color(0x00000000))から補間するより、相手の色の透明版から補間するほうが望ましい。Colors.transparentはとりわけ透明な黒である」という趣旨の注意書きが入っています。
青地に重ねた中点ピクセルの実測値
不透明な青(0xFF0000FF)の上に幅400px・高さ8pxのグラデーションを描画し、縦方向の中央にあたる行のピクセルを読み戻した結果です。Flutter 3.44.9、flutter test上のPictureRecorderで計測しています。端数はレンダラのディザリングにより読み出す行で±1ほど動きますが、以下の比較の結論は変わりません。
| 位置 | Colors.transparent | white.withValues(alpha: 0) |
|---|---|---|
| 0.00 | 254, 254, 254 | 254, 254, 255 |
| 0.25 | 142, 142, 207 | 190, 190, 255 |
| 0.50 | 63, 63, 191 | 127, 127, 255 |
| 0.75 | 17, 17, 208 | 64, 64, 255 |
| 1.00 | 0, 0, 255 | 0, 0, 255 |
中点を比べると差は明白です。正しい書き方では白の成分が127残るのに対し、Colors.transparent版は63まで落ちています。ちょうど半減している値で、白と黒を50%で混ぜた結果がそのまま出ています。青チャンネルも255に対して191で、色全体が沈んで見えます。人の目には「白いフェードのはずが灰色〜暗い帯が入る」と映る差です。
正しい書き方は次の2通りで、どちらも同じ0x00FFFFFFになります。
// リテラルで書く(const にできる)
const LinearGradient fadeOut = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[Colors.white, Color(0x00FFFFFF)],
);
// 既存の色から作る
final Color baseColor = Theme.of(context).colorScheme.surface;
final LinearGradient fade = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[baseColor, baseColor.withValues(alpha: 0)],
);
実務では後者を既定にしてください。テーマ色やブランドカラーを直接受け取れるので、色を差し替えたときに片方だけ直し忘れる事故が起きません。半透明を重ねてすりガラス風の面を作る設計そのものについては、Liquid Glassとは?仕組みと対応OS、SwiftUI・CSSでの実装方法【iOS 27の変更点まで】で扱っています。
ShaderMaskのマスクなら濁らない境界
「Colors.transparentを使うな」で片付けると行き過ぎです。ShaderMaskにBlendMode.dstInでグラデーションを渡す場合、参照されるのはマスクの不透明度だけで、RGBは結果に影響しません。
確かめるには、RGBがまったく違う2つのマスクを比べます。黒からColors.transparent(RGBは黒)へのマスクと、黒からColors.white.withValues(alpha: 0)(RGBは白)へのマスクで、同じ白い前景を抜いた結果です。3.44.9で計測すると、位置0.25で191,191,255、0.50で127,127,255、0.75で64,64,255と、両者のピクセル値が完全に一致しました。RGBを黒から白へ振ってもマスクの結果は1バイトも変わりません。
なお、比較相手にColors.black.withValues(alpha: 0)を選ぶと検証になりません。Colors.transparentと等値(どちらも0x00000000)なので、同じ色どうしを比べているだけになるためです。
切り分けの基準はこうです。グラデーション自体を色として塗るとき、つまりBoxDecorationのgradientやPaint.shaderに渡すときは、RGBが結果に出るのでColors.transparentは使えません。グラデーションをマスクとして使うとき、つまりShaderMaskのdstInやdstOutのときは、どちらで書いても同じです。判断に迷うなら常にwithValues(alpha: 0)で書けば、両方のケースで正解になります。
背景・テキスト・アイコンへの適用と実装上の注意
画面全体の背景への適用とextendBodyBehindAppBarの設定
Scaffold自体にgradientプロパティはありません。backgroundColorは単色しか受け付けないため、Containerをbodyに置いて塗ります。AppBarの下から塗りたい場合はextendBodyBehindAppBar: trueを指定し、AppBar側をbackgroundColor: Colors.transparentとelevation: 0で透過させます。ここでのAppBarは色を塗らない指定なので、Colors.transparentで問題ありません。
Material 3ではこの2つだけでは足りません。3.44.9のM3既定値はscrolledUnderElevation: 3.0で、コンテンツがAppBarの下へスクロールで潜った瞬間に影が付き、透過させたはずの境界に線が出ます。scrolledUnderElevation: 0まで指定してください。surfaceTintColorのM3既定はColors.transparentなので、こちらの追加指定は不要です。
Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
),
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[Color(0xFF1E88E5), Color(0xFF6A1B9A)],
),
),
),
)
ShaderMaskによるテキスト・アイコンへの着色と画像のフェード
テキストやアイコンはcolorに単色しか渡せないため、グラデーションを乗せるにはShaderMaskを使います。前景を白などの不透明色で描いたうえで、shaderCallbackが返すグラデーションを重ねる構造です。文字やアイコンそのものをグラデーションで塗るなら既定のBlendMode.modulate、画像の下端を透明へ抜くならBlendMode.dstInを指定します。
アイコンや文字に色を乗せる場合、子ウィジェット側の色は必ず白(Colors.white)にします。modulateは前景とマスクの色を乗算するため、子の色が白以外だとグラデーションの色がその分だけ暗く沈みます。
ShaderMask(
shaderCallback: (Rect bounds) => const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[Color(0xFFFF6D00), Color(0xFFD500F9)],
).createShader(bounds),
child: const Icon(Icons.favorite, size: 64, color: Colors.white),
)
ShaderMask(
shaderCallback: (Rect bounds) => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[Colors.black, Colors.transparent],
).createShader(bounds),
blendMode: BlendMode.dstIn,
child: Image.asset('assets/photo.png'),
)
shaderCallbackが受け取るboundsは子ウィジェットの矩形なので、そのままcreateShaderに渡せば範囲がずれません。固定値のRectを渡すと画面サイズが変わったときに破綻します。Flutterの画面構成やウィジェット分割の考え方はFlutterにおけるアーキテクチャ設計の基本と重要性で扱っています。
RadialGradient・SweepGradientとの使い分け
Flutterには線形のほかに2種類のグラデーションがあり、いずれもBoxDecorationのgradientに同じ形で渡せます。
| クラス | 色の変化 | 位置指定 | 主な用途 |
|---|---|---|---|
| LinearGradient | 直線方向 | begin / end | 背景・ボタン・フェード |
| RadialGradient | 中心から外周へ | center / radius | スポットライト・円形ボタン |
| SweepGradient | 中心まわりの角度 | center / startAngle | 円グラフ・カラーホイール |
RadialGradientのradiusの基準と既定値0.5
円形・放射状の背景を作るならRadialGradientですが、radiusの基準を取り違えやすい点に注意してください。公式のドキュメントコメントでは「矩形の短辺の長さを1.0とした比率」と定義されています。幅100px・高さ200pxの矩形ならradius: 1.0は中心から100pxの位置です。短辺方向の端にちょうど外側の色が来るのはradius: 0.5で、これが既定値になっています。
1.0へ上げると色の変化が矩形の外側まで続くため、画面内には中心寄りの色しか出ません。円形ボタンや放射状の背景で「外周まで色が回りきらない」ときは、まずこの値を疑ってください。中心をずらしたい場合はcenterにAlignmentを渡します。
SweepGradientの角度指定と使い分けの判断
SweepGradientは中心まわりの角度で色が変わります。startAngleとendAngleはラジアン指定で、既定は0.0からmath.pi * 2までの全周です。進捗リングやカラーホイールのように、角度そのものが意味を持つ表現に向きます。
線形で足りる場面でRadialGradientやSweepGradientを選ぶ理由はありません。中心に視線を集めたい、または円形の要素に沿わせたいという明確な意図があるときだけ切り替えてください。Web側で3Dのグラデーション背景を扱う場合はShaderGradientとは?React・Next.jsで3Dグラデーション背景を実装する手順が参考になります。
グラデーションに縞(バンディング)が出る条件
グラデーションが滑らかにならず縞模様に見える現象をカラーバンディングと呼びます。原因は表示できる階調数の不足です。Flutter 3.44.9で、幅を変えながらグラデーション1行に現れる色数を数えた結果が次のとおりです(高さ4pxで描画し中央の行を集計。#FFFFFFの行はディザリングの影響を受けるため、読み出す行により数十色ぶれます)。
| 配色 | 幅200px | 幅400px | 幅1200px |
|---|---|---|---|
| #1A1A1A から #000000 | 27色 | 27色 | 27色 |
| #FFFFFF から #000000 | 160色 | 220色 | 256色 |
読み取れることははっきりしています。階調数は要素の幅ではなく、指定した2色が8bitの範囲でどれだけ離れているかで決まります。#1A1A1Aと#000000の間には26段しか整数値が存在しないため、1200pxまで広げても27色のままで、1色あたり44pxの帯になります。これが縞として見える正体です。白黒のように離れた配色では256色の上限に張り付き、幅200pxでも160色が出ています。
したがって対策は「グラデーションを広くしない」ではありません。近い2色を広い面積に引き延ばす設計そのものを避けます。暗い背景で微妙な色差を出したい場合は、中間に色を1つ足してstopsで配分を変えるか、わずかなノイズを重ねた画像に置き換えるほうが確実です。LinearGradientをはじめとする標準のGradient系クラスには、階調を増やす指定はありません。どうしても必要ならFragmentProgramでGLSLのシェーダを書き、ディザを自前で乗せることになります。UIの背景1枚のためにその複雑さを持ち込む価値はほとんどないので、配色を見直すほうが先です。
よくある質問
Flutterで透明のカラーコードはどう書きますか?
Color(0x00FFFFFF)のように、先頭2桁の不透明度を00にして書きます。FlutterのColorは0xAARRGGBBの順で、CSSのrgba()とは不透明度の位置が違う点に注意してください。RGB部分は完全透明でも意味を持ち、グラデーションやアニメーションの補間で使われます。白を透明にするなら0x00FFFFFF、黒なら0x00000000です。後者はColors.transparentと同じ値です。
withOpacityは使えなくなりましたか?
Flutter 3.44.9時点では動作します。3.27で非推奨になりましたが、公式の移行ガイドは削除時期を「Not yet」としており、まだ削除されていません。flutter analyzeではinfoレベルの警告が出るだけで、ビルドは通ります。ただし内部で8bitへ丸めているため、広色域を扱う場合はwithValues(alpha:)へ移行してください。opacityゲッターの置き換え先.aはdoubleを返すので、型の違いに注意が必要です。
円形・放射状のグラデーションはどう作りますか?
LinearGradientをRadialGradientに置き換えます。渡し先は同じBoxDecorationのgradientで、方向を決めるbegin・endの代わりにcenterとradiusを指定します。radiusは矩形の短辺の長さを1.0とした比率で、既定値は0.5です。外周まで色が届かないと感じる場合は、この既定値のまま中心をずらしていないかを確認してください。中心のまわりを角度で一周させたい場合はSweepGradientを使います。
アイコンや文字にグラデーションをかけるにはどうしますか?
IconやTextのcolorは単色しか受け付けないため、ShaderMaskで包み、shaderCallbackからcreateShader(bounds)したグラデーションを返します。blendModeは既定のBlendMode.modulateのままで構いません。このとき子ウィジェットのcolorは白にしてください。modulateは前景の色とマスクを乗算するため、白以外だとグラデーションがその分だけ暗くなります。
白から透明のグラデーションが灰色になるのはバグですか?
仕様どおりの動作です。Colors.transparentはColor(0x00000000)、つまりRGBが黒の色として定義されているため、白から補間すると中間が灰色になります。Flutter 3.44.9で青地に重ねて計測したところ、中点のRGBは正しい書き方の127に対して63まで落ちました。Colors.white.withValues(alpha: 0)またはColor(0x00FFFFFF)に置き換えれば解消します。ShaderMaskのdstInマスクとして使う場合は不透明度しか見ないため、この問題は起きません。