Flutter 聊天文本像 Whatsapp 或 Telegram 一样对齐
Flutter chat text align like Whatsapp or Telegram
我无法确定这种与 Flutter 的一致性。
Whatsapp 或 Telegram 上的右转换是左对齐,但日期在右。如果日期有 space,则它位于同一行的末尾。
第一条和第三条聊天线可以用 Wrap()
小部件完成。但是第 2 行不可能使用 Wrap()
,因为聊天文本是一个单独的小部件并填充整个宽度并且不允许日期小部件适合。你会如何使用 Flutter 做到这一点?
这里有一个您可以在 DartPad 中 运行 的示例,它可能足以让您入门。它使用 SingleChildRenderObjectWidget
布局 child 并绘制 ChatBubble
的聊天消息以及消息时间和虚拟复选标记图标。
要了解有关 RenderObject
class 的更多信息,我可以推荐这个 video。它非常深入地描述了所有相关的 classes 和方法,并帮助我创建了我的第一个自定义 RenderObject
。
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:intl/intl.dart';
const Color darkBlue = Color.fromARGB(255, 18, 32, 47);
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: darkBlue,
),
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(
child: ExampleChatBubbles(),
),
),
);
}
}
class ChatBubble extends StatelessWidget {
final String message;
final DateTime messageTime;
final Alignment alignment;
final Icon icon;
final TextStyle textStyleMessage;
final TextStyle textStyleMessageTime;
// The available max width for the chat bubble in percent of the incoming constraints
final int maxChatBubbleWidthPercentage;
const ChatBubble({
Key? key,
required this.message,
required this.icon,
required this.alignment,
required this.messageTime,
this.maxChatBubbleWidthPercentage = 80,
this.textStyleMessage = const TextStyle(
fontSize: 11,
color: Colors.black,
),
this.textStyleMessageTime = const TextStyle(
fontSize: 11,
color: Colors.black,
),
}) : assert(
maxChatBubbleWidthPercentage <= 100 &&
maxChatBubbleWidthPercentage >= 50,
'maxChatBubbleWidthPercentage width must lie between 50 and 100%',
),
super(key: key);
@override
Widget build(BuildContext context) {
final textSpan = TextSpan(text: message, style: textStyleMessage);
final textPainter = TextPainter(
text: textSpan,
textDirection: ui.TextDirection.ltr,
);
return Align(
alignment: alignment,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 5,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
color: Colors.green.shade200,
),
child: InnerChatBubble(
maxChatBubbleWidthPercentage: maxChatBubbleWidthPercentage,
textPainter: textPainter,
child: Padding(
padding: const EdgeInsets.only(
left: 15,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
DateFormat('hh:mm').format(messageTime),
style: textStyleMessageTime,
),
const SizedBox(
width: 5,
),
icon
],
),
),
),
),
);
}
}
// By using a SingleChildRenderObjectWidget we have full control about the whole
// layout and painting process.
class InnerChatBubble extends SingleChildRenderObjectWidget {
final TextPainter textPainter;
final int maxChatBubbleWidthPercentage;
const InnerChatBubble({
Key? key,
required this.textPainter,
required this.maxChatBubbleWidthPercentage,
Widget? child,
}) : super(key: key, child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderInnerChatBubble(textPainter, maxChatBubbleWidthPercentage);
}
@override
void updateRenderObject(
BuildContext context, RenderInnerChatBubble renderObject) {
renderObject
..textPainter = textPainter
..maxChatBubbleWidthPercentage = maxChatBubbleWidthPercentage;
}
}
class RenderInnerChatBubble extends RenderBox
with RenderObjectWithChildMixin<RenderBox> {
TextPainter _textPainter;
int _maxChatBubbleWidthPercentage;
double _lastLineHeight = 0;
RenderInnerChatBubble(
TextPainter textPainter, int maxChatBubbleWidthPercentage)
: _textPainter = textPainter,
_maxChatBubbleWidthPercentage = maxChatBubbleWidthPercentage;
TextPainter get textPainter => _textPainter;
set textPainter(TextPainter value) {
if (_textPainter == value) return;
_textPainter = value;
markNeedsLayout();
}
int get maxChatBubbleWidthPercentage => _maxChatBubbleWidthPercentage;
set maxChatBubbleWidthPercentage(int value) {
if (_maxChatBubbleWidthPercentage == value) return;
_maxChatBubbleWidthPercentage = value;
markNeedsLayout();
}
@override
void performLayout() {
// Layout child and calculate size
size = _performLayout(
constraints: constraints,
dry: false,
);
// Position child
final BoxParentData childParentData = child!.parentData as BoxParentData;
childParentData.offset = Offset(
size.width - child!.size.width, textPainter.height - _lastLineHeight);
}
@override
Size computeDryLayout(BoxConstraints constraints) {
return _performLayout(constraints: constraints, dry: true);
}
Size _performLayout({
required BoxConstraints constraints,
required bool dry,
}) {
final BoxConstraints constraints =
this.constraints * (_maxChatBubbleWidthPercentage / 100);
textPainter.layout(minWidth: 0, maxWidth: constraints.maxWidth);
double height = textPainter.height;
double width = textPainter.width;
// Compute the LineMetrics of our textPainter
final List<ui.LineMetrics> lines = textPainter.computeLineMetrics();
// We are only interested in the last line's width
final lastLineWidth = lines.last.width;
_lastLineHeight = lines.last.height;
// Layout child and assign size of RenderBox
if (child != null) {
late final Size childSize;
if (!dry) {
child!.layout(BoxConstraints(maxWidth: constraints.maxWidth),
parentUsesSize: true);
childSize = child!.size;
} else {
childSize =
child!.getDryLayout(BoxConstraints(maxWidth: constraints.maxWidth));
}
final horizontalSpaceExceeded =
lastLineWidth + childSize.width > constraints.maxWidth;
if (horizontalSpaceExceeded) {
height += childSize.height;
_lastLineHeight = 0;
} else {
height += childSize.height - _lastLineHeight;
}
if (lines.length == 1 && !horizontalSpaceExceeded) {
width += childSize.width;
}
}
return Size(width, height);
}
@override
void paint(PaintingContext context, Offset offset) {
// Paint the chat message
textPainter.paint(context.canvas, offset);
if (child != null) {
final parentData = child!.parentData as BoxParentData;
// Paint the child (i.e. the row with the messageTime and Icon)
context.paintChild(child!, offset + parentData.offset);
}
}
}
class ExampleChatBubbles extends StatelessWidget {
// Some chat dummy data
final chatData = [
[
'Hi',
Alignment.centerRight,
DateTime.now().add(const Duration(minutes: -100)),
],
[
'Helloooo?',
Alignment.centerRight,
DateTime.now().add(const Duration(minutes: -60)),
],
[
'Hi James',
Alignment.centerLeft,
DateTime.now().add(const Duration(minutes: -58)),
],
[
'Do you want to watch the basketball game tonight? We could order some chinese food :)',
Alignment.centerRight,
DateTime.now().add(const Duration(minutes: -57)),
],
[
'Sounds great! Let us meet at 7 PM, okay?',
Alignment.centerLeft,
DateTime.now().add(const Duration(minutes: -57)),
],
[
'See you later!',
Alignment.centerRight,
DateTime.now().add(const Duration(minutes: -55)),
],
];
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListView.builder(
itemCount: chatData.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 5,
),
child: ChatBubble(
icon: Icon(
Icons.check,
size: 15,
color: Colors.grey.shade700,
),
alignment: chatData[index][1] as Alignment,
message: chatData[index][0] as String,
messageTime: chatData[index][2] as DateTime,
// How much of the available width may be consumed by the ChatBubble
maxChatBubbleWidthPercentage: 75,
),
);
},
),
);
}
}
@hnnngwdlch 感谢您的回答,它对我有帮助,您可以完全控制画家。我出于我的目的稍微修改了您的代码,也许它对某些人有用。
PD:我不知道在RenderObject 中声明TextPainter 是否有明显的性能缺点,如果有人知道请写在评论中。
class TextMessageWidget extends SingleChildRenderObjectWidget {
final String text;
final TextStyle? textStyle;
final double? spacing;
const TextMessageWidget({
Key? key,
required this.text,
this.textStyle,
this.spacing,
required Widget child,
}) : super(key: key, child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderTextMessageWidget(text, textStyle, spacing);
}
@override
void updateRenderObject(BuildContext context, RenderTextMessageWidget renderObject) {
renderObject
..text = text
..textStyle = textStyle
..spacing = spacing;
}
}
class RenderTextMessageWidget extends RenderBox with RenderObjectWithChildMixin<RenderBox> {
String _text;
TextStyle? _textStyle;
double? _spacing;
// With this constants you can modify the final result
static const double _kOffset = 1.5;
static const double _kFactor = 0.8;
RenderTextMessageWidget(
String text,
TextStyle? textStyle,
double? spacing
) : _text = text, _textStyle = textStyle, _spacing = spacing;
String get text => _text;
set text(String value) {
if (_text == value) return;
_text = value;
markNeedsLayout();
}
TextStyle? get textStyle => _textStyle;
set textStyle(TextStyle? value) {
if (_textStyle == value) return;
_textStyle = value;
markNeedsLayout();
}
double? get spacing => _spacing;
set spacing(double? value) {
if (_spacing == value) return;
_spacing = value;
markNeedsLayout();
}
TextPainter textPainter = TextPainter();
@override
void performLayout() {
size = _performLayout(constraints: constraints, dry: false);
final BoxParentData childParentData = child!.parentData as BoxParentData;
childParentData.offset = Offset(
size.width - child!.size.width,
size.height - child!.size.height / _kOffset
);
}
@override
Size computeDryLayout(BoxConstraints constraints) {
return _performLayout(constraints: constraints, dry: true);
}
Size _performLayout({required BoxConstraints constraints, required bool dry}) {
textPainter = TextPainter(
text: TextSpan(text: _text, style: _textStyle),
textDirection: TextDirection.ltr
);
late final double spacing;
if(_spacing == null){
spacing = constraints.maxWidth * 0.03;
} else {
spacing = _spacing!;
}
textPainter.layout(minWidth: 0, maxWidth: constraints.maxWidth);
double height = textPainter.height;
double width = textPainter.width;
// Compute the LineMetrics of our textPainter
final List<LineMetrics> lines = textPainter.computeLineMetrics();
// We are only interested in the last line's width
final lastLineWidth = lines.last.width;
if(child != null){
late final Size childSize;
if (!dry) {
child!.layout(BoxConstraints(maxWidth: constraints.maxWidth), parentUsesSize: true);
childSize = child!.size;
} else {
childSize = child!.getDryLayout(BoxConstraints(maxWidth: constraints.maxWidth));
}
if(lastLineWidth + spacing > constraints.maxWidth - child!.size.width) {
height += (childSize.height * _kFactor);
} else if(lines.length == 1){
width += childSize.width + spacing;
}
}
return Size(width, height);
}
@override
void paint(PaintingContext context, Offset offset) {
textPainter.paint(context.canvas, offset);
final parentData = child!.parentData as BoxParentData;
context.paintChild(child!, offset + parentData.offset);
}
}
我无法确定这种与 Flutter 的一致性。
Whatsapp 或 Telegram 上的右转换是左对齐,但日期在右。如果日期有 space,则它位于同一行的末尾。
第一条和第三条聊天线可以用 Wrap()
小部件完成。但是第 2 行不可能使用 Wrap()
,因为聊天文本是一个单独的小部件并填充整个宽度并且不允许日期小部件适合。你会如何使用 Flutter 做到这一点?
这里有一个您可以在 DartPad 中 运行 的示例,它可能足以让您入门。它使用 SingleChildRenderObjectWidget
布局 child 并绘制 ChatBubble
的聊天消息以及消息时间和虚拟复选标记图标。
要了解有关 RenderObject
class 的更多信息,我可以推荐这个 video。它非常深入地描述了所有相关的 classes 和方法,并帮助我创建了我的第一个自定义 RenderObject
。
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:intl/intl.dart';
const Color darkBlue = Color.fromARGB(255, 18, 32, 47);
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData.dark().copyWith(
scaffoldBackgroundColor: darkBlue,
),
debugShowCheckedModeBanner: false,
home: Scaffold(
body: Center(
child: ExampleChatBubbles(),
),
),
);
}
}
class ChatBubble extends StatelessWidget {
final String message;
final DateTime messageTime;
final Alignment alignment;
final Icon icon;
final TextStyle textStyleMessage;
final TextStyle textStyleMessageTime;
// The available max width for the chat bubble in percent of the incoming constraints
final int maxChatBubbleWidthPercentage;
const ChatBubble({
Key? key,
required this.message,
required this.icon,
required this.alignment,
required this.messageTime,
this.maxChatBubbleWidthPercentage = 80,
this.textStyleMessage = const TextStyle(
fontSize: 11,
color: Colors.black,
),
this.textStyleMessageTime = const TextStyle(
fontSize: 11,
color: Colors.black,
),
}) : assert(
maxChatBubbleWidthPercentage <= 100 &&
maxChatBubbleWidthPercentage >= 50,
'maxChatBubbleWidthPercentage width must lie between 50 and 100%',
),
super(key: key);
@override
Widget build(BuildContext context) {
final textSpan = TextSpan(text: message, style: textStyleMessage);
final textPainter = TextPainter(
text: textSpan,
textDirection: ui.TextDirection.ltr,
);
return Align(
alignment: alignment,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 5,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
color: Colors.green.shade200,
),
child: InnerChatBubble(
maxChatBubbleWidthPercentage: maxChatBubbleWidthPercentage,
textPainter: textPainter,
child: Padding(
padding: const EdgeInsets.only(
left: 15,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
DateFormat('hh:mm').format(messageTime),
style: textStyleMessageTime,
),
const SizedBox(
width: 5,
),
icon
],
),
),
),
),
);
}
}
// By using a SingleChildRenderObjectWidget we have full control about the whole
// layout and painting process.
class InnerChatBubble extends SingleChildRenderObjectWidget {
final TextPainter textPainter;
final int maxChatBubbleWidthPercentage;
const InnerChatBubble({
Key? key,
required this.textPainter,
required this.maxChatBubbleWidthPercentage,
Widget? child,
}) : super(key: key, child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderInnerChatBubble(textPainter, maxChatBubbleWidthPercentage);
}
@override
void updateRenderObject(
BuildContext context, RenderInnerChatBubble renderObject) {
renderObject
..textPainter = textPainter
..maxChatBubbleWidthPercentage = maxChatBubbleWidthPercentage;
}
}
class RenderInnerChatBubble extends RenderBox
with RenderObjectWithChildMixin<RenderBox> {
TextPainter _textPainter;
int _maxChatBubbleWidthPercentage;
double _lastLineHeight = 0;
RenderInnerChatBubble(
TextPainter textPainter, int maxChatBubbleWidthPercentage)
: _textPainter = textPainter,
_maxChatBubbleWidthPercentage = maxChatBubbleWidthPercentage;
TextPainter get textPainter => _textPainter;
set textPainter(TextPainter value) {
if (_textPainter == value) return;
_textPainter = value;
markNeedsLayout();
}
int get maxChatBubbleWidthPercentage => _maxChatBubbleWidthPercentage;
set maxChatBubbleWidthPercentage(int value) {
if (_maxChatBubbleWidthPercentage == value) return;
_maxChatBubbleWidthPercentage = value;
markNeedsLayout();
}
@override
void performLayout() {
// Layout child and calculate size
size = _performLayout(
constraints: constraints,
dry: false,
);
// Position child
final BoxParentData childParentData = child!.parentData as BoxParentData;
childParentData.offset = Offset(
size.width - child!.size.width, textPainter.height - _lastLineHeight);
}
@override
Size computeDryLayout(BoxConstraints constraints) {
return _performLayout(constraints: constraints, dry: true);
}
Size _performLayout({
required BoxConstraints constraints,
required bool dry,
}) {
final BoxConstraints constraints =
this.constraints * (_maxChatBubbleWidthPercentage / 100);
textPainter.layout(minWidth: 0, maxWidth: constraints.maxWidth);
double height = textPainter.height;
double width = textPainter.width;
// Compute the LineMetrics of our textPainter
final List<ui.LineMetrics> lines = textPainter.computeLineMetrics();
// We are only interested in the last line's width
final lastLineWidth = lines.last.width;
_lastLineHeight = lines.last.height;
// Layout child and assign size of RenderBox
if (child != null) {
late final Size childSize;
if (!dry) {
child!.layout(BoxConstraints(maxWidth: constraints.maxWidth),
parentUsesSize: true);
childSize = child!.size;
} else {
childSize =
child!.getDryLayout(BoxConstraints(maxWidth: constraints.maxWidth));
}
final horizontalSpaceExceeded =
lastLineWidth + childSize.width > constraints.maxWidth;
if (horizontalSpaceExceeded) {
height += childSize.height;
_lastLineHeight = 0;
} else {
height += childSize.height - _lastLineHeight;
}
if (lines.length == 1 && !horizontalSpaceExceeded) {
width += childSize.width;
}
}
return Size(width, height);
}
@override
void paint(PaintingContext context, Offset offset) {
// Paint the chat message
textPainter.paint(context.canvas, offset);
if (child != null) {
final parentData = child!.parentData as BoxParentData;
// Paint the child (i.e. the row with the messageTime and Icon)
context.paintChild(child!, offset + parentData.offset);
}
}
}
class ExampleChatBubbles extends StatelessWidget {
// Some chat dummy data
final chatData = [
[
'Hi',
Alignment.centerRight,
DateTime.now().add(const Duration(minutes: -100)),
],
[
'Helloooo?',
Alignment.centerRight,
DateTime.now().add(const Duration(minutes: -60)),
],
[
'Hi James',
Alignment.centerLeft,
DateTime.now().add(const Duration(minutes: -58)),
],
[
'Do you want to watch the basketball game tonight? We could order some chinese food :)',
Alignment.centerRight,
DateTime.now().add(const Duration(minutes: -57)),
],
[
'Sounds great! Let us meet at 7 PM, okay?',
Alignment.centerLeft,
DateTime.now().add(const Duration(minutes: -57)),
],
[
'See you later!',
Alignment.centerRight,
DateTime.now().add(const Duration(minutes: -55)),
],
];
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: ListView.builder(
itemCount: chatData.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 5,
),
child: ChatBubble(
icon: Icon(
Icons.check,
size: 15,
color: Colors.grey.shade700,
),
alignment: chatData[index][1] as Alignment,
message: chatData[index][0] as String,
messageTime: chatData[index][2] as DateTime,
// How much of the available width may be consumed by the ChatBubble
maxChatBubbleWidthPercentage: 75,
),
);
},
),
);
}
}
@hnnngwdlch 感谢您的回答,它对我有帮助,您可以完全控制画家。我出于我的目的稍微修改了您的代码,也许它对某些人有用。
PD:我不知道在RenderObject 中声明TextPainter 是否有明显的性能缺点,如果有人知道请写在评论中。
class TextMessageWidget extends SingleChildRenderObjectWidget {
final String text;
final TextStyle? textStyle;
final double? spacing;
const TextMessageWidget({
Key? key,
required this.text,
this.textStyle,
this.spacing,
required Widget child,
}) : super(key: key, child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderTextMessageWidget(text, textStyle, spacing);
}
@override
void updateRenderObject(BuildContext context, RenderTextMessageWidget renderObject) {
renderObject
..text = text
..textStyle = textStyle
..spacing = spacing;
}
}
class RenderTextMessageWidget extends RenderBox with RenderObjectWithChildMixin<RenderBox> {
String _text;
TextStyle? _textStyle;
double? _spacing;
// With this constants you can modify the final result
static const double _kOffset = 1.5;
static const double _kFactor = 0.8;
RenderTextMessageWidget(
String text,
TextStyle? textStyle,
double? spacing
) : _text = text, _textStyle = textStyle, _spacing = spacing;
String get text => _text;
set text(String value) {
if (_text == value) return;
_text = value;
markNeedsLayout();
}
TextStyle? get textStyle => _textStyle;
set textStyle(TextStyle? value) {
if (_textStyle == value) return;
_textStyle = value;
markNeedsLayout();
}
double? get spacing => _spacing;
set spacing(double? value) {
if (_spacing == value) return;
_spacing = value;
markNeedsLayout();
}
TextPainter textPainter = TextPainter();
@override
void performLayout() {
size = _performLayout(constraints: constraints, dry: false);
final BoxParentData childParentData = child!.parentData as BoxParentData;
childParentData.offset = Offset(
size.width - child!.size.width,
size.height - child!.size.height / _kOffset
);
}
@override
Size computeDryLayout(BoxConstraints constraints) {
return _performLayout(constraints: constraints, dry: true);
}
Size _performLayout({required BoxConstraints constraints, required bool dry}) {
textPainter = TextPainter(
text: TextSpan(text: _text, style: _textStyle),
textDirection: TextDirection.ltr
);
late final double spacing;
if(_spacing == null){
spacing = constraints.maxWidth * 0.03;
} else {
spacing = _spacing!;
}
textPainter.layout(minWidth: 0, maxWidth: constraints.maxWidth);
double height = textPainter.height;
double width = textPainter.width;
// Compute the LineMetrics of our textPainter
final List<LineMetrics> lines = textPainter.computeLineMetrics();
// We are only interested in the last line's width
final lastLineWidth = lines.last.width;
if(child != null){
late final Size childSize;
if (!dry) {
child!.layout(BoxConstraints(maxWidth: constraints.maxWidth), parentUsesSize: true);
childSize = child!.size;
} else {
childSize = child!.getDryLayout(BoxConstraints(maxWidth: constraints.maxWidth));
}
if(lastLineWidth + spacing > constraints.maxWidth - child!.size.width) {
height += (childSize.height * _kFactor);
} else if(lines.length == 1){
width += childSize.width + spacing;
}
}
return Size(width, height);
}
@override
void paint(PaintingContext context, Offset offset) {
textPainter.paint(context.canvas, offset);
final parentData = child!.parentData as BoxParentData;
context.paintChild(child!, offset + parentData.offset);
}
}