Here's an example that you can run in DartPad that might be enough to get you started. It uses a SingleChildRenderObjectWidget
for laying out the child and painting the ChatBubble
's chat message as well as the message time and a dummy check mark icon.
To learn more about the RenderObject
class I can recommend this video. It describes all relevant classes and methods in great depth and helped me a lot to create my first custom 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,
),
);
},
),
);
}
}