创建渐变聊天气泡
传统的聊天应用使用带有纯色背景的聊天气泡显示消息。现代聊天应用则使用基于气泡在屏幕上位置的渐变来显示聊天气泡。在这个示例中,你将通过为聊天气泡实现渐变背景来使聊天UI现代化。
下面的动画展示了应用的行为:
了解挑战
#传统的聊天气泡解决方案可能使用 DecoratedBox
或类似的 widget 来在每条聊天消息后面绘制一个圆角矩形。这种方法对于纯色甚至在每个聊天气泡中重复的渐变来说都很不错。但是,现代的、全屏的、渐变的气泡背景需要不同的方法。全屏渐变与气泡在屏幕上上下滚动相结合,需要一种允许你根据布局信息做出绘制决策的方法。
每个气泡的渐变都需要知道气泡在屏幕上的位置。这意味着绘制行为需要访问布局信息。这种绘制行为在典型的 widget 中是不可能的,因为像 Container
和 DecoratedBox
这样的 widget 在布局发生之前就决定了背景颜色,而不是之后。在这种情况下,因为你需要自定义绘制行为,但不需要自定义布局行为或自定义点击测试行为,所以CustomPainter
是完成这项工作的绝佳选择。
替换原始背景 widget
#用一个名为 BubbleBackground
的新的无状态 widget 替换负责绘制背景的 widget。包含一个 colors
属性来表示应该应用于气泡的全屏渐变。
BubbleBackground(
// 渐变的颜色,根据发送消息的用户不同而不同。
colors: message.isMine
? const [Color(0xFF6C7689), Color(0xFF3A364B)]
: const [Color(0xFF19B7FF), Color(0xFF491CCB)],
// 气泡内的内容。
child: DefaultTextStyle.merge(
style: const TextStyle(
fontSize: 18.0,
color: Colors.white,
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(message.text),
),
),
);
创建自定义 painter
#接下来,将 BubbleBackground
实现为一个无状态 widget。目前,将 build()
方法定义为返回一个带有名为 BubblePainter
的 CustomPainter
的 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
是一个独特的 widget,它允许你在布局阶段完成后执行自定义绘制行为。如果你在布局阶段之后执行绘制行为,那么你就可以根据布局信息(例如 CustomPaint
widget 在 Scrollable
或屏幕中的位置)来做出绘制决策。
除非另有说明,否则本网站上的文档反映的是 Flutter 的最新稳定版本。页面最后更新于 2025-01-30。 查看源代码 或 报告问题。