460 lines
14 KiB
Dart
460 lines
14 KiB
Dart
// Copyright 2014 The Flutter Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
/// @docImport 'editable_text.dart';
|
|
/// @docImport 'text.dart';
|
|
library;
|
|
|
|
import 'dart:ui' as ui show ParagraphBuilder, PlaceholderAlignment;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
|
|
import 'basic.dart';
|
|
import 'framework.dart';
|
|
|
|
// Examples can assume:
|
|
// late WidgetSpan myWidgetSpan;
|
|
|
|
/// An immutable widget that is embedded inline within text.
|
|
///
|
|
/// The [child] property is the widget that will be embedded. Children are
|
|
/// constrained by the width of the paragraph.
|
|
///
|
|
/// The [child] property may contain its own [Widget] children (if applicable),
|
|
/// including [Text] and [RichText] widgets which may include additional
|
|
/// [WidgetSpan]s. Child [Text] and [RichText] widgets will be laid out
|
|
/// independently and occupy a rectangular space in the parent text layout.
|
|
///
|
|
/// [WidgetSpan]s will be ignored when passed into a [TextPainter] directly.
|
|
/// To properly layout and paint the [child] widget, [WidgetSpan] should be
|
|
/// passed into a [Text.rich] widget.
|
|
///
|
|
/// {@tool snippet}
|
|
///
|
|
/// A card with `Hello World!` embedded inline within a TextSpan tree.
|
|
///
|
|
/// ```dart
|
|
/// const Text.rich(
|
|
/// TextSpan(
|
|
/// children: <InlineSpan>[
|
|
/// TextSpan(text: 'Flutter is'),
|
|
/// WidgetSpan(
|
|
/// child: SizedBox(
|
|
/// width: 120,
|
|
/// height: 50,
|
|
/// child: Card(
|
|
/// child: Center(
|
|
/// child: Text('Hello World!')
|
|
/// )
|
|
/// ),
|
|
/// )
|
|
/// ),
|
|
/// TextSpan(text: 'the best!'),
|
|
/// ],
|
|
/// )
|
|
/// )
|
|
/// ```
|
|
/// {@end-tool}
|
|
///
|
|
/// [WidgetSpan] contributes the semantics of the [WidgetSpan.child] to the
|
|
/// semantics tree.
|
|
///
|
|
/// See also:
|
|
///
|
|
/// * [TextSpan], a node that represents text in an [InlineSpan] tree.
|
|
/// * [Text], a widget for showing uniformly-styled text.
|
|
/// * [RichText], a widget for finer control of text rendering.
|
|
/// * [TextPainter], a class for painting [InlineSpan] objects on a [Canvas].
|
|
@immutable
|
|
class WidgetSpan extends PlaceholderSpan {
|
|
/// Creates a [WidgetSpan] with the given values.
|
|
///
|
|
/// [WidgetSpan] is a leaf node in the [InlineSpan] tree. Child widgets are
|
|
/// constrained by the width of the paragraph they occupy. Child widget
|
|
/// heights are unconstrained, and may cause the text to overflow and be
|
|
/// ellipsized/truncated.
|
|
///
|
|
/// A [TextStyle] may be provided with the [style] property, but only the
|
|
/// decoration, foreground, background, and spacing options will be used.
|
|
const WidgetSpan({required this.child, super.alignment, super.baseline, super.style})
|
|
: assert(
|
|
baseline != null ||
|
|
!(identical(alignment, ui.PlaceholderAlignment.aboveBaseline) ||
|
|
identical(alignment, ui.PlaceholderAlignment.belowBaseline) ||
|
|
identical(alignment, ui.PlaceholderAlignment.baseline)),
|
|
);
|
|
|
|
/// Helper function for extracting [WidgetSpan]s in preorder, from the given
|
|
/// [InlineSpan] as a list of widgets.
|
|
///
|
|
/// The `textScaler` is the scaling strategy for scaling the content.
|
|
///
|
|
/// This function is used by [EditableText] and [RichText] so calling it
|
|
/// directly is rarely necessary.
|
|
static List<Widget> extractFromInlineSpan(InlineSpan span, TextScaler textScaler) {
|
|
final widgets = <Widget>[];
|
|
// _kEngineDefaultFontSize is the default font size to use when none of the
|
|
// ancestor spans specifies one.
|
|
final fontSizeStack = <double>[kDefaultFontSize];
|
|
var index = 0;
|
|
// This assumes an InlineSpan tree's logical order is equivalent to preorder.
|
|
bool visitSubtree(InlineSpan span) {
|
|
final double? fontSizeToPush = switch (span.style?.fontSize) {
|
|
final double size when size != fontSizeStack.last => size,
|
|
_ => null,
|
|
};
|
|
if (fontSizeToPush != null) {
|
|
fontSizeStack.add(fontSizeToPush);
|
|
}
|
|
if (span is WidgetSpan) {
|
|
final double fontSize = fontSizeStack.last;
|
|
final double textScaleFactor = fontSize == 0 ? 0 : textScaler.scale(fontSize) / fontSize;
|
|
widgets.add(
|
|
_WidgetSpanParentData(
|
|
span: span,
|
|
child: Semantics(
|
|
tagForChildren: PlaceholderSpanIndexSemanticsTag(index++),
|
|
child: _AutoScaleInlineWidget(
|
|
span: span,
|
|
textScaleFactor: textScaleFactor,
|
|
child: span.child,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
assert(
|
|
span is WidgetSpan || span is! PlaceholderSpan,
|
|
'$span is a PlaceholderSpan but not a WidgetSpan subclass. This is currently not supported.',
|
|
);
|
|
span.visitDirectChildren(visitSubtree);
|
|
if (fontSizeToPush != null) {
|
|
final double poppedFontSize = fontSizeStack.removeLast();
|
|
assert(fontSizeStack.isNotEmpty);
|
|
assert(poppedFontSize == fontSizeToPush);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
visitSubtree(span);
|
|
return widgets;
|
|
}
|
|
|
|
/// The widget to embed inline within text.
|
|
final Widget child;
|
|
|
|
/// Adds a placeholder box to the paragraph builder if a size has been
|
|
/// calculated for the widget.
|
|
///
|
|
/// Sizes are provided through `dimensions`, which should contain a 1:1
|
|
/// in-order mapping of widget to laid-out dimensions. If no such dimension
|
|
/// is provided, the widget will be skipped.
|
|
///
|
|
/// The `textScaler` will be applied to the laid-out size of the widget.
|
|
@override
|
|
void build(
|
|
ui.ParagraphBuilder builder, {
|
|
TextScaler textScaler = TextScaler.noScaling,
|
|
List<PlaceholderDimensions>? dimensions,
|
|
}) {
|
|
assert(debugAssertIsValid());
|
|
assert(dimensions != null);
|
|
final hasStyle = style != null;
|
|
if (hasStyle) {
|
|
builder.pushStyle(style!.getTextStyle(textScaler: textScaler));
|
|
}
|
|
assert(builder.placeholderCount < dimensions!.length);
|
|
final PlaceholderDimensions currentDimensions = dimensions![builder.placeholderCount];
|
|
builder.addPlaceholder(
|
|
currentDimensions.size.width,
|
|
currentDimensions.size.height,
|
|
alignment,
|
|
baseline: currentDimensions.baseline,
|
|
baselineOffset: currentDimensions.baselineOffset,
|
|
);
|
|
if (hasStyle) {
|
|
builder.pop();
|
|
}
|
|
}
|
|
|
|
/// Calls `visitor` on this [WidgetSpan]. There are no children spans to walk.
|
|
@override
|
|
bool visitChildren(InlineSpanVisitor visitor) => visitor(this);
|
|
|
|
@override
|
|
bool visitDirectChildren(InlineSpanVisitor visitor) => true;
|
|
|
|
@override
|
|
InlineSpan? getSpanForPositionVisitor(TextPosition position, Accumulator offset) {
|
|
if (position.offset == offset.value) {
|
|
return this;
|
|
}
|
|
offset.increment(1);
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
int? codeUnitAtVisitor(int index, Accumulator offset) {
|
|
final int localOffset = index - offset.value;
|
|
assert(localOffset >= 0);
|
|
offset.increment(1);
|
|
return localOffset == 0 ? PlaceholderSpan.placeholderCodeUnit : null;
|
|
}
|
|
|
|
@override
|
|
RenderComparison compareTo(InlineSpan other) {
|
|
if (identical(this, other)) {
|
|
return RenderComparison.identical;
|
|
}
|
|
if (other.runtimeType != runtimeType) {
|
|
return RenderComparison.layout;
|
|
}
|
|
if ((style == null) != (other.style == null)) {
|
|
return RenderComparison.layout;
|
|
}
|
|
final typedOther = other as WidgetSpan;
|
|
if (child != typedOther.child || alignment != typedOther.alignment) {
|
|
return RenderComparison.layout;
|
|
}
|
|
RenderComparison result = RenderComparison.identical;
|
|
if (style != null) {
|
|
final RenderComparison candidate = style!.compareTo(other.style!);
|
|
if (candidate.index > result.index) {
|
|
result = candidate;
|
|
}
|
|
if (result == RenderComparison.layout) {
|
|
return result;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
if (identical(this, other)) {
|
|
return true;
|
|
}
|
|
if (other.runtimeType != runtimeType) {
|
|
return false;
|
|
}
|
|
if (super != other) {
|
|
return false;
|
|
}
|
|
return other is WidgetSpan &&
|
|
other.child == child &&
|
|
other.alignment == alignment &&
|
|
other.baseline == baseline;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => Object.hash(super.hashCode, child, alignment, baseline);
|
|
|
|
/// Returns the text span that contains the given position in the text.
|
|
@override
|
|
InlineSpan? getSpanForPosition(TextPosition position) {
|
|
assert(debugAssertIsValid());
|
|
return null;
|
|
}
|
|
|
|
/// In debug mode, throws an exception if the object is not in a
|
|
/// valid configuration. Otherwise, returns true.
|
|
///
|
|
/// This is intended to be used as follows:
|
|
///
|
|
/// ```dart
|
|
/// assert(myWidgetSpan.debugAssertIsValid());
|
|
/// ```
|
|
@override
|
|
bool debugAssertIsValid() {
|
|
// WidgetSpans are always valid as asserts prevent invalid WidgetSpans
|
|
// from being constructed.
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
|
super.debugFillProperties(properties);
|
|
properties.add(DiagnosticsProperty<Widget>('widget', child));
|
|
}
|
|
}
|
|
|
|
// A ParentDataWidget that sets TextParentData.span.
|
|
class _WidgetSpanParentData extends ParentDataWidget<TextParentData> {
|
|
const _WidgetSpanParentData({required this.span, required super.child});
|
|
|
|
final WidgetSpan span;
|
|
|
|
@override
|
|
void applyParentData(RenderObject renderObject) {
|
|
final parentData = renderObject.parentData! as TextParentData;
|
|
parentData.span = span;
|
|
}
|
|
|
|
@override
|
|
Type get debugTypicalAncestorWidgetClass => RichText;
|
|
}
|
|
|
|
// A RenderObjectWidget that automatically applies text scaling on inline
|
|
// widgets.
|
|
//
|
|
// TODO(LongCatIsLooong): this shouldn't happen automatically, at least there
|
|
// should be a way to opt out: https://github.com/flutter/flutter/issues/126962
|
|
class _AutoScaleInlineWidget extends SingleChildRenderObjectWidget {
|
|
const _AutoScaleInlineWidget({
|
|
required this.span,
|
|
required this.textScaleFactor,
|
|
required super.child,
|
|
});
|
|
|
|
final WidgetSpan span;
|
|
final double textScaleFactor;
|
|
|
|
@override
|
|
_RenderScaledInlineWidget createRenderObject(BuildContext context) {
|
|
return _RenderScaledInlineWidget(span.alignment, span.baseline, textScaleFactor);
|
|
}
|
|
|
|
@override
|
|
void updateRenderObject(BuildContext context, _RenderScaledInlineWidget renderObject) {
|
|
renderObject
|
|
..alignment = span.alignment
|
|
..baseline = span.baseline
|
|
..scale = textScaleFactor;
|
|
}
|
|
}
|
|
|
|
class _RenderScaledInlineWidget extends RenderBox with RenderObjectWithChildMixin<RenderBox> {
|
|
_RenderScaledInlineWidget(this._alignment, this._baseline, this._scale);
|
|
|
|
double get scale => _scale;
|
|
double _scale;
|
|
set scale(double value) {
|
|
if (value == _scale) {
|
|
return;
|
|
}
|
|
assert(value > 0);
|
|
assert(value.isFinite);
|
|
_scale = value;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
ui.PlaceholderAlignment get alignment => _alignment;
|
|
ui.PlaceholderAlignment _alignment;
|
|
set alignment(ui.PlaceholderAlignment value) {
|
|
if (_alignment == value) {
|
|
return;
|
|
}
|
|
_alignment = value;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
TextBaseline? get baseline => _baseline;
|
|
TextBaseline? _baseline;
|
|
set baseline(TextBaseline? value) {
|
|
if (value == _baseline) {
|
|
return;
|
|
}
|
|
_baseline = value;
|
|
markNeedsLayout();
|
|
}
|
|
|
|
@override
|
|
double computeMaxIntrinsicHeight(double width) {
|
|
return (child?.getMaxIntrinsicHeight(width / scale) ?? 0.0) * scale;
|
|
}
|
|
|
|
@override
|
|
double computeMaxIntrinsicWidth(double height) {
|
|
return (child?.getMaxIntrinsicWidth(height / scale) ?? 0.0) * scale;
|
|
}
|
|
|
|
@override
|
|
double computeMinIntrinsicHeight(double width) {
|
|
return (child?.getMinIntrinsicHeight(width / scale) ?? 0.0) * scale;
|
|
}
|
|
|
|
@override
|
|
double computeMinIntrinsicWidth(double height) {
|
|
return (child?.getMinIntrinsicWidth(height / scale) ?? 0.0) * scale;
|
|
}
|
|
|
|
@override
|
|
double? computeDistanceToActualBaseline(TextBaseline baseline) {
|
|
return switch (child?.getDistanceToActualBaseline(baseline)) {
|
|
null => super.computeDistanceToActualBaseline(baseline),
|
|
final double childBaseline => scale * childBaseline,
|
|
};
|
|
}
|
|
|
|
@override
|
|
double? computeDryBaseline(BoxConstraints constraints, TextBaseline baseline) {
|
|
final double? distance = child?.getDryBaseline(
|
|
BoxConstraints(maxWidth: constraints.maxWidth / scale),
|
|
baseline,
|
|
);
|
|
return distance == null ? null : scale * distance;
|
|
}
|
|
|
|
@override
|
|
Size computeDryLayout(BoxConstraints constraints) {
|
|
assert(!constraints.hasBoundedHeight);
|
|
final Size unscaledSize =
|
|
child?.getDryLayout(BoxConstraints(maxWidth: constraints.maxWidth / scale)) ?? Size.zero;
|
|
return constraints.constrain(unscaledSize * scale);
|
|
}
|
|
|
|
@override
|
|
void performLayout() {
|
|
final RenderBox? child = this.child;
|
|
if (child == null) {
|
|
return;
|
|
}
|
|
assert(!constraints.hasBoundedHeight);
|
|
// Only constrain the width to the maximum width of the paragraph.
|
|
// Leave height unconstrained, which will overflow if expanded past.
|
|
child.layout(BoxConstraints(maxWidth: constraints.maxWidth / scale), parentUsesSize: true);
|
|
size = constraints.constrain(child.size * scale);
|
|
}
|
|
|
|
@override
|
|
void applyPaintTransform(RenderBox child, Matrix4 transform) {
|
|
transform.scaleByDouble(scale, scale, scale, 1);
|
|
}
|
|
|
|
@override
|
|
void paint(PaintingContext context, Offset offset) {
|
|
final RenderBox? child = this.child;
|
|
if (child == null) {
|
|
layer = null;
|
|
return;
|
|
}
|
|
if (scale == 1.0) {
|
|
context.paintChild(child, offset);
|
|
layer = null;
|
|
return;
|
|
}
|
|
layer = context.pushTransform(
|
|
needsCompositing,
|
|
offset,
|
|
Matrix4.diagonal3Values(scale, scale, 1.0),
|
|
(PaintingContext context, Offset offset) => context.paintChild(child, offset),
|
|
oldLayer: layer as TransformLayer?,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
|
final RenderBox? child = this.child;
|
|
if (child == null) {
|
|
return false;
|
|
}
|
|
return result.addWithPaintTransform(
|
|
transform: Matrix4.diagonal3Values(scale, scale, 1.0),
|
|
position: position,
|
|
hitTest: (BoxHitTestResult result, Offset transformedOffset) =>
|
|
child.hitTest(result, position: transformedOffset),
|
|
);
|
|
}
|
|
}
|