Flutter 聊天文本像 Whatsapp 或 Telegram 一样对齐

Flutter chat text align like Whatsapp or Telegram

我无法确定这种与 Flutter 的一致性。

Whatsapp 或 Telegram 上的右转换是左对齐,但日期在右。如果日期有 space,则它位于同一行的末尾。

第一条和第三条聊天线可以用 Wrap() 小部件完成。但是第 2 行不可能使用 Wrap(),因为聊天文本是一个单独的小部件并填充整个宽度并且不允许日期小部件适合。你会如何使用 Flutter 做到这一点?

这里有一个您可以在 DartPad 中 运行 的示例,它可能足以让您入门。它使用 SingleChildRenderObjectWidget 布局 child 并绘制 ChatBubble 的聊天消息以及消息时间和虚拟复选标记图标。

要了解有关 RenderObject class 的更多信息,我可以推荐这个 video。它非常深入地描述了所有相关的 classes 和方法,并帮助我创建了我的第一个自定义 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 感谢您的回答,它对我有帮助,您可以完全控制画家。我出于我的目的稍微修改了您的代码,也许它对某些人有用。

PD:我不知道在RenderObject 中声明TextPainter 是否有明显的性能缺点,如果有人知道请写在评论中。

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