skip to Main Content

I’m having trouble figuring this alignment with Flutter.

The right conversion on Whatsapp or Telegram is left-aligned but the date is on the right. If there’s space available for the date it is at the end of the same line.enter image description here

The 1st and 3rd chat lines can be done with Wrap() widget. But the 2nd line is not possible with Wrap() since the chat text is a separate Widget and fills the full width and doesn’t allow the date widget to fit. How would you do this with Flutter?

2

Answers


  1. 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.

    enter image description here

    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,
                ),
              );
            },
          ),
        );
      }
    }
    
    Login or Signup to reply.
  2. @hnnngwdlch thanks for your answer it helped me, with this you have full control over the painter. I slightly modified your code for my purposes maybe it will be useful for someone.

    PD: I don’t know if declaring the TextPainter inside the RenderObject has significant performance disadvantages, if someone knows please write in the comments.

    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);
      }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search