text_span.dart 14.5 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

import 'basic_types.dart';
12 13
import 'inline_span.dart';
import 'text_painter.dart';
14 15 16 17 18 19 20 21 22 23 24 25
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
26 27 28
/// [children] list. Leaving the [TextSpan.text] field null results
/// in the [TextSpan] acting as an empty node in the [InlineSpan]
/// tree with a list of children.
29 30 31 32 33
///
/// 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.
///
34
/// {@tool sample}
35 36 37 38
///
/// The text "Hello world!", in black:
///
/// ```dart
39
/// TextSpan(
40
///   text: 'Hello world!',
41
///   style: TextStyle(color: Colors.black),
42 43
/// )
/// ```
44
/// {@end-tool}
45 46 47 48
///
/// _There is some more detailed sample code in the documentation for the
/// [recognizer] property._
///
49 50 51 52 53
/// The [TextSpan.text] will be used as the semantics label unless overriden
/// by the [TextSpan.semanticsLabel] property. Any [PlaceholderSpan]s in the
/// [TextSpan.children] list will separate the text before and after it into
/// two semantics nodes.
///
54 55
/// See also:
///
56 57 58 59
///  * [WidgetSpan], a leaf node that represents an embedded inline widget
///    in an [InlineSpan] tree. Specify a widget within the [children]
///    list by wrapping the widget with a [WidgetSpan]. The widget will be
///    laid out inline within the paragraph.
60 61 62
///  * [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].
63
@immutable
64
class TextSpan extends InlineSpan {
65 66 67 68 69 70 71
  /// 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.text,
    this.children,
72
    TextStyle style,
73
    this.recognizer,
74
    this.semanticsLabel,
75
  }) : super(style: style,);
76

77
  /// The text contained in this span.
78
  ///
79
  /// If both [text] and [children] are non-null, the text will precede the
80
  /// children.
81 82 83
  ///
  /// This getter does not include the contents of its children.
  @override
84 85
  final String text;

86

87 88
  /// Additional spans to include as children.
  ///
89
  /// If both [text] and [children] are non-null, the text will precede the
90 91 92 93 94 95
  /// 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.
96 97
  @override
  final List<InlineSpan> children;
98

99
  /// A gesture recognizer that will receive events that hit this span.
100
  ///
101 102
  /// [InlineSpan] itself does not implement hit testing or event dispatch. The
  /// object that manages the [InlineSpan] painting is also responsible for
103 104
  /// dispatching events. In the rendering library, that is the
  /// [RenderParagraph] object, which corresponds to the [RichText] widget in
105
  /// the widgets layer; these objects do not bubble events in [InlineSpan]s, so a
106
  /// [recognizer] is only effective for events that directly hit the [text] of
107
  /// that [InlineSpan], not any of its [children].
108
  ///
109
  /// [InlineSpan] also does not manage the lifetime of the gesture recognizer.
110
  /// The code that owns the [GestureRecognizer] object must call
111
  /// [GestureRecognizer.dispose] when the [InlineSpan] object is no longer used.
112
  ///
113
  /// {@tool sample}
114 115
  ///
  /// This example shows how to manage the lifetime of a gesture recognizer
116
  /// provided to an [InlineSpan] object. It defines a `BuzzingText` widget which
117 118 119 120 121 122 123
  /// 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
124
  ///   _BuzzingTextState createState() => _BuzzingTextState();
125 126 127 128 129 130 131 132
  /// }
  ///
  /// class _BuzzingTextState extends State<BuzzingText> {
  ///   LongPressGestureRecognizer _longPressRecognizer;
  ///
  ///   @override
  ///   void initState() {
  ///     super.initState();
133
  ///     _longPressRecognizer = LongPressGestureRecognizer()
134 135 136 137 138 139 140 141
  ///       ..onLongPress = _handlePress;
  ///   }
  ///
  ///   @override
  ///   void dispose() {
  ///     _longPressRecognizer.dispose();
  ///     super.dispose();
  ///   }
142
  ///
143 144 145 146 147 148
  ///   void _handlePress() {
  ///     HapticFeedback.vibrate();
  ///   }
  ///
  ///   @override
  ///   Widget build(BuildContext context) {
149 150
  ///     return Text.rich(
  ///       TextSpan(
151
  ///         text: 'Can you ',
152
  ///         style: TextStyle(color: Colors.black),
153
  ///         children: <InlineSpan>[
154
  ///           TextSpan(
155
  ///             text: 'find the',
156
  ///             style: TextStyle(
157 158 159 160 161 162
  ///               color: Colors.green,
  ///               decoration: TextDecoration.underline,
  ///               decorationStyle: TextDecorationStyle.wavy,
  ///             ),
  ///             recognizer: _longPressRecognizer,
  ///           ),
163
  ///           TextSpan(
164 165 166 167 168 169 170 171
  ///             text: ' secret?',
  ///           ),
  ///         ],
  ///       ),
  ///     );
  ///   }
  /// }
  /// ```
172
  /// {@end-tool}
173
  @override
174 175
  final GestureRecognizer recognizer;

176
  /// An alternative semantics label for this [TextSpan].
177 178 179 180 181 182 183 184 185 186 187 188
  ///
  /// If present, the semantics of this span will contain this value instead
  /// of the actual text.
  ///
  /// This is useful for replacing abbreviations or shorthands with the full
  /// text value:
  ///
  /// ```dart
  /// TextSpan(text: r'$$', semanticsLabel: 'Double dollars')
  /// ```
  final String semanticsLabel;

189 190 191 192 193 194 195
  /// 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.
196 197
  @override
  void build(ui.ParagraphBuilder builder, { double textScaleFactor = 1.0, List<PlaceholderDimensions> dimensions }) {
198
    assert(debugAssertIsValid());
199 200
    final bool hasStyle = style != null;
    if (hasStyle)
201
      builder.pushStyle(style.getTextStyle(textScaleFactor: textScaleFactor));
202 203 204
    if (text != null)
      builder.addText(text);
    if (children != null) {
205
      for (InlineSpan child in children) {
206
        assert(child != null);
207
        child.build(builder, textScaleFactor: textScaleFactor, dimensions: dimensions);
208 209 210 211 212 213
      }
    }
    if (hasStyle)
      builder.pop();
  }

214
  /// Walks this [TextSpan] and its descendants in pre-order and calls [visitor]
215
  /// for each span that has text.
216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241
  ///
  /// When `visitor` returns true, the walk will continue. When `visitor` returns
  /// false, then the walk will end.
  @override
  bool visitChildren(InlineSpanVisitor visitor) {
    if (text != null) {
      if (!visitor(this))
        return false;
    }
    if (children != null) {
      for (InlineSpan child in children) {
        if (!child.visitChildren(visitor))
          return false;
      }
    }
    return true;
  }

  // TODO(garyq): Remove this after next stable release.
  /// Walks this [TextSpan] and any descendants in pre-order and calls `visitor`
  /// for each span that has content.
  ///
  /// When `visitor` returns true, the walk will continue. When `visitor` returns
  /// false, then the walk will end.
  @override
  @Deprecated('Use to visitChildren instead')
242
  bool visitTextSpan(bool visitor(TextSpan span)) {
243 244 245 246 247
    if (text != null) {
      if (!visitor(this))
        return false;
    }
    if (children != null) {
248 249 250 251
      for (InlineSpan child in children) {
        assert(child is TextSpan, 'visitTextSpan is deprecated. Use visitChildren to support InlineSpans');
        final TextSpan textSpanChild = child;
        if (!textSpanChild.visitTextSpan(visitor))
252 253 254 255 256 257 258
          return false;
      }
    }
    return true;
  }

  /// Returns the text span that contains the given position in the text.
259 260 261 262 263
  @override
  InlineSpan getSpanForPositionVisitor(TextPosition position, Accumulator offset) {
    if (text == null) {
      return null;
    }
264 265
    final TextAffinity affinity = position.affinity;
    final int targetOffset = position.offset;
266 267 268 269 270 271 272 273
    final int endOffset = offset.value + text.length;
    if (offset.value == targetOffset && affinity == TextAffinity.downstream ||
        offset.value < targetOffset && targetOffset < endOffset ||
        endOffset == targetOffset && affinity == TextAffinity.upstream) {
      return this;
    }
    offset.increment(text.length);
    return null;
274 275
  }

276 277
  @override
  void computeToPlainText(StringBuffer buffer, {bool includeSemanticsLabels = true, bool includePlaceholders = true}) {
278
    assert(debugAssertIsValid());
279 280 281 282 283 284 285 286 287 288 289
    if (semanticsLabel != null && includeSemanticsLabels) {
      buffer.write(semanticsLabel);
    } else if (text != null) {
      buffer.write(text);
    }
    if (children != null) {
      for (InlineSpan child in children) {
        child.computeToPlainText(buffer,
          includeSemanticsLabels: includeSemanticsLabels,
          includePlaceholders: includePlaceholders,
        );
290
      }
291
    }
292 293
  }

294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310
  @override
  void computeSemanticsInformation(List<InlineSpanSemanticsInformation> collector) {
    assert(debugAssertIsValid());
    if (text != null || semanticsLabel != null) {
      collector.add(InlineSpanSemanticsInformation(
        text,
        semanticsLabel: semanticsLabel,
        recognizer: recognizer,
      ));
    }
    if (children != null) {
      for (InlineSpan child in children) {
        child.computeSemanticsInformation(collector);
      }
    }
  }

311 312 313
  @override
  int codeUnitAtVisitor(int index, Accumulator offset) {
    if (text == null) {
314
      return null;
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331
    }
    if (index - offset.value < text.length) {
      return text.codeUnitAt(index - offset.value);
    }
    offset.increment(text.length);
    return null;
  }

  @override
  void describeSemantics(Accumulator offset, List<int> semanticsOffsets, List<dynamic> semanticsElements) {
    if (recognizer != null && (recognizer is TapGestureRecognizer || recognizer is LongPressGestureRecognizer)) {
      final int length = semanticsLabel?.length ?? text.length;
      semanticsOffsets.add(offset.value);
      semanticsOffsets.add(offset.value + length);
      semanticsElements.add(recognizer);
    }
    offset.increment(text != null ? text.length : 0);
332 333
  }

334 335 336 337
  /// 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:
338
  ///
339
  /// ```dart
340
  /// assert(myTextSpan.debugAssertIsValid());
341
  /// ```
342
  @override
343
  bool debugAssertIsValid() {
344
    assert(() {
345 346 347 348 349 350 351 352 353
      if (children != null) {
        for (InlineSpan child in children) {
          assert(child != null,
            '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'
            '${toStringDeep(prefixLineOne: '  ')}'
          );
          assert(child.debugAssertIsValid());
354 355 356
        }
      }
      return true;
357
    }());
358
    return super.debugAssertIsValid();
359 360
  }

361 362
  @override
  RenderComparison compareTo(InlineSpan other) {
363 364
    if (identical(this, other))
      return RenderComparison.identical;
365
    if (other.runtimeType != runtimeType)
366
      return RenderComparison.layout;
367 368 369 370 371 372
    final TextSpan textSpan = other;
    if (textSpan.text != text ||
        children?.length != textSpan.children?.length ||
        (style == null) != (textSpan.style == null))
      return RenderComparison.layout;
    RenderComparison result = recognizer == textSpan.recognizer ? RenderComparison.identical : RenderComparison.metadata;
373
    if (style != null) {
374
      final RenderComparison candidate = style.compareTo(textSpan.style);
375 376 377 378 379 380 381
      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) {
382
        final RenderComparison candidate = children[index].compareTo(textSpan.children[index]);
383 384 385 386 387 388 389 390 391
        if (candidate.index > result.index)
          result = candidate;
        if (result == RenderComparison.layout)
          return result;
      }
    }
    return result;
  }

392 393 394 395
  @override
  bool operator ==(dynamic other) {
    if (identical(this, other))
      return true;
396
    if (other.runtimeType != runtimeType)
397
      return false;
398 399
    if (super != other)
      return false;
400 401 402
    final TextSpan typedOther = other;
    return typedOther.text == text
        && typedOther.recognizer == recognizer
403
        && typedOther.semanticsLabel == semanticsLabel
404
        && listEquals<InlineSpan>(typedOther.children, children);
405 406 407
  }

  @override
408
  int get hashCode => hashValues(super.hashCode, text, recognizer, semanticsLabel, hashList(children));
409 410 411 412

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

413
  @override
414 415
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
416 417 418 419

    properties.add(StringProperty('text', text, showName: false, defaultValue: null));
    if (style == null && text == null && children == null)
      properties.add(DiagnosticsNode.message('(empty)'));
420

421
    properties.add(DiagnosticsProperty<GestureRecognizer>(
422 423 424 425
      'recognizer', recognizer,
      description: recognizer?.runtimeType?.toString(),
      defaultValue: null,
    ));
426

427 428 429
    if (semanticsLabel != null) {
      properties.add(StringProperty('semanticsLabel', semanticsLabel));
    }
430 431 432 433
  }

  @override
  List<DiagnosticsNode> debugDescribeChildren() {
434 435
    if (children == null)
      return const <DiagnosticsNode>[];
436
    return children.map<DiagnosticsNode>((InlineSpan child) {
437 438 439
      if (child != null) {
        return child.toDiagnosticsNode();
      } else {
440
        return DiagnosticsNode.message('<null child>');
441 442
      }
    }).toList();
443
  }
444
}