创建渐变聊天气泡
传统的聊天应用程序在聊天气泡中显示带有纯色背景的消息。现代聊天应用程序显示带有渐变的聊天气泡,这些渐变基于气泡在屏幕上的位置。在本食谱中,您将通过为聊天气泡实现渐变背景来使聊天 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()
方法以返回一个带有名为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
是一个独特的小部件,它允许你在布局阶段完成后执行自定义绘制行为。如果你在布局阶段完成后执行绘制行为,那么你就可以根据布局信息(例如 CustomPaint
小部件在 Scrollable
或屏幕中的位置)做出绘制决策。