简体   繁体   中英

Flutter chat text align like Whatsapp or Telegram

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. 在此处输入图像描述

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?

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,
            ),
          );
        },
      ),
    );
  }
}

@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);
  }
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM