-
-
Save craiglabenz/c6fc52e3e61f66c51f7a858115bfce51 to your computer and use it in GitHub Desktop.
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); | |
} | |
} |
Thank you
That's what I get for always using the master
channel, @Sun3. Thanks for pointing that out!
Great example, I love it.
Hey @craiglabenz, TextPainter
needs to be disposed right?
The ListenableBuilder
is now also available in beta channel, in the latest one 3.10.0-1.1.pre
, which means it will land in next stable release, be it called 3.10 or 4.0. 😄 💙
API docs for ListenableBuilder
can be found already now in master-api channel docs here:
https://master-api.flutter.dev/flutter/widgets/ListenableBuilder-class.html
If you are using Flutter 3.7.x stable you can replace ListenableBuilder
with AnimatedBuilder
, works the same.
Here is the back story to ListenableBuilder
flutter/flutter#19255 and the PR that brought the alias flutter/flutter#116543
Wow, it is wonderful !!
Yep, that's what I use in my projects, the AnimatedBuilder, but I have been waiting for so long for the ListenableBuilder. Yep, the same functionality but a better name.
If you want to paint an arrow in a corner like Whatsapp bubbles, is this the way to go, or a RenderBox just paint the box (the size)?
@craiglabenz or any one can help me.... In my design, I have a FloatingWidget
floating above some widgets that are behind it in the stack (SameHeightWidget
), and these widgets need to have the same size as the FloatingWidget. Do I need a RenderObject
for this? What would be the general idea to follow? I haven't found a way to calculate the size of the FloatingWidget
before it's painted and then assign that height to the SameHeightWidget
. I would really appreciate any help as I'm stuck on my project.
Thanks for a great show and your effort, but could you tell the manager of those episodes to add Arabic translation that will help us
And Thanks again 🙂
Thanks
Awesome job... just a note that ListenableBuilder has not been released yet on the stable channel, but just the master channel. Thank you for the example.
@Sun3 try ValueListenableBuilder
final _controller = TextEditingController();
ValueListenableBuilder(
valueListenable: _controller,
builder: (context, val, widget) {
return Text(val.text);
}
)
@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...
@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).
Excellent, thanks for sharing this.
Just added a few revisions, included missing guard statements on several magic setters and improved consistency for TextSpan generation.
Great!
cool one!
@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?
@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,
);
@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;
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
Good catch, @rafalbednarczuk. Updated!
@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
Awesome job... just a note that ListenableBuilder has not been released yet on the stable channel, but just the master channel.
Thank you for the example.