text_span.dart 11.7 KB
Newer Older
1 2 3 4 5 6 7
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:ui' as ui show ParagraphBuilder;

import 'package:flutter/foundation.dart';
8 9
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29

import 'basic_types.dart';
import 'text_style.dart';

/// An immutable span of text.
///
/// A [TextSpan] object can be styled using its [style] property.
/// The style will be applied to the [text] and the [children].
///
/// A [TextSpan] object can just have plain text, or it can have
/// children [TextSpan] objects with their own styles that (possibly
/// only partially) override the [style] of this object. If a
/// [TextSpan] has both [text] and [children], then the [text] is
/// treated as if it was an unstyled [TextSpan] at the start of the
/// [children] list.
///
/// To paint a [TextSpan] on a [Canvas], use a [TextPainter]. To display a text
/// span in a widget, use a [RichText]. For text with a single style, consider
/// using the [Text] widget.
///
30 31 32 33 34 35 36 37 38 39 40 41 42 43
/// ## Sample code
///
/// The text "Hello world!", in black:
///
/// ```dart
/// new TextSpan(
///   text: 'Hello world!',
///   style: new TextStyle(color: Colors.black),
/// )
/// ```
///
/// _There is some more detailed sample code in the documentation for the
/// [recognizer] property._
///
44 45
/// See also:
///
46 47 48
///  * [Text], a widget for showing uniformly-styled text.
///  * [RichText], a widget for finer control of text rendering.
///  * [TextPainter], a class for painting [TextSpan] objects on a [Canvas].
49
@immutable
50
class TextSpan extends DiagnosticableTree {
51 52 53 54 55 56 57 58
  /// Creates a [TextSpan] with the given values.
  ///
  /// For the object to be useful, at least one of [text] or
  /// [children] should be set.
  const TextSpan({
    this.style,
    this.text,
    this.children,
59
    this.recognizer,
60 61 62 63 64 65 66
  });

  /// The style to apply to the [text] and the [children].
  final TextStyle style;

  /// The text contained in the span.
  ///
67
  /// If both [text] and [children] are non-null, the text will precede the
68 69 70 71 72
  /// children.
  final String text;

  /// Additional spans to include as children.
  ///
73
  /// If both [text] and [children] are non-null, the text will precede the
74 75 76 77 78 79 80 81 82 83
  /// children.
  ///
  /// Modifying the list after the [TextSpan] has been created is not
  /// supported and may have unexpected results.
  ///
  /// The list must not contain any nulls.
  final List<TextSpan> children;

  /// A gesture recognizer that will receive events that hit this text span.
  ///
84 85 86 87
  /// [TextSpan] itself does not implement hit testing or event dispatch. The
  /// object that manages the [TextSpan] painting is also responsible for
  /// dispatching events. In the rendering library, that is the
  /// [RenderParagraph] object, which corresponds to the [RichText] widget in
88 89 90
  /// the widgets layer; these objects do not bubble events in [TextSpan]s, so a
  /// [recognizer] is only effective for events that directly hit the [text] of
  /// that [TextSpan], not any of its [children].
91 92 93 94 95 96 97 98
  ///
  /// [TextSpan] also does not manage the lifetime of the gesture recognizer.
  /// The code that owns the [GestureRecognizer] object must call
  /// [GestureRecognizer.dispose] when the [TextSpan] object is no longer used.
  ///
  /// ## Sample code
  ///
  /// This example shows how to manage the lifetime of a gesture recognizer
99
  /// provided to a [TextSpan] object. It defines a `BuzzingText` widget which
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124
  /// uses the [HapticFeedback] class to vibrate the device when the user
  /// long-presses the "find the" span, which is underlined in wavy green. The
  /// hit-testing is handled by the [RichText] widget.
  ///
  /// ```dart
  /// class BuzzingText extends StatefulWidget {
  ///   @override
  ///   _BuzzingTextState createState() => new _BuzzingTextState();
  /// }
  ///
  /// class _BuzzingTextState extends State<BuzzingText> {
  ///   LongPressGestureRecognizer _longPressRecognizer;
  ///
  ///   @override
  ///   void initState() {
  ///     super.initState();
  ///     _longPressRecognizer = new LongPressGestureRecognizer()
  ///       ..onLongPress = _handlePress;
  ///   }
  ///
  ///   @override
  ///   void dispose() {
  ///     _longPressRecognizer.dispose();
  ///     super.dispose();
  ///   }
125
  ///
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154
  ///   void _handlePress() {
  ///     HapticFeedback.vibrate();
  ///   }
  ///
  ///   @override
  ///   Widget build(BuildContext context) {
  ///     return new RichText(
  ///       text: new TextSpan(
  ///         text: 'Can you ',
  ///         style: new TextStyle(color: Colors.black),
  ///         children: <TextSpan>[
  ///           new TextSpan(
  ///             text: 'find the',
  ///             style: new TextStyle(
  ///               color: Colors.green,
  ///               decoration: TextDecoration.underline,
  ///               decorationStyle: TextDecorationStyle.wavy,
  ///             ),
  ///             recognizer: _longPressRecognizer,
  ///           ),
  ///           new TextSpan(
  ///             text: ' secret?',
  ///           ),
  ///         ],
  ///       ),
  ///     );
  ///   }
  /// }
  /// ```
155 156 157 158 159 160 161 162 163
  final GestureRecognizer recognizer;

  /// Apply the [style], [text], and [children] of this object to the
  /// given [ParagraphBuilder], from which a [Paragraph] can be obtained.
  /// [Paragraph] objects can be drawn on [Canvas] objects.
  ///
  /// Rather than using this directly, it's simpler to use the
  /// [TextPainter] class to paint [TextSpan] objects onto [Canvas]
  /// objects.
164
  void build(ui.ParagraphBuilder builder, { double textScaleFactor: 1.0 }) {
165
    assert(debugAssertIsValid());
166 167
    final bool hasStyle = style != null;
    if (hasStyle)
168
      builder.pushStyle(style.getTextStyle(textScaleFactor: textScaleFactor));
169 170 171 172 173
    if (text != null)
      builder.addText(text);
    if (children != null) {
      for (TextSpan child in children) {
        assert(child != null);
174
        child.build(builder, textScaleFactor: textScaleFactor);
175 176 177 178 179 180
      }
    }
    if (hasStyle)
      builder.pop();
  }

181
  /// Walks this text span and its descendants in pre-order and calls [visitor]
182
  /// for each span that has text.
183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198
  bool visitTextSpan(bool visitor(TextSpan span)) {
    if (text != null) {
      if (!visitor(this))
        return false;
    }
    if (children != null) {
      for (TextSpan child in children) {
        if (!child.visitTextSpan(visitor))
          return false;
      }
    }
    return true;
  }

  /// Returns the text span that contains the given position in the text.
  TextSpan getSpanForPosition(TextPosition position) {
199
    assert(debugAssertIsValid());
200 201
    final TextAffinity affinity = position.affinity;
    final int targetOffset = position.offset;
202 203 204 205
    int offset = 0;
    TextSpan result;
    visitTextSpan((TextSpan span) {
      assert(result == null);
206
      final int endOffset = offset + span.text.length;
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222
      if (targetOffset == offset && affinity == TextAffinity.downstream ||
          targetOffset > offset && targetOffset < endOffset ||
          targetOffset == endOffset && affinity == TextAffinity.upstream) {
        result = span;
        return false;
      }
      offset = endOffset;
      return true;
    });
    return result;
  }

  /// Flattens the [TextSpan] tree into a single string.
  ///
  /// Styles are not honored in this process.
  String toPlainText() {
223
    assert(debugAssertIsValid());
224
    final StringBuffer buffer = new StringBuffer();
225 226 227 228 229 230 231
    visitTextSpan((TextSpan span) {
      buffer.write(span.text);
      return true;
    });
    return buffer.toString();
  }

232
  /// Returns the UTF-16 code unit at the given index in the flattened string.
233
  ///
234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250
  /// Returns null if the index is out of bounds.
  int codeUnitAt(int index) {
    if (index < 0)
      return null;
    int offset = 0;
    int result;
    visitTextSpan((TextSpan span) {
      if (index - offset < span.text.length) {
        result = span.text.codeUnitAt(index - offset);
        return false;
      }
      offset += span.text.length;
      return true;
    });
    return result;
  }

251 252 253 254
  /// In checked mode, throws an exception if the object is not in a
  /// valid configuration. Otherwise, returns true.
  ///
  /// This is intended to be used as follows:
255
  ///
256
  /// ```dart
257
  /// assert(myTextSpan.debugAssertIsValid());
258
  /// ```
259
  bool debugAssertIsValid() {
260 261 262 263 264 265 266 267 268 269 270 271 272 273
    assert(() {
      if (!visitTextSpan((TextSpan span) {
        if (span.children != null) {
          for (TextSpan child in span.children) {
            if (child == null)
              return false;
          }
        }
        return true;
      })) {
        throw new FlutterError(
          'TextSpan contains a null child.\n'
          'A TextSpan object with a non-null child list should not have any nulls in its child list.\n'
          'The full text in question was:\n'
274
          '${toStringDeep(prefixLineOne: '  ')}'
275 276 277
        );
      }
      return true;
278
    }());
279 280 281
    return true;
  }

282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314
  /// Describe the difference between this text span and another, in terms of
  /// how much damage it will make to the rendering. The comparison is deep.
  ///
  /// See also:
  ///
  ///  * [TextStyle.compareTo], which does the same thing for [TextStyle]s.
  RenderComparison compareTo(TextSpan other) {
    if (identical(this, other))
      return RenderComparison.identical;
    if (other.text != text ||
        children?.length != other.children?.length ||
        (style == null) != (other.style == null))
      return RenderComparison.layout;
    RenderComparison result = recognizer == other.recognizer ? RenderComparison.identical : RenderComparison.metadata;
    if (style != null) {
      final RenderComparison candidate = style.compareTo(other.style);
      if (candidate.index > result.index)
        result = candidate;
      if (result == RenderComparison.layout)
        return result;
    }
    if (children != null) {
      for (int index = 0; index < children.length; index += 1) {
        final RenderComparison candidate = children[index].compareTo(other.children[index]);
        if (candidate.index > result.index)
          result = candidate;
        if (result == RenderComparison.layout)
          return result;
      }
    }
    return result;
  }

315 316 317 318
  @override
  bool operator ==(dynamic other) {
    if (identical(this, other))
      return true;
319
    if (other.runtimeType != runtimeType)
320 321 322 323 324
      return false;
    final TextSpan typedOther = other;
    return typedOther.text == text
        && typedOther.style == style
        && typedOther.recognizer == recognizer
325
        && listEquals<TextSpan>(typedOther.children, children);
326 327 328 329
  }

  @override
  int get hashCode => hashValues(style, text, recognizer, hashList(children));
330 331 332 333

  @override
  String toStringShort() => '$runtimeType';

334
  @override
335 336 337 338 339 340 341
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.defaultDiagnosticsTreeStyle = DiagnosticsTreeStyle.whitespace;
    // Properties on style are added as if they were properties directly on
    // this TextSpan.
    if (style != null)
      style.debugFillProperties(properties);
342

343 344 345 346 347
    properties.add(new DiagnosticsProperty<GestureRecognizer>(
      'recognizer', recognizer,
      description: recognizer?.runtimeType?.toString(),
      defaultValue: null,
    ));
348

349 350 351 352 353 354 355
    properties.add(new StringProperty('text', text, showName: false, defaultValue: null));
    if (style == null && text == null && children == null)
      properties.add(new DiagnosticsNode.message('(empty)'));
  }

  @override
  List<DiagnosticsNode> debugDescribeChildren() {
356 357 358 359 360 361 362 363 364
    if (children == null)
      return const <DiagnosticsNode>[];
    return children.map((TextSpan child) {
      if (child != null) {
        return child.toDiagnosticsNode();
      } else {
        return new DiagnosticsNode.message('<null child>');
      }
    }).toList();
365
  }
366
}