Skip to content

Instantly share code, notes, and snippets.

@craiglabenz
Last active December 16, 2024 15:56
Show Gist options
  • Save craiglabenz/c6fc52e3e61f66c51f7a858115bfce51 to your computer and use it in GitHub Desktop.
Save craiglabenz/c6fc52e3e61f66c51f7a858115bfce51 to your computer and use it in GitHub Desktop.
Demonstrates a custom RenderObject that draws chat messages like WhatsApp, where the `sentAt` timestamp is tucked into the last line if it fits
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Chat message RenderBox',
home: ChatAppConversationView(),
);
}
}
/// A full-screen widget which vaguely resembles a chat app's conversation view.
///
/// The main purpose of the [ChatAppConversationView] class is to feed the value
/// from a [TextEditingController] into a [TimestampedChatMessage] widget. The
/// [TimestampedChatMessage] is the real star of the show.
class ChatAppConversationView extends StatefulWidget {
const ChatAppConversationView({super.key});
@override
State<ChatAppConversationView> createState() =>
_ChatAppConversationViewState();
}
class _ChatAppConversationViewState extends State<ChatAppConversationView> {
final TextEditingController _controller = TextEditingController();
final String sentAt = '3 seconds ago';
@override
void initState() {
super.initState();
_controller.text =
'Hello?! this is a message! If you read it for long enough, '
'your brain will grow';
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SizedBox(
width: 220,
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
(_controller.text != '')
? Align(
alignment: Alignment.centerRight,
child: Container(
color: Colors.blue[100]!,
padding: const EdgeInsets.all(15),
// ListenableBuilder is available in Flutter 3.10
child: ListenableBuilder(
listenable: _controller,
builder: (context, widget) {
return TimestampedChatMessage(
text: _controller.text,
sentAt: sentAt,
style: const TextStyle(color: Colors.red),
);
},
),
),
)
: Container(),
const SizedBox(height: 50),
Padding(
padding: const EdgeInsets.symmetric(vertical: 25),
child: TextField(
controller: _controller,
),
),
],
),
),
),
);
}
}
/// Simplified variant of the [Text] widget which accepts both a primary string
/// for the raw text body, and a secondary `sentAt` string which annotates the
/// former with a timestamp, similar to how popular chat apps like WhatsApp
/// render individual messages.
///
/// The [TimestampedChatMessage] extends [LeafRenderObjectWidget], which means
/// it has no children and instead creates a [TimestampedChatMessageRenderObject],
/// which handles all layout and painting itself.
class TimestampedChatMessage extends LeafRenderObjectWidget {
const TimestampedChatMessage({
super.key,
required this.text,
required this.sentAt,
this.style,
});
final String text;
final String sentAt;
final TextStyle? style;
@override
RenderObject createRenderObject(BuildContext context) {
final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context);
TextStyle? effectiveTextStyle = style;
if (style == null || style!.inherit) {
effectiveTextStyle = defaultTextStyle.style.merge(style);
}
return TimestampedChatMessageRenderObject(
text: text,
sentAt: sentAt,
textDirection: Directionality.of(context),
textStyle: effectiveTextStyle!,
sentAtStyle: effectiveTextStyle.copyWith(color: Colors.grey),
);
}
@override
void updateRenderObject(
BuildContext context,
TimestampedChatMessageRenderObject renderObject,
) {
final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context);
TextStyle? effectiveTextStyle = style;
if (style == null || style!.inherit) {
effectiveTextStyle = defaultTextStyle.style.merge(style);
}
renderObject.text = text;
renderObject.textStyle = effectiveTextStyle!;
renderObject.sentAt = sentAt;
renderObject.sentAtStyle = effectiveTextStyle.copyWith(color: Colors.grey);
renderObject.textDirection = Directionality.of(context);
}
}
/// Simplified variant of [RenderParagraph] which supports the
/// [TimestampedChatMessage] widget.
///
/// Like the [Text] widget and its inner [RenderParagraph], the
/// [TimestampedChatMessageRenderObject] makes heavy use of the [TextPainter]
/// class.
class TimestampedChatMessageRenderObject extends RenderBox {
TimestampedChatMessageRenderObject({
required String sentAt,
required String text,
required TextStyle sentAtStyle,
required TextStyle textStyle,
required TextDirection textDirection,
}) {
_text = text;
_sentAt = sentAt;
_textStyle = textStyle;
_sentAtStyle = sentAtStyle;
_textDirection = textDirection;
_textPainter = TextPainter(
text: textTextSpan,
textDirection: _textDirection,
);
_sentAtTextPainter = TextPainter(
text: sentAtTextSpan,
textDirection: _textDirection,
);
}
late TextDirection _textDirection;
late String _text;
late String _sentAt;
late TextPainter _textPainter;
late TextPainter _sentAtTextPainter;
late TextStyle _sentAtStyle;
late TextStyle _textStyle;
late bool _sentAtFitsOnLastLine;
late double _lineHeight;
late double _lastMessageLineWidth;
double _longestLineWidth = 0;
late double _sentAtLineWidth;
late int _numMessageLines;
set sentAt(String val) {
if (val == _sentAt) return;
_sentAt = val;
// `sentAtTextSpan` is a computed property that incorporates both the raw
// string value and the [TextStyle], so we have to update the whole [TextSpan]
// any time either value is updated.
_sentAtTextPainter.text = sentAtTextSpan;
markNeedsLayout();
// Because changing any text in our widget will definitely change the
// semantic meaning of this piece of our UI, we need to call
markNeedsSemanticsUpdate();
}
set sentAtStyle(TextStyle val) {
if (val == _sentAtStyle) return;
_sentAtStyle = val;
// `sentAtTextSpan` is a computed property that incorporates both the raw
// string value and the [TextStyle], so we have to update the whole [TextSpan]
// any time either value is updated.
_sentAtTextPainter.text = sentAtTextSpan;
markNeedsLayout();
}
String get text => _text;
set text(String val) {
if (val == _text) return;
_text = val;
_textPainter.text = textTextSpan;
markNeedsLayout();
// Because changing any text in our widget will definitely change the
// semantic meaning of this piece of our UI, we need to call
markNeedsSemanticsUpdate();
}
TextStyle get textStyle => _textStyle;
set textStyle(TextStyle val) {
if (val == _textStyle) return;
_textStyle = val;
_textPainter.text = textTextSpan;
// If we knew that the new [TextStyle] had only changed in certain ways (e.g.
// color) then we could be more performant and call `markNeedsPaint()` instead.
// However, without carefully making that assessment, it is safer to call
// the more generic method, `markNeedsLayout()` instead (which also implies
// a repaint).
markNeedsLayout();
}
set textDirection(TextDirection val) {
if (_textDirection == val) {
return;
}
_textDirection = val;
_textPainter.textDirection = val;
_sentAtTextPainter.textDirection = val;
markNeedsSemanticsUpdate();
markNeedsLayout();
}
TextSpan get textTextSpan => TextSpan(text: _text, style: _textStyle);
TextSpan get sentAtTextSpan => TextSpan(text: _sentAt, style: _sentAtStyle);
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
// Set this to `true` because individual chat bubbles are perfectly
// self-contained semantic objects.
config.isSemanticBoundary = true;
config.label = '$_text, sent $_sentAt';
config.textDirection = _textDirection;
}
@override
double computeMinIntrinsicWidth(double height) {
// Ignore `height` parameter because chat bubbles' height grows as a
// function of available width and text length.
_layoutText(double.infinity);
return _longestLineWidth;
}
@override
double computeMinIntrinsicHeight(double width) =>
computeMaxIntrinsicHeight(width);
@override
double computeMaxIntrinsicHeight(double width) {
final computedSize = _layoutText(width);
return computedSize.height;
}
@override
void performLayout() {
final unconstrainedSize = _layoutText(constraints.maxWidth);
size = constraints.constrain(
Size(unconstrainedSize.width, unconstrainedSize.height),
);
}
/// Lays out the text within a given width constraint and returns its [Size].
///
/// Because [_layoutText] is called from multiple places with multiple concerns,
/// like intrinsics which could have different width parameters than a typical
/// layout, this logic is moved out of `performLayout` and into a dedicated
/// method which accepts and works with any width constraint.
Size _layoutText(double maxWidth) {
// Draw nothing (requiring no size) if the string doesn't exist. This is one
// of many opinionated choices we could make here if the text is empty.
if (_textPainter.text?.toPlainText() == '') {
return Size.zero;
}
assert(
maxWidth > 0,
'You must allocate SOME space to layout a TimestampedChatMessageRenderObject. Received a '
'`maxWidth` value of $maxWidth.',
);
// Layout the raw message, which saves expected high-level sizing values
// to the painter itself.
_textPainter.layout(maxWidth: maxWidth);
final textLines = _textPainter.computeLineMetrics();
// Now make similar calculations for `sentAt`.
_sentAtTextPainter.layout(maxWidth: maxWidth);
_sentAtLineWidth = _sentAtTextPainter.computeLineMetrics().first.width;
// Reset cached values from the last frame if they're assumed to start at 0.
// (Because this is used in `max`, if it opens a new frame still holding the
// value from a previous frame, we could fail to accurately calculate the
// longest line.)
_longestLineWidth = 0;
// Next, we calculate a few metrics for the height and width of the message.
// First, chat messages don't actually grow to their full available width
// if their longest line does not require it. Thus, we need to note the
// longest line in the message.
for (final line in textLines) {
_longestLineWidth = max(_longestLineWidth, line.width);
}
// If the message is very short, it's possible that the longest line is
// is actually the date.
_longestLineWidth = max(_longestLineWidth, _sentAtTextPainter.width);
// Because [_textPainter.width] can be the maximum width we passed to it,
// even if the longest line is shorter, we use this logic to determine its
// real size, for our purposes.
final sizeOfMessage = Size(_longestLineWidth, _textPainter.height);
// Cache additional variables used both in the rest of this method and in
// `paint` later on.
_lastMessageLineWidth = textLines.last.width;
_lineHeight = textLines.last.height;
_numMessageLines = textLines.length;
// Determine whether the message's last line and the date can share a
// horizontal row together.
final lastLineWithDate = _lastMessageLineWidth + (_sentAtLineWidth * 1.08);
if (textLines.length == 1) {
_sentAtFitsOnLastLine = lastLineWithDate < maxWidth;
} else {
_sentAtFitsOnLastLine =
lastLineWithDate < min(_longestLineWidth, maxWidth);
}
late Size computedSize;
if (!_sentAtFitsOnLastLine) {
computedSize = Size(
// If `sentAt` does not fit on the longest line, then we know the
// message contains a long line, making this a safe value for `width`.
sizeOfMessage.width,
// And similarly, if `sentAt` does not fit, we know to add its height
// to the overall size of just-the-message.
sizeOfMessage.height + _sentAtTextPainter.height,
);
} else {
// Moving forward, of course, we know that `sentAt` DOES fit into the last
// line.
if (textLines.length == 1) {
computedSize = Size(
// When there is only 1 line, our width calculations are in a special
// case of needing as many pixels as our line plus the date, as opposed
// to the full size of the longest line.
lastLineWithDate,
sizeOfMessage.height,
);
} else {
computedSize = Size(
// But when there's more than 1 line, our width should be equal to the
// longest line.
_longestLineWidth,
sizeOfMessage.height,
);
}
}
return computedSize;
}
@override
void paint(PaintingContext context, Offset offset) {
// Draw nothing (requiring no paint calls) if the string doesn't exist. This
// is one of many opinionated choices we could make here if the text is empty.
if (_textPainter.text?.toPlainText() == '') {
return;
}
// This line writes the actual message to the screen. Because we use the
// same offset we were passed, the text will appear in the upper-left corner
// of our available space.
_textPainter.paint(context.canvas, offset);
late Offset sentAtOffset;
if (_sentAtFitsOnLastLine) {
sentAtOffset = Offset(
offset.dx + (size.width - _sentAtLineWidth),
offset.dy + (_lineHeight * (_numMessageLines - 1)),
);
} else {
sentAtOffset = Offset(
offset.dx + (size.width - _sentAtLineWidth),
offset.dy + _lineHeight * _numMessageLines,
);
}
// Finally, place the `sentAt` value accordingly.
_sentAtTextPainter.paint(context.canvas, sentAtOffset);
}
}
@Sun3
Copy link

Sun3 commented Apr 21, 2023

@VIPER-VLAD I appreciate it and yep, I use ValueListenableBuilder in my projects, works great. I am just looking forward to getting the ListenableBuilder, since I currently use the AnimatedBuilder and ChangeNotifier class.

Thanks...

@timmaffett
Copy link

@craiglabenz Love the new series.
It might be nice to include a dartpad.dev link to the gist example/with channel so that users can jump right in to playing with it.
ie: https://dartpad.dev/?channel=master&id=c6fc52e3e61f66c51f7a858115bfce51

(Here in the description and/or in the youtube description as well).

@hydev777
Copy link

Excellent, thanks for sharing this.

@craiglabenz
Copy link
Author

Just added a few revisions, included missing guard statements on several magic setters and improved consistency for TextSpan generation.

@Gavin0x0
Copy link

Great!

@LimaniBhavik
Copy link

cool one!

@Milad-Akarie
Copy link

Milad-Akarie commented Jun 6, 2023

@craiglabenz, Thanks for all the advanced videos.
What's the best way to optimize the painting of two different entities on the same canvas, one is static (container) and the other is dynamic ( depends on an animated value ).
is there a way to not paint the container on every frame since it doesn't change? or is painting so cheap that I don't have to worry about it?

@sthefanoss
Copy link

@craiglabenz hey thanks for your great example! I'm rendering some chat bubble pretty similar to yours, but its content may has urls with onTap gesture recognizers.

The hit test doesn't work. Is necessary any extra step in my case?

  TextSpan get textTextSpan => TextSpan(
        children: linkify(_text)
            .map((e) => e is LinkableElement //
                ? TextSpan(
                    text: e.url,
                    style: _urlTextStyle,
                    recognizer: TapGestureRecognizer()..onTap = () => _handleTap(e.url),
                  )
                : TextSpan(text: e.text))
            .toList(),
        style: _textStyle,
      );

@zombie6888
Copy link

@craiglabenz hey thanks for your great example! I'm rendering some chat bubble pretty similar to yours, but its content may has urls with onTap gesture recognizers.

The hit test doesn't work. Is necessary any extra step in my case?

  TextSpan get textTextSpan => TextSpan(
        children: linkify(_text)
            .map((e) => e is LinkableElement //
                ? TextSpan(
                    text: e.url,
                    style: _urlTextStyle,
                    recognizer: TapGestureRecognizer()..onTap = () => _handleTap(e.url),
                  )
                : TextSpan(text: e.text))
            .toList(),
        style: _textStyle,
      );

add this line:

@override
bool hitTestSelf(Offset position) => true;

@rafalbednarczuk
Copy link

rafalbednarczuk commented May 26, 2024

There is a bug with textDirection setter

set textDirection(TextDirection val) {
    if (_textDirection == val) {
      return;
    }
    _textPainter.textDirection = val;
    _sentAtTextPainter.textDirection = val;
    markNeedsSemanticsUpdate();
  }

Changing _textDirection field itself is missing

 _textDirection = val;

Also markNeedsLayout() is needed

@craiglabenz
Copy link
Author

Good catch, @rafalbednarczuk. Updated!

@kit-g
Copy link

kit-g commented Aug 11, 2024

@craiglabenz hey thanks for your great example! I'm rendering some chat bubble pretty similar to yours, but its content may has urls with onTap gesture recognizers.

The hit test doesn't work. Is necessary any extra step in my case?

  TextSpan get textTextSpan => TextSpan(
        children: linkify(_text)
            .map((e) => e is LinkableElement //
                ? TextSpan(
                    text: e.url,
                    style: _urlTextStyle,
                    recognizer: TapGestureRecognizer()..onTap = () => _handleTap(e.url),
                  )
                : TextSpan(text: e.text))
            .toList(),
        style: _textStyle,
      );

@sthefanoss here's how I solved it:

  @override
  bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
    final textPosition = _textPainter.getPositionForOffset(position);
    final span = _textPainter.text?.getSpanForPosition(textPosition);

    if (_textSpans[span] case LinkifyElement found) {
      onLink?.call(found);
    }

    return false;
  }

where _textSpans is defined as

List<LinkifyElement> get _linkElements {
    return linkify(
      _text,
      linkifiers: [
        ...defaultLinkifiers,
        const _HashtagLinkifier(),
      ],
    );
  }

  Map<InlineSpan, LinkifyElement> get _textSpans {
    return Map.fromEntries(
      _linkElements.map<MapEntry<InlineSpan, LinkifyElement>>(
        (element) {
          return MapEntry<InlineSpan, LinkifyElement>(
            TextSpan(
              text: element.text,
              style: switch (element) {
                LinkableElement() => _linkStyle,
                _ => _textStyle,
              },
            ),
            element,
          );
        },
      ),
    );
  }

onLink is my custom handler where I process taps depending on their type

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment