Replace text if it will overflow

2,442

Solution 1

I ended up going with a solution inspired by @Mantoska's answer.

import 'package:flutter/widgets.dart';

class OverflowProofText extends StatelessWidget {
  const OverflowProofText({@required this.text, @required this.fallback});

  final Text text;
  final Text fallback;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
        width: double.infinity,
        child: LayoutBuilder(builder: (BuildContext context, BoxConstraints size) {
          final TextPainter painter = TextPainter(
            maxLines: 1,
            textAlign: TextAlign.left,
            textDirection: TextDirection.ltr,
            text: TextSpan(
                style: text.style ?? DefaultTextStyle.of(context).style,
                text: text.data
            ),
          );

          painter.layout(maxWidth: size.maxWidth);

          return painter.didExceedMaxLines ? fallback : text;
        })
    );
  }
}

Usage:

OverflowProofText(
  text: Text('January 1, 2019'),
  fallback: Text('1/1/2019', overflow: TextOverflow.fade),
),

Solution 2

The current Text implementation doesn't allow for this kind of logic. You will need to override their implementation with custom overflow logic.

The modification is trivial, but bear in mind that in case of an overflow you're actually computing the text twice.

The modification needs to be done inside RenderParagraph's performLayout.

In short, something like that :

performLayout()
   layout();
   if (overflow) {
      layoutWithText(text);
   }
}

Which then requires a custom RichText to use your new RenderParagraph. And then a new Text class, to use your new RichText.

Quite a lot of copy paste. But fortunately I'll do it for you :D

Here's an example rendering the same Super long text twice. Once without enough size, the other with no limitation.

enter image description here

Achieved using the following code :

new Column(
  crossAxisAlignment: CrossAxisAlignment.center,
  mainAxisSize: MainAxisSize.min,
  children: <Widget>[
    new SizedBox(
      width: 70.0,
      child: new Card(
        child: new MyText(
          "Super long text",
          maxLines: 1,
          overflowBuilder: (size) {
            return new TextSpan(
                text: "Hello", style: new TextStyle(color: Colors.red));
          },
        ),
      ),
    ),
    new Card(
      child: new MyText(
        "Super long text",
        maxLines: 1,
        overflowBuilder: (size) {
          return new TextSpan(
              text: "Hello", style: new TextStyle(color: Colors.red));
        },
      ),
    ),
  ],
);

And here's the fully working example (with RenderParagraph changes and stuff)

import 'dart:async';
import 'package:flutter/rendering.dart';
import 'dart:ui' as ui show Gradient, Shader, TextBox;

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => new _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final scrollController = new ScrollController();
  final videoRef = Firestore.instance.collection('videos');

  @override
  void initState() {
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      body: new Center(
        child: new Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            new SizedBox(
              width: 70.0,
              child: new Card(
                child: new MyText(
                  "Super long text",
                  maxLines: 1,
                  overflowBuilder: (size) {
                    return new TextSpan(
                        text: "Hello", style: new TextStyle(color: Colors.red));
                  },
                ),
              ),
            ),
            new Card(
              child: new MyText(
                "Super long text",
                maxLines: 1,
                overflowBuilder: (size) {
                  return new TextSpan(
                      text: "Hello", style: new TextStyle(color: Colors.red));
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class OverflowText extends LeafRenderObjectWidget {
  final TextSpan textSpan;
  final TextAlign textAlign;
  final TextDirection textDirection;
  final bool softWrap;
  final TextOverflow overflow;
  final double textScaleFactor;
  final int maxLines;
  final TextOverflowBuilder overflowBuilder;

  OverflowText(
      {this.textSpan,
      this.textAlign: TextAlign.start,
      this.textDirection,
      this.softWrap: true,
      this.overflow: TextOverflow.clip,
      this.maxLines,
      this.overflowBuilder,
      this.textScaleFactor: 1.0});

  @override
  RenderObject createRenderObject(BuildContext context) {
    return new OverflowTextRenderObject(this.textSpan,
        textAlign: textAlign,
        textDirection: textDirection ?? Directionality.of(context),
        softWrap: softWrap,
        overflow: overflow,
        textScaleFactor: textScaleFactor,
        maxLines: maxLines,
        overflowBuilder: overflowBuilder);
  }

  @override
  void updateRenderObject(
      BuildContext context, OverflowTextRenderObject renderObject) {
    renderObject
      ..text = textSpan
      ..textAlign = textAlign
      ..textDirection = textDirection ?? Directionality.of(context)
      ..softWrap = softWrap
      ..overflow = overflow
      ..textScaleFactor = textScaleFactor
      ..overflowBuilder = overflowBuilder
      ..maxLines = maxLines;
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(new StringProperty('textSpan', textSpan.toPlainText()));
  }
}

typedef TextSpan TextOverflowBuilder(Size size);

const String _kEllipsis = '\u2026';

/// A render object that displays a paragraph of text
class OverflowTextRenderObject extends RenderBox {
  /// Creates a paragraph render object.
  ///
  /// The [text], [textAlign], [textDirection], [overflow], [softWrap], and
  /// [textScaleFactor] arguments must not be null.
  ///
  /// The [maxLines] property may be null (and indeed defaults to null), but if
  /// it is not null, it must be greater than zero.
  OverflowTextRenderObject(
    TextSpan text, {
    TextAlign textAlign: TextAlign.start,
    @required TextDirection textDirection,
    bool softWrap: true,
    TextOverflow overflow: TextOverflow.clip,
    double textScaleFactor: 1.0,
    int maxLines,
    this.overflowBuilder,
  })  : assert(text != null),
        assert(text.debugAssertIsValid()),
        assert(textAlign != null),
        assert(textDirection != null),
        assert(softWrap != null),
        assert(overflow != null),
        assert(textScaleFactor != null),
        assert(maxLines == null || maxLines > 0),
        _softWrap = softWrap,
        _overflow = overflow,
        _textPainter = new TextPainter(
          text: text,
          textAlign: textAlign,
          textDirection: textDirection,
          textScaleFactor: textScaleFactor,
          maxLines: maxLines,
          ellipsis: overflow == TextOverflow.ellipsis ? _kEllipsis : null,
        );

  TextOverflowBuilder overflowBuilder;

  final TextPainter _textPainter;

  /// The text to display
  TextSpan get text => _textPainter.text;
  set text(TextSpan value) {
    assert(value != null);
    switch (_textPainter.text.compareTo(value)) {
      case RenderComparison.identical:
      case RenderComparison.metadata:
        return;
      case RenderComparison.paint:
        _textPainter.text = value;
        markNeedsPaint();
        break;
      case RenderComparison.layout:
        _textPainter.text = value;
        _overflowShader = null;
        markNeedsLayout();
        break;
    }
  }

  /// How the text should be aligned horizontally.
  TextAlign get textAlign => _textPainter.textAlign;
  set textAlign(TextAlign value) {
    assert(value != null);
    if (_textPainter.textAlign == value) return;
    _textPainter.textAlign = value;
    markNeedsPaint();
  }

  /// The directionality of the text.
  ///
  /// This decides how the [TextAlign.start], [TextAlign.end], and
  /// [TextAlign.justify] values of [textAlign] are interpreted.
  ///
  /// This is also used to disambiguate how to render bidirectional text. For
  /// example, if the [text] is an English phrase followed by a Hebrew phrase,
  /// in a [TextDirection.ltr] context the English phrase will be on the left
  /// and the Hebrew phrase to its right, while in a [TextDirection.rtl]
  /// context, the English phrase will be on the right and the Hebrew phrase on
  /// its left.
  ///
  /// This must not be null.
  TextDirection get textDirection => _textPainter.textDirection;
  set textDirection(TextDirection value) {
    assert(value != null);
    if (_textPainter.textDirection == value) return;
    _textPainter.textDirection = value;
    markNeedsLayout();
  }

  /// Whether the text should break at soft line breaks.
  ///
  /// If false, the glyphs in the text will be positioned as if there was
  /// unlimited horizontal space.
  ///
  /// If [softWrap] is false, [overflow] and [textAlign] may have unexpected
  /// effects.
  bool get softWrap => _softWrap;
  bool _softWrap;
  set softWrap(bool value) {
    assert(value != null);
    if (_softWrap == value) return;
    _softWrap = value;
    markNeedsLayout();
  }

  /// How visual overflow should be handled.
  TextOverflow get overflow => _overflow;
  TextOverflow _overflow;
  set overflow(TextOverflow value) {
    assert(value != null);
    if (_overflow == value) return;
    _overflow = value;
    _textPainter.ellipsis = value == TextOverflow.ellipsis ? _kEllipsis : null;
    markNeedsLayout();
  }

  /// The number of font pixels for each logical pixel.
  ///
  /// For example, if the text scale factor is 1.5, text will be 50% larger than
  /// the specified font size.
  double get textScaleFactor => _textPainter.textScaleFactor;
  set textScaleFactor(double value) {
    assert(value != null);
    if (_textPainter.textScaleFactor == value) return;
    _textPainter.textScaleFactor = value;
    _overflowShader = null;
    markNeedsLayout();
  }

  /// An optional maximum number of lines for the text to span, wrapping if necessary.
  /// If the text exceeds the given number of lines, it will be truncated according
  /// to [overflow] and [softWrap].
  int get maxLines => _textPainter.maxLines;

  /// The value may be null. If it is not null, then it must be greater than zero.
  set maxLines(int value) {
    assert(value == null || value > 0);
    if (_textPainter.maxLines == value) return;
    _textPainter.maxLines = value;
    _overflowShader = null;
    markNeedsLayout();
  }

  void _layoutText({double minWidth: 0.0, double maxWidth: double.infinity}) {
    final bool widthMatters = softWrap || overflow == TextOverflow.ellipsis;
    _textPainter.layout(
        minWidth: minWidth,
        maxWidth: widthMatters ? maxWidth : double.infinity);
  }

  void _layoutTextWithConstraints(BoxConstraints constraints) {
    _layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
  }

  @override
  double computeMinIntrinsicWidth(double height) {
    _layoutText();
    return _textPainter.minIntrinsicWidth;
  }

  @override
  double computeMaxIntrinsicWidth(double height) {
    _layoutText();
    return _textPainter.maxIntrinsicWidth;
  }

  double _computeIntrinsicHeight(double width) {
    _layoutText(minWidth: width, maxWidth: width);
    return _textPainter.height;
  }

  @override
  double computeMinIntrinsicHeight(double width) {
    return _computeIntrinsicHeight(width);
  }

  @override
  double computeMaxIntrinsicHeight(double width) {
    return _computeIntrinsicHeight(width);
  }

  @override
  double computeDistanceToActualBaseline(TextBaseline baseline) {
    assert(!debugNeedsLayout);
    assert(constraints != null);
    assert(constraints.debugAssertIsValid());
    _layoutTextWithConstraints(constraints);
    return _textPainter.computeDistanceToActualBaseline(baseline);
  }

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

  @override
  void handleEvent(PointerEvent event, BoxHitTestEntry entry) {
    assert(debugHandleEvent(event, entry));
    if (event is! PointerDownEvent) return;
    _layoutTextWithConstraints(constraints);
    final Offset offset = entry.localPosition;
    final TextPosition position = _textPainter.getPositionForOffset(offset);
    final TextSpan span = _textPainter.text.getSpanForPosition(position);
    span?.recognizer?.addPointer(event);
  }

  bool _hasVisualOverflow = false;
  ui.Shader _overflowShader;

  @visibleForTesting
  bool get debugHasOverflowShader => _overflowShader != null;

  void _performLayout() {
    _layoutTextWithConstraints(constraints);

    final Size textSize = _textPainter.size;
    final bool didOverflowHeight = _textPainter.didExceedMaxLines;
    size = constraints.constrain(textSize);

    final bool didOverflowWidth = size.width < textSize.width;

    _hasVisualOverflow = didOverflowWidth || didOverflowHeight;
    if (_hasVisualOverflow) {
      switch (_overflow) {
        case TextOverflow.clip:
        case TextOverflow.ellipsis:
          _overflowShader = null;
          break;
        case TextOverflow.fade:
          assert(textDirection != null);
          final TextPainter fadeSizePainter = new TextPainter(
            text: new TextSpan(style: _textPainter.text.style, text: '\u2026'),
            textDirection: textDirection,
            textScaleFactor: textScaleFactor,
          )..layout();
          if (didOverflowWidth) {
            double fadeEnd, fadeStart;
            switch (textDirection) {
              case TextDirection.rtl:
                fadeEnd = 0.0;
                fadeStart = fadeSizePainter.width;
                break;
              case TextDirection.ltr:
                fadeEnd = size.width;
                fadeStart = fadeEnd - fadeSizePainter.width;
                break;
            }
            _overflowShader = new ui.Gradient.linear(
              new Offset(fadeStart, 0.0),
              new Offset(fadeEnd, 0.0),
              <Color>[const Color(0xFFFFFFFF), const Color(0x00FFFFFF)],
            );
          } else {
            final double fadeEnd = size.height;
            final double fadeStart = fadeEnd - fadeSizePainter.height / 2.0;
            _overflowShader = new ui.Gradient.linear(
              new Offset(0.0, fadeStart),
              new Offset(0.0, fadeEnd),
              <Color>[const Color(0xFFFFFFFF), const Color(0x00FFFFFF)],
            );
          }
          break;
      }
    } else {
      _overflowShader = null;
    }
  }

  @override
  performLayout() {
    _performLayout();
    if (this._hasVisualOverflow && overflowBuilder != null) {
      final replacement = overflowBuilder(size);
      _textPainter.text = replacement;
      _performLayout();
    }
  }

  @override
  void paint(PaintingContext context, Offset offset) {

    _layoutTextWithConstraints(constraints);
    final Canvas canvas = context.canvas;

    assert(() {
      if (debugRepaintTextRainbowEnabled) {
        final Paint paint = new Paint()
          ..color = debugCurrentRepaintColor.toColor();
        canvas.drawRect(offset & size, paint);
      }
      return true;
    }());

    if (_hasVisualOverflow) {
      final Rect bounds = offset & size;
      if (_overflowShader != null) {

        canvas.saveLayer(bounds, new Paint());
      } else {
        canvas.save();
      }
      canvas.clipRect(bounds);
    }
    _textPainter.paint(canvas, offset);
    if (_hasVisualOverflow) {
      if (_overflowShader != null) {
        canvas.translate(offset.dx, offset.dy);
        final Paint paint = new Paint()
          ..blendMode = BlendMode.modulate
          ..shader = _overflowShader;
        canvas.drawRect(Offset.zero & size, paint);
      }
      canvas.restore();
    }
  }

  Offset getOffsetForCaret(TextPosition position, Rect caretPrototype) {
    assert(!debugNeedsLayout);
    _layoutTextWithConstraints(constraints);
    return _textPainter.getOffsetForCaret(position, caretPrototype);
  }


  List<ui.TextBox> getBoxesForSelection(TextSelection selection) {
    assert(!debugNeedsLayout);
    _layoutTextWithConstraints(constraints);
    return _textPainter.getBoxesForSelection(selection);
  }

  TextPosition getPositionForOffset(Offset offset) {
    assert(!debugNeedsLayout);
    _layoutTextWithConstraints(constraints);
    return _textPainter.getPositionForOffset(offset);
  }


  TextRange getWordBoundary(TextPosition position) {
    assert(!debugNeedsLayout);
    _layoutTextWithConstraints(constraints);
    return _textPainter.getWordBoundary(position);
  }


  Size get textSize {
    assert(!debugNeedsLayout);
    return _textPainter.size;
  }

  @override
  void describeSemanticsConfiguration(SemanticsConfiguration config) {
    super.describeSemanticsConfiguration(config);
    config
      ..label = text.toPlainText()
      ..textDirection = textDirection;
  }

  @override
  List<DiagnosticsNode> debugDescribeChildren() {
    return <DiagnosticsNode>[
      text.toDiagnosticsNode(
          name: 'text', style: DiagnosticsTreeStyle.transition)
    ];
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(new EnumProperty<TextAlign>('textAlign', textAlign));
    properties
        .add(new EnumProperty<TextDirection>('textDirection', textDirection));
    properties.add(new FlagProperty('softWrap',
        value: softWrap,
        ifTrue: 'wrapping at box width',
        ifFalse: 'no wrapping except at line break characters',
        showName: true));
    properties.add(new EnumProperty<TextOverflow>('overflow', overflow));
    properties.add(new DoubleProperty('textScaleFactor', textScaleFactor,
        defaultValue: 1.0));
    properties.add(new IntProperty('maxLines', maxLines, ifNull: 'unlimited'));
  }
}


class MyText extends StatelessWidget {
  const MyText(this.data,
      {Key key,
      this.style,
      this.textAlign,
      this.textDirection,
      this.softWrap,
      this.overflow,
      this.textScaleFactor,
      this.maxLines,
      this.overflowBuilder})
      : assert(data != null),
        textSpan = null,
        super(key: key);

  const MyText.rich(this.textSpan,
      {Key key,
      this.style,
      this.textAlign,
      this.textDirection,
      this.softWrap,
      this.overflow,
      this.textScaleFactor,
      this.maxLines,
      this.overflowBuilder})
      : assert(textSpan != null),
        data = null,
        super(key: key);

  final String data;
  final TextSpan textSpan;

  final TextStyle style;

  final TextAlign textAlign;


  final TextDirection textDirection;
  final bool softWrap;

  final TextOverflow overflow;

  final double textScaleFactor;

  final TextOverflowBuilder overflowBuilder;

  final int maxLines;

  @override
  Widget build(BuildContext context) {
    final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context);
    TextStyle effectiveTextStyle = style;
    if (style == null || style.inherit)
      effectiveTextStyle = defaultTextStyle.style.merge(style);
    return new OverflowText(
      textAlign: textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
      textDirection:
          textDirection, // RichText uses Directionality.of to obtain a default if this is null.
      softWrap: softWrap ?? defaultTextStyle.softWrap,
      overflow: overflow ?? defaultTextStyle.overflow,
      overflowBuilder: overflowBuilder,
      textScaleFactor: textScaleFactor ??
          MediaQuery.of(context, nullOk: true)?.textScaleFactor ??
          1.0,
      maxLines: maxLines ?? defaultTextStyle.maxLines,
      textSpan: new TextSpan(
        style: effectiveTextStyle,
        text: data,
        children: textSpan != null ? <TextSpan>[textSpan] : null,
      ),
    );
  }

}

Solution 3

Here is a solution that look simpler (or at least shorter) than Remi's.

The idea is that you use LayoutBuilder to wrap your widget, thus getting the BoxConstraints and using that you can use TextPainter to determine if the text would fit in the given BoxConstraints.

Here is a working example:

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Text Overflow Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        appBar: AppBar(title: Text("DEMO")),
        body: TextOverflowDemo(),
      ),
    );
  }
}

class TextOverflowDemo extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    int maxLines = 1;

    return Container(
      color: Colors.white,
      child: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 60.0),// set maxWidth to a low value to see the result
        child: LayoutBuilder(builder: (context, size) {
          String text = 'January 1, 2019';
          var exceeded = doesTextFit(text, maxLines, size);

          return Column(children: <Widget>[
            Text(
              exceeded ? '1/1/2019' : text,
              overflow: TextOverflow.ellipsis,
              maxLines: maxLines,
            ),
          ]);
        }),
      ),
    );
  }

  bool doesTextFit(String text, int maxLines, BoxConstraints size,
      {TextStyle textStyle}) {
    TextSpan span;
    if (textStyle == null) {
      span = TextSpan(
        text: text,
      );
    } else {
      span = TextSpan(text: text, style: textStyle);
    }

    TextPainter tp = TextPainter(
      maxLines: maxLines,
      textAlign: TextAlign.left,
      textDirection: TextDirection.ltr,
      text: span,
    );

    tp.layout(maxWidth: size.maxWidth);

    return tp.didExceedMaxLines;
  }
}
Share:
2,442
Westy92
Author by

Westy92

Updated on December 05, 2022

Comments

  • Westy92
    Westy92 over 1 year

    I'm wondering if there's a way in Flutter to show alternate text if the original text will overflow.

    Example:

    I'm by default showing a full date: January 1, 2019.

    However, if I'm on a small screen and it would overflow (January 1...), I'd like to instead display a different string (1/1/2019).