diff --git a/demo/lib/main.dart b/demo/lib/main.dart index a6c2a18..d3b9db0 100644 --- a/demo/lib/main.dart +++ b/demo/lib/main.dart @@ -7,6 +7,7 @@ import 'max_lines_demo.dart'; import 'min_font_size_demo.dart'; import 'overflow_replacement_demo.dart'; import 'preset_font_sizes_demo.dart'; +import 'selectable_text_demo.dart'; import 'step_granularity.dart'; import 'sync_demo.dart'; @@ -43,6 +44,7 @@ List colors = [ Colors.lightBlue, Colors.green, Colors.blueGrey, + Colors.teal, ]; List demoNames = [ @@ -52,6 +54,7 @@ List demoNames = [ 'StepGranularity', 'PresetFontSizes', 'OverflowReplacement', + 'Text Selection', ]; class _DemoAppState extends State { @@ -138,6 +141,11 @@ class _DemoAppState extends State { title: Text('replacement'), activeColor: colors[5], ), + BottomNavyBarItem( + icon: Icon(MdiIcons.selection), + title: Text('selection'), + activeColor: colors[6], + ), ], ), ); @@ -155,6 +163,10 @@ class _DemoAppState extends State { return StepGranularityDemo(_richText); case 4: return PresetFontSizesDemo(_richText); + case 5: + return OverflowReplacementDemo(_richText); + case 6: + return SelectableTextDemo(_richText); default: return OverflowReplacementDemo(_richText); } diff --git a/demo/lib/selectable_text_demo.dart b/demo/lib/selectable_text_demo.dart new file mode 100644 index 0000000..1e36ce9 --- /dev/null +++ b/demo/lib/selectable_text_demo.dart @@ -0,0 +1,62 @@ +library selectable_text_demo.dart; + +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; + +import 'animated_input.dart'; +import 'text_card.dart'; +import 'utils.dart'; + +/// A stateful widget to render SelectableTextDemo. +class SelectableTextDemo extends StatelessWidget { + const SelectableTextDemo(this.richText, {Key? key}) : super(key: key); + + final bool richText; + + @override + Widget build(BuildContext context) { + return AnimatedInput( + text: '"AutoSizeText.selectable" & "AutoSizeText.richSelectable"' + ' widgets displays a string of text with a single style' + ' or paragraphs with differently styled TextSpans.' + ' It is similar to "SelectableText" & "SelectableText.rich", but uses AutoSizeText to render.', + builder: (input) { + return Row( + children: [ + Expanded( + child: TextCard( + title: 'AutoSizeText', + child: !richText + ? AutoSizeText( + input, + style: TextStyle(fontSize: 30), + ) + : AutoSizeText.rich( + spanFromString(input), + style: TextStyle(fontSize: 30), + ), + ), + ), + SizedBox(width: 10), + Expanded( + child: TextCard( + title: 'AutoSizeText.selectable', + child: !richText + ? AutoSizeText.selectable( + input, + style: TextStyle(fontSize: 30), + maxLines: 2, + ) + : AutoSizeText.richSelectable( + spanFromString(input), + style: TextStyle(fontSize: 30), + maxLines: 2, + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/auto_size_text.dart b/lib/auto_size_text.dart index 0ff37b6..cd64ef1 100644 --- a/lib/auto_size_text.dart +++ b/lib/auto_size_text.dart @@ -3,7 +3,10 @@ library auto_size_text; import 'dart:async'; +import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart' show SelectableText; import 'package:flutter/widgets.dart'; part 'src/auto_size_text.dart'; diff --git a/lib/src/auto_size_text.dart b/lib/src/auto_size_text.dart index ec43838..f4aa2ab 100644 --- a/lib/src/auto_size_text.dart +++ b/lib/src/auto_size_text.dart @@ -33,6 +33,24 @@ class AutoSizeText extends StatefulWidget { this.maxLines, this.semanticsLabel, }) : textSpan = null, + autofocus = false, + showCursor = false, + cursorWidth = 2.0, + cursorHeight = null, + cursorRadius = null, + focusNode = null, + cursorColor = null, + enableInteractiveSelection = false, + selectionControls = null, + dragStartBehavior = DragStartBehavior.start, + toolbarOptions = null, + onTap = null, + scrollPhysics = null, + onSelectionChanged = null, + minLines = null, + selectionHeightStyle = ui.BoxHeightStyle.tight, + selectionWidthStyle = ui.BoxWidthStyle.tight, + _isSelectableText = false, super(key: key); /// Creates a [AutoSizeText] widget with a [TextSpan]. @@ -58,6 +76,115 @@ class AutoSizeText extends StatefulWidget { this.maxLines, this.semanticsLabel, }) : data = null, + autofocus = false, + showCursor = false, + cursorWidth = 2.0, + cursorHeight = null, + cursorRadius = null, + focusNode = null, + cursorColor = null, + enableInteractiveSelection = false, + selectionControls = null, + dragStartBehavior = DragStartBehavior.start, + toolbarOptions = null, + onTap = null, + scrollPhysics = null, + onSelectionChanged = null, + minLines = null, + selectionHeightStyle = ui.BoxHeightStyle.tight, + selectionWidthStyle = ui.BoxWidthStyle.tight, + _isSelectableText = false, + super(key: key); + + /// Creates a selectable [AutoSizeText] widget with a [TextSpan]. + const AutoSizeText.richSelectable( + this.textSpan, { + Key? key, + this.textKey, + this.style, + this.strutStyle, + this.minFontSize = 12, + this.maxFontSize = double.infinity, + this.stepGranularity = 1, + this.presetFontSizes, + this.group, + this.textAlign, + this.textDirection, + this.wrapWords = true, + this.textScaleFactor, + this.minLines, + this.maxLines, + this.autofocus = false, + this.showCursor = false, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius, + this.focusNode, + this.cursorColor, + this.enableInteractiveSelection = true, + this.selectionControls, + this.dragStartBehavior = DragStartBehavior.start, + this.selectionHeightStyle = ui.BoxHeightStyle.tight, + this.selectionWidthStyle = ui.BoxWidthStyle.tight, + this.toolbarOptions, + this.onTap, + this.scrollPhysics, + this.onSelectionChanged, + }) : assert(textSpan != null, 'A non-null TextSpan must be provided to a AutoSizeText.rich widget.'), + data = null, + locale = null, + softWrap = null, + overflow = null, + overflowReplacement = null, + semanticsLabel = null, + _isSelectableText = true, + super(key: key); + + /// Creates a selectable [AutoSizeText] widget. + /// + /// If the [style] argument is null, the text will use the style from the + /// closest enclosing [DefaultTextStyle]. + const AutoSizeText.selectable( + this.data, { + Key? key, + this.textKey, + this.style, + this.strutStyle, + this.minFontSize = 12, + this.maxFontSize = double.infinity, + this.stepGranularity = 1, + this.presetFontSizes, + this.group, + this.textAlign, + this.textDirection, + this.wrapWords = true, + this.textScaleFactor, + this.minLines, + this.maxLines, + this.autofocus = false, + this.showCursor = false, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius, + this.focusNode, + this.cursorColor, + this.enableInteractiveSelection = true, + this.selectionControls, + this.dragStartBehavior = DragStartBehavior.start, + this.selectionHeightStyle = ui.BoxHeightStyle.tight, + this.selectionWidthStyle = ui.BoxWidthStyle.tight, + this.toolbarOptions, + this.onTap, + this.scrollPhysics, + this.onSelectionChanged, + }) : assert(data != null, 'A non-null String must be provided to a AutoSizeText widget.'), + textSpan = null, + locale = null, + softWrap = null, + overflow = null, + overflowReplacement = null, + semanticsLabel = null, + _isSelectableText = true, super(key: key); /// Sets the key for the resulting [Text] widget. @@ -201,6 +328,9 @@ class AutoSizeText extends StatefulWidget { /// widget directly to entirely override the [DefaultTextStyle]. final int? maxLines; + /// {@macro flutter.widgets.editableText.minLines} + final int? minLines; + /// An alternative semantics label for this text. /// /// If present, the semantics of this widget will contain this value instead @@ -215,6 +345,110 @@ class AutoSizeText extends StatefulWidget { /// ``` final String? semanticsLabel; + /// Enable/disable selectable text. + final bool _isSelectableText; + + /// {@macro flutter.widgets.editableText.autofocus} + final bool autofocus; + + /// The color to use when painting the cursor. + /// + /// Defaults to the theme's `cursorColor` when null. + final Color? cursorColor; + + /// {@macro flutter.widgets.editableText.cursorHeight} + final double? cursorHeight; + + /// {@macro flutter.widgets.editableText.cursorRadius} + final Radius? cursorRadius; + + /// {@macro flutter.widgets.editableText.cursorWidth} + final double cursorWidth; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// Whether to enable user interface affordances for changing the text selection. + /// + /// For example, setting this to true will enable features such as long-pressing the TextField to select text and show the cut/copy/paste menu, and tapping to move the text caret. + /// + /// When this is false, the text selection cannot be adjusted by the user, text cannot be copied, and the user cannot paste into the text field from the clipboard. + final bool enableInteractiveSelection; + + /// Defines the focus for this widget. + /// + /// Text is only selectable when widget is focused. + /// + /// The [focusNode] is a long-lived object that's typically managed by a + /// [StatefulWidget] parent. See [FocusNode] for more information. + /// + /// To give the focus to this widget, provide a [focusNode] and then + /// use the current [FocusScope] to request the focus: + /// + /// ```dart + /// FocusScope.of(context).requestFocus(myFocusNode); + /// ``` + /// + /// This happens automatically when the widget is tapped. + /// + /// To be notified when the widget gains or loses the focus, add a listener + /// to the [focusNode]: + /// + /// ```dart + /// focusNode.addListener(() { print(myFocusNode.hasFocus); }); + /// ``` + /// + /// If null, this widget will create its own [FocusNode]. + final FocusNode? focusNode; + + /// {@macro flutter.widgets.editableText.onSelectionChanged} + final SelectionChangedCallback? onSelectionChanged; + + /// Called when the user taps on this selectable text. + /// + /// The selectable text builds a [GestureDetector] to handle input events like tap, + /// to trigger focus requests, to move the caret, adjust the selection, etc. + /// Handling some of those events by wrapping the selectable text with a competing + /// GestureDetector is problematic. + /// + /// To unconditionally handle taps, without interfering with the selectable text's + /// internal gesture detector, provide this callback. + /// + /// To be notified when the text field gains or loses the focus, provide a + /// [focusNode] and add a listener to that. + /// + /// To listen to arbitrary pointer events without competing with the + /// selectable text's internal gesture detector, use a [Listener]. + final GestureTapCallback? onTap; + + /// The ScrollPhysics to use when vertically scrolling the input. + /// + /// If not specified, it will behave according to the current platform. + final ScrollPhysics? scrollPhysics; + + /// {@macro flutter.widgets.editableText.selectionControls} + final TextSelectionControls? selectionControls; + + /// {@macro flutter.widgets.editableText.showCursor} + final bool showCursor; + + /// Configuration of toolbar options. + /// + /// Paste and cut will be disabled regardless. + /// + /// If not set, select all and copy will be enabled by default. + final ToolbarOptions? toolbarOptions; + + /// Controls how tall the selection highlight boxes are computed to be. + /// + /// See [ui.BoxHeightStyle] for details on available styles. + final ui.BoxHeightStyle selectionHeightStyle; + + /// Controls how wide the selection highlight boxes are computed to be. + /// + /// See [ui.BoxWidthStyle] for details on available styles. + final ui.BoxWidthStyle selectionWidthStyle; + @override _AutoSizeTextState createState() => _AutoSizeTextState(); } @@ -276,37 +510,30 @@ class _AutoSizeTextState extends State { } void _validateProperties(TextStyle style, int? maxLines) { - assert(widget.overflow == null || widget.overflowReplacement == null, - 'Either overflow or overflowReplacement must be null.'); - assert(maxLines == null || maxLines > 0, - 'MaxLines must be greater than or equal to 1.'); - assert(widget.key == null || widget.key != widget.textKey, - 'Key and textKey must not be equal.'); + if (!widget._isSelectableText) { + assert(widget.overflow == null || widget.overflowReplacement == null, 'Either overflow or overflowReplacement must be null.'); + } + assert(maxLines == null || maxLines > 0, 'MaxLines must be greater than or equal to 1.'); + assert(widget.key == null || widget.key != widget.textKey, 'Key and textKey must not be equal.'); if (widget.presetFontSizes == null) { assert( widget.stepGranularity >= 0.1, 'StepGranularity must be greater than or equal to 0.1. It is not a ' 'good idea to resize the font with a higher accuracy.'); - assert(widget.minFontSize >= 0, - 'MinFontSize must be greater than or equal to 0.'); + assert(widget.minFontSize >= 0, 'MinFontSize must be greater than or equal to 0.'); assert(widget.maxFontSize > 0, 'MaxFontSize has to be greater than 0.'); - assert(widget.minFontSize <= widget.maxFontSize, - 'MinFontSize must be smaller or equal than maxFontSize.'); - assert(widget.minFontSize / widget.stepGranularity % 1 == 0, - 'MinFontSize must be a multiple of stepGranularity.'); + assert(widget.minFontSize <= widget.maxFontSize, 'MinFontSize must be smaller or equal than maxFontSize.'); + assert(widget.minFontSize / widget.stepGranularity % 1 == 0, 'MinFontSize must be a multiple of stepGranularity.'); if (widget.maxFontSize != double.infinity) { - assert(widget.maxFontSize / widget.stepGranularity % 1 == 0, - 'MaxFontSize must be a multiple of stepGranularity.'); + assert(widget.maxFontSize / widget.stepGranularity % 1 == 0, 'MaxFontSize must be a multiple of stepGranularity.'); } } else { - assert(widget.presetFontSizes!.isNotEmpty, - 'PresetFontSizes must not be empty.'); + assert(widget.presetFontSizes!.isNotEmpty, 'PresetFontSizes must not be empty.'); } } - List _calculateFontSize( - BoxConstraints size, TextStyle? style, int? maxLines) { + List _calculateFontSize(BoxConstraints size, TextStyle? style, int? maxLines) { final span = TextSpan( style: widget.textSpan?.style ?? style, text: widget.textSpan?.text ?? widget.data, @@ -314,16 +541,14 @@ class _AutoSizeTextState extends State { recognizer: widget.textSpan?.recognizer, ); - final userScale = - widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context); + final userScale = widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context); int left; int right; final presetFontSizes = widget.presetFontSizes?.reversed.toList(); if (presetFontSizes == null) { - final num defaultFontSize = - style!.fontSize!.clamp(widget.minFontSize, widget.maxFontSize); + final num defaultFontSize = style!.fontSize!.clamp(widget.minFontSize, widget.maxFontSize); final defaultScale = defaultFontSize * userScale / style.fontSize!; if (_checkTextFits(span, defaultScale, maxLines, size)) { return [defaultFontSize * userScale, true]; @@ -367,8 +592,7 @@ class _AutoSizeTextState extends State { return [fontSize, lastValueFits]; } - bool _checkTextFits( - TextSpan text, double scale, int? maxLines, BoxConstraints constraints) { + bool _checkTextFits(TextSpan text, double scale, int? maxLines, BoxConstraints constraints) { if (!widget.wrapWords) { final words = text.toPlainText().split(RegExp('\\s+')); @@ -387,8 +611,7 @@ class _AutoSizeTextState extends State { wordWrapTextPainter.layout(maxWidth: constraints.maxWidth); - if (wordWrapTextPainter.didExceedMaxLines || - wordWrapTextPainter.width > constraints.maxWidth) { + if (wordWrapTextPainter.didExceedMaxLines || wordWrapTextPainter.width > constraints.maxWidth) { return false; } } @@ -403,14 +626,78 @@ class _AutoSizeTextState extends State { strutStyle: widget.strutStyle, ); - textPainter.layout(maxWidth: constraints.maxWidth); + if (widget._isSelectableText) { + textPainter.layout(maxWidth: constraints.maxWidth - widget.cursorWidth - 1.0); + } else { + textPainter.layout(maxWidth: constraints.maxWidth); + } - return !(textPainter.didExceedMaxLines || - textPainter.height > constraints.maxHeight || - textPainter.width > constraints.maxWidth); + return !(textPainter.didExceedMaxLines || textPainter.height > constraints.maxHeight || textPainter.width > constraints.maxWidth); } Widget _buildText(double fontSize, TextStyle style, int? maxLines) { + if (widget._isSelectableText) { + if (widget.data != null) { + return SelectableText( + widget.data!, + key: widget.textKey, + style: style.copyWith(fontSize: fontSize), + strutStyle: widget.strutStyle, + textAlign: widget.textAlign, + textDirection: widget.textDirection, + textScaleFactor: 1, + minLines: widget.minLines, + maxLines: maxLines, + autofocus: widget.autofocus, + cursorColor: widget.cursorColor, + cursorHeight: widget.cursorHeight, + cursorRadius: widget.cursorRadius, + cursorWidth: widget.cursorWidth, + dragStartBehavior: widget.dragStartBehavior, + enableInteractiveSelection: widget.enableInteractiveSelection, + focusNode: widget.focusNode, + onSelectionChanged: widget.onSelectionChanged, + onTap: widget.onTap, + scrollPhysics: widget.scrollPhysics, + selectionControls: widget.selectionControls, + showCursor: widget.showCursor, + toolbarOptions: widget.toolbarOptions, + semanticsLabel: widget.semanticsLabel, + selectionHeightStyle: widget.selectionHeightStyle, + selectionWidthStyle: widget.selectionWidthStyle, + ); + } else { + return SelectableText.rich( + widget.textSpan!, + key: widget.textKey, + style: style, + strutStyle: widget.strutStyle, + textAlign: widget.textAlign, + textDirection: widget.textDirection, + textScaleFactor: fontSize / style.fontSize!, + minLines: widget.minLines, + maxLines: maxLines, + autofocus: widget.autofocus, + cursorColor: widget.cursorColor, + cursorHeight: widget.cursorHeight, + cursorRadius: widget.cursorRadius, + cursorWidth: widget.cursorWidth, + dragStartBehavior: widget.dragStartBehavior, + enableInteractiveSelection: widget.enableInteractiveSelection, + focusNode: widget.focusNode, + onSelectionChanged: widget.onSelectionChanged, + onTap: widget.onTap, + scrollPhysics: widget.scrollPhysics, + selectionControls: widget.selectionControls, + showCursor: widget.showCursor, + toolbarOptions: widget.toolbarOptions, + semanticsLabel: widget.semanticsLabel, + selectionHeightStyle: widget.selectionHeightStyle, + selectionWidthStyle: widget.selectionWidthStyle, + ); + } + } + if (widget.data != null) { return Text( widget.data!, diff --git a/test/selectable_test.dart b/test/selectable_test.dart new file mode 100644 index 0000000..1c0552f --- /dev/null +++ b/test/selectable_test.dart @@ -0,0 +1,77 @@ +import 'package:auto_size_text/auto_size_text.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'utils.dart'; + +Widget testWidget(AutoSizeText text) { + return MaterialApp( + useInheritedMediaQuery: true, + home: text, + ); +} + +void main() { + testWidgets('Only Text', (tester) async { + await pump( + tester: tester, + widget: testWidget(AutoSizeText.selectable('Some Text')), + ); + }); + + testWidgets('Only text (rich)', (tester) async { + await pump( + tester: tester, + widget: testWidget(AutoSizeText.richSelectable(TextSpan(text: 'Some Text'))), + ); + }); + + testWidgets('Uses style fontSize', (tester) async { + await pumpAndExpectFontSize( + tester: tester, + expectedFontSize: 34, + selectable: true, + widget: testWidget(AutoSizeText.selectable( + 'Some Text', + style: TextStyle(fontSize: 34), + )), + ); + }); + + testWidgets('Uses style fontSize (rich)', (tester) async { + await pumpAndExpectFontSize( + tester: tester, + expectedFontSize: 35, + selectable: true, + widget: testWidget(AutoSizeText.richSelectable( + TextSpan(text: 'Some Text'), + style: TextStyle(fontSize: 35), + )), + ); + }); + + testWidgets('Applies scale even if initial fontSize fits (#25)', (tester) async { + await pumpAndExpectFontSize( + tester: tester, + expectedFontSize: 60, + selectable: true, + widget: testWidget(AutoSizeText.selectable( + 'Some Text', + style: TextStyle(fontSize: 15), + textScaleFactor: 4, + )), + ); + }); + + testWidgets('Uses textKey', (tester) async { + final textKey = GlobalKey(); + final text = await pumpAndGetSelectableText( + tester: tester, + widget: testWidget(AutoSizeText.selectable( + 'A text with key', + textKey: textKey, + )), + ); + expect(text.key, textKey); + }); +} diff --git a/test/utils.dart b/test/utils.dart index 9c33d88..d8d2fe4 100644 --- a/test/utils.dart +++ b/test/utils.dart @@ -5,8 +5,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -double effectiveFontSize(Text text) => - (text.textScaleFactor ?? 1) * text.style!.fontSize!; +double effectiveFontSize(Text text) => (text.textScaleFactor ?? 1) * text.style!.fontSize!; + +double selectableEffectiveFontSize(SelectableText text) => (text.textScaleFactor ?? 1) * text.style!.fontSize!; bool doesTextFit( Text text, [ @@ -33,9 +34,7 @@ bool doesTextFit( textPainter.layout(maxWidth: maxWidth); - return !(textPainter.didExceedMaxLines || - textPainter.height > maxHeight || - textPainter.width > maxWidth); + return !(textPainter.didExceedMaxLines || textPainter.height > maxHeight || textPainter.width > maxWidth); } bool prepared = false; @@ -47,9 +46,7 @@ Future prepareTests(WidgetTester tester) async { tester.binding.addTime(Duration(seconds: 10)); prepared = true; - final fontData = File('test/assets/Roboto-Regular.ttf') - .readAsBytes() - .then((bytes) => ByteData.view(Uint8List.fromList(bytes).buffer)); + final fontData = File('test/assets/Roboto-Regular.ttf').readAsBytes().then((bytes) => ByteData.view(Uint8List.fromList(bytes).buffer)); final fontLoader = FontLoader('Roboto')..addFont(fontData); await fontLoader.load(); @@ -77,17 +74,33 @@ Future pumpAndGetText({ return tester.widget(find.byType(Text)); } +Future pumpAndGetSelectableText({ + required WidgetTester tester, + required Widget widget, +}) async { + await pump(tester: tester, widget: widget); + return tester.widget(find.byType(SelectableText)); +} + Future pumpAndExpectFontSize({ required WidgetTester tester, required double expectedFontSize, required Widget widget, + bool selectable = false, }) async { + if (selectable) { + final text = await pumpAndGetSelectableText(tester: tester, widget: widget); + expect(selectableEffectiveFontSize(text), expectedFontSize); + return; + } + final text = await pumpAndGetText(tester: tester, widget: widget); expect(effectiveFontSize(text), expectedFontSize); } -RichText getRichText(WidgetTester tester) => - tester.widget(find.byType(RichText)); +RichText getRichText(WidgetTester tester) => tester.widget(find.byType(RichText)); + +EditableText getEditableText(WidgetTester tester) => tester.widget(find.byType(EditableText)); class OverflowNotifier extends StatelessWidget { final VoidCallback overflowCallback;