建立漸層聊天氣泡
傳統的聊天應用程式會以單色背景的氣泡顯示訊息。現代的聊天應用程式則會以基於氣泡在螢幕上的位置的漸層來顯示聊天氣泡。在這個食譜中,您將透過實作聊天氣泡的漸層背景來現代化聊天 UI。
以下的動畫顯示了應用程式的行為
瞭解挑戰
#傳統的聊天氣泡解決方案可能使用 DecoratedBox
或類似的小工具,在每個聊天訊息後方繪製圓角矩形。這種方法對於單色甚至是每個聊天氣泡中重複的漸層都很好。但是,現代的全螢幕漸層氣泡背景需要不同的方法。全螢幕漸層,加上氣泡在螢幕上向上和向下滾動,需要一種允許您根據佈局資訊做出繪製決策的方法。
每個氣泡的漸層都需要知道氣泡在螢幕上的位置。這表示繪製行為需要存取佈局資訊。這種繪製行為無法使用典型的小工具實現,因為像 Container
和 DecoratedBox
這樣的小工具會在佈局發生之前(而非之後)做出背景顏色的決策。在這種情況下,由於您需要自訂繪製行為,但不需要自訂佈局行為或自訂命中測試行為,因此 CustomPainter
是一個完成工作的絕佳選擇。
取代原始背景小工具
#將負責繪製背景的小工具替換為名為 BubbleBackground
的新無狀態小工具。包含一個 colors
屬性,以表示應套用到氣泡的全螢幕漸層。
BubbleBackground(
// The colors of the gradient, which are different
// depending on which user sent this message.
colors: message.isMine
? const [Color(0xFF6C7689), Color(0xFF3A364B)]
: const [Color(0xFF19B7FF), Color(0xFF491CCB)],
// The content within the bubble.
child: DefaultTextStyle.merge(
style: const TextStyle(
fontSize: 18.0,
color: Colors.white,
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(message.text),
),
),
);
建立自訂繪圖器
#接下來,為 BubbleBackground
引入一個無狀態小工具的實作。目前,定義 build()
方法以傳回一個帶有 CustomPainter
(名為 BubblePainter
) 的 CustomPaint
。BubblePainter
用於繪製氣泡漸層。
@immutable
class BubbleBackground extends StatelessWidget {
const BubbleBackground({
super.key,
required this.colors,
this.child,
});
final List<Color> colors;
final Widget? child;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: BubblePainter(
colors: colors,
),
child: child,
);
}
}
class BubblePainter extends CustomPainter {
BubblePainter({
required List<Color> colors,
}) : _colors = colors;
final List<Color> _colors;
@override
void paint(Canvas canvas, Size size) {
// TODO:
}
@override
bool shouldRepaint(BubblePainter oldDelegate) {
// TODO:
return false;
}
}
提供捲動資訊的存取權
#CustomPainter
需要必要的資訊來判斷其氣泡在 ListView
的邊界內(也稱為 Viewport
)的位置。判斷位置需要參考上層的 ScrollableState
和參考 BubbleBackground
的 BuildContext
。將這兩者都提供給 CustomPainter
。
BubblePainter(
colors: colors,
bubbleContext: context,
scrollable: ScrollableState(),
),
class BubblePainter extends CustomPainter {
BubblePainter({
required ScrollableState scrollable,
required BuildContext bubbleContext,
required List<Color> colors,
}) : _scrollable = scrollable,
_bubbleContext = bubbleContext,
_colors = colors;
final ScrollableState _scrollable;
final BuildContext _bubbleContext;
final List<Color> _colors;
@override
bool shouldRepaint(BubblePainter oldDelegate) {
return oldDelegate._scrollable != _scrollable ||
oldDelegate._bubbleContext != _bubbleContext ||
oldDelegate._colors != _colors;
}
}
繪製全螢幕氣泡漸層
#CustomPainter
現在具有所需的漸層顏色、對包含的 ScrollableState
的參考以及對此氣泡的 BuildContext
的參考。這就是 CustomPainter
繪製全螢幕氣泡漸層所需的所有資訊。實作 paint()
方法以計算氣泡的位置、使用給定的顏色配置著色器,然後使用矩陣轉換根據氣泡在 Scrollable
中的位置偏移著色器。
class BubblePainter extends CustomPainter {
BubblePainter({
required ScrollableState scrollable,
required BuildContext bubbleContext,
required List<Color> colors,
}) : _scrollable = scrollable,
_bubbleContext = bubbleContext,
_colors = colors;
final ScrollableState _scrollable;
final BuildContext _bubbleContext;
final List<Color> _colors;
@override
bool shouldRepaint(BubblePainter oldDelegate) {
return oldDelegate._scrollable != _scrollable ||
oldDelegate._bubbleContext != _bubbleContext ||
oldDelegate._colors != _colors;
}
@override
void paint(Canvas canvas, Size size) {
final scrollableBox = _scrollable.context.findRenderObject() as RenderBox;
final scrollableRect = Offset.zero & scrollableBox.size;
final bubbleBox = _bubbleContext.findRenderObject() as RenderBox;
final origin =
bubbleBox.localToGlobal(Offset.zero, ancestor: scrollableBox);
final paint = Paint()
..shader = ui.Gradient.linear(
scrollableRect.topCenter,
scrollableRect.bottomCenter,
_colors,
[0.0, 1.0],
TileMode.clamp,
Matrix4.translationValues(-origin.dx, -origin.dy, 0.0).storage,
);
canvas.drawRect(Offset.zero & size, paint);
}
}
恭喜!您現在有一個現代的聊天氣泡 UI。
互動式範例
#執行應用程式
- 向上和向下滾動以觀察漸層效果。
- 位於螢幕底部的聊天氣泡的漸層顏色比頂部的氣泡深。
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
void main() {
runApp(const App(home: ExampleGradientBubbles()));
}
@immutable
class App extends StatelessWidget {
const App({super.key, this.home});
final Widget? home;
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Flutter Chat',
theme: ThemeData.dark(useMaterial3: true),
home: home,
);
}
}
@immutable
class ExampleGradientBubbles extends StatefulWidget {
const ExampleGradientBubbles({super.key});
@override
State<ExampleGradientBubbles> createState() => _ExampleGradientBubblesState();
}
class _ExampleGradientBubblesState extends State<ExampleGradientBubbles> {
late final List<Message> data;
@override
void initState() {
super.initState();
data = MessageGenerator.generate(60, 1337);
}
@override
Widget build(BuildContext context) {
return Theme(
data: ThemeData(
brightness: Brightness.dark,
primaryColor: const Color(0xFF4F4F4F),
),
child: Scaffold(
appBar: AppBar(
title: const Text('Flutter Chat'),
),
body: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 16.0),
reverse: true,
itemCount: data.length,
itemBuilder: (context, index) {
final message = data[index];
return MessageBubble(
message: message,
child: Text(message.text),
);
},
),
),
);
}
}
@immutable
class MessageBubble extends StatelessWidget {
const MessageBubble({
super.key,
required this.message,
required this.child,
});
final Message message;
final Widget child;
@override
Widget build(BuildContext context) {
final messageAlignment =
message.isMine ? Alignment.topLeft : Alignment.topRight;
return FractionallySizedBox(
alignment: messageAlignment,
widthFactor: 0.8,
child: Align(
alignment: messageAlignment,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 20.0),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: BubbleBackground(
colors: [
if (message.isMine) ...const [
Color(0xFF6C7689),
Color(0xFF3A364B),
] else ...const [
Color(0xFF19B7FF),
Color(0xFF491CCB),
],
],
child: DefaultTextStyle.merge(
style: const TextStyle(
fontSize: 18.0,
color: Colors.white,
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: child,
),
),
),
),
),
),
);
}
}
@immutable
class BubbleBackground extends StatelessWidget {
const BubbleBackground({
super.key,
required this.colors,
this.child,
});
final List<Color> colors;
final Widget? child;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: BubblePainter(
scrollable: Scrollable.of(context),
bubbleContext: context,
colors: colors,
),
child: child,
);
}
}
class BubblePainter extends CustomPainter {
BubblePainter({
required ScrollableState scrollable,
required BuildContext bubbleContext,
required List<Color> colors,
}) : _scrollable = scrollable,
_bubbleContext = bubbleContext,
_colors = colors,
super(repaint: scrollable.position);
final ScrollableState _scrollable;
final BuildContext _bubbleContext;
final List<Color> _colors;
@override
void paint(Canvas canvas, Size size) {
final scrollableBox = _scrollable.context.findRenderObject() as RenderBox;
final scrollableRect = Offset.zero & scrollableBox.size;
final bubbleBox = _bubbleContext.findRenderObject() as RenderBox;
final origin =
bubbleBox.localToGlobal(Offset.zero, ancestor: scrollableBox);
final paint = Paint()
..shader = ui.Gradient.linear(
scrollableRect.topCenter,
scrollableRect.bottomCenter,
_colors,
[0.0, 1.0],
TileMode.clamp,
Matrix4.translationValues(-origin.dx, -origin.dy, 0.0).storage,
);
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(BubblePainter oldDelegate) {
return oldDelegate._scrollable != _scrollable ||
oldDelegate._bubbleContext != _bubbleContext ||
oldDelegate._colors != _colors;
}
}
enum MessageOwner { myself, other }
@immutable
class Message {
const Message({
required this.owner,
required this.text,
});
final MessageOwner owner;
final String text;
bool get isMine => owner == MessageOwner.myself;
}
class MessageGenerator {
static List<Message> generate(int count, [int? seed]) {
final random = Random(seed);
return List.unmodifiable(List<Message>.generate(count, (index) {
return Message(
owner: random.nextBool() ? MessageOwner.myself : MessageOwner.other,
text: _exampleData[random.nextInt(_exampleData.length)],
);
}));
}
static final _exampleData = [
'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
'In tempus mauris at velit egestas, sed blandit felis ultrices.',
'Ut molestie mauris et ligula finibus iaculis.',
'Sed a tempor ligula.',
'Test',
'Phasellus ullamcorper, mi ut imperdiet consequat, nibh augue condimentum nunc, vitae molestie massa augue nec erat.',
'Donec scelerisque, erat vel placerat facilisis, eros turpis egestas nulla, a sodales elit nibh et enim.',
'Mauris quis dignissim neque. In a odio leo. Aliquam egestas egestas tempor. Etiam at tortor metus.',
'Quisque lacinia imperdiet faucibus.',
'Proin egestas arcu non nisl laoreet, vitae iaculis enim volutpat. In vehicula convallis magna.',
'Phasellus at diam a sapien laoreet gravida.',
'Fusce maximus fermentum sem a scelerisque.',
'Nam convallis sapien augue, malesuada aliquam dui bibendum nec.',
'Quisque dictum tincidunt ex non lobortis.',
'In hac habitasse platea dictumst.',
'Ut pharetra ligula libero, sit amet imperdiet lorem luctus sit amet.',
'Sed ex lorem, lacinia et varius vitae, sagittis eget libero.',
'Vestibulum scelerisque velit sed augue ultricies, ut vestibulum lorem luctus.',
'Pellentesque et risus pretium, egestas ipsum at, facilisis lectus.',
'Praesent id eleifend lacus.',
'Fusce convallis eu tortor sit amet mattis.',
'Vivamus lacinia magna ut urna feugiat tincidunt.',
'Sed in diam ut dolor imperdiet vehicula non ac turpis.',
'Praesent at est hendrerit, laoreet tortor sed, varius mi.',
'Nunc in odio leo.',
'Praesent placerat semper libero, ut aliquet dolor.',
'Vestibulum elementum leo metus, vitae auctor lorem tincidunt ut.',
];
}
回顧
#一般來說,當根據捲動位置或螢幕位置進行繪製時,根本的挑戰在於繪製行為必須在佈局階段完成後發生。CustomPaint
是一個獨特的小工具,可讓您在佈局階段完成後執行自訂繪製行為。如果您在佈局階段之後執行繪製行為,則您可以根據佈局資訊(例如 CustomPaint
小工具在 Scrollable
中或在螢幕中的位置)來做出繪製決策。
除非另有說明,否則本網站上的文件反映了 Flutter 的最新穩定版本。頁面上次更新於 2024-06-26。 檢視原始碼 或 回報問題。