diff --git a/examples/api/lib/material/selectable_region/selectable_region.0.dart b/examples/api/lib/material/selectable_region/selectable_region.0.dart index 869d5a0195..7ab61b1461 100644 --- a/examples/api/lib/material/selectable_region/selectable_region.0.dart +++ b/examples/api/lib/material/selectable_region/selectable_region.0.dart @@ -94,7 +94,7 @@ class _RenderSelectableAdapter extends RenderProxyBox with Selectable, Selection final ValueNotifier _geometry; Color get selectionColor => _selectionColor; - late Color _selectionColor; + Color _selectionColor; set selectionColor(Color value) { if (_selectionColor == value) { return; @@ -272,6 +272,20 @@ class _RenderSelectableAdapter extends RenderProxyBox with Selectable, Selection return value.hasSelection ? const SelectedContent(plainText: 'Custom Text') : null; } + @override + SelectedContentRange? getSelection() { + if (!value.hasSelection) { + return null; + } + return const SelectedContentRange( + startOffset: 0, + endOffset: 1, + ); + } + + @override + int get contentLength => 1; + LayerLink? _startHandle; LayerLink? _endHandle; diff --git a/examples/api/lib/material/selection_area/selection_area.1.dart b/examples/api/lib/material/selection_area/selection_area.1.dart new file mode 100644 index 0000000000..c4c40ee5b3 --- /dev/null +++ b/examples/api/lib/material/selection_area/selection_area.1.dart @@ -0,0 +1,136 @@ +// 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. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// Flutter code sample for [SelectionArea]. + +void main() => runApp(const SelectionAreaSelectionListenerExampleApp()); + +class SelectionAreaSelectionListenerExampleApp extends StatelessWidget { + const SelectionAreaSelectionListenerExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title}); + + final String title; + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + final SelectionListenerNotifier _selectionNotifier = SelectionListenerNotifier(); + SelectableRegionSelectionStatus? _selectableRegionStatus; + + void _handleOnSelectionStateChanged(SelectableRegionSelectionStatus status) { + setState(() { + _selectableRegionStatus = status; + }); + } + + @override + void dispose() { + _selectionNotifier.dispose(); + _selectableRegionStatus = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: Text(widget.title), + ), + body: Center( + child: Column( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final (int? offset, String label) in <(int? offset, String label)>[ + (_selectionNotifier.registered ? _selectionNotifier.selection.range?.startOffset : null, 'StartOffset'), + (_selectionNotifier.registered ? _selectionNotifier.selection.range?.endOffset : null, 'EndOffset'), + ]) + Text('Selection $label: $offset'), + Text('Selection Status: ${_selectionNotifier.registered ? _selectionNotifier.selection.status : 'SelectionListenerNotifier not registered.'}'), + Text('Selectable Region Status: $_selectableRegionStatus'), + ], + ), + const SizedBox(height: 15.0,), + SelectionArea( + child: MySelectableText( + selectionNotifier: _selectionNotifier, + onChanged: _handleOnSelectionStateChanged, + ), + ), + ], + ), + ), + ); + } +} + +class MySelectableText extends StatefulWidget { + const MySelectableText({ + super.key, + required this.selectionNotifier, + required this.onChanged, + }); + + final SelectionListenerNotifier selectionNotifier; + final ValueChanged onChanged; + + @override + State createState() => _MySelectableTextState(); +} + +class _MySelectableTextState extends State { + ValueListenable? _selectableRegionScope; + + void _handleOnSelectableRegionChanged() { + if (_selectableRegionScope == null) { + return; + } + widget.onChanged.call(_selectableRegionScope!.value); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _selectableRegionScope?.removeListener(_handleOnSelectableRegionChanged); + _selectableRegionScope = SelectableRegionSelectionStatusScope.maybeOf(context); + _selectableRegionScope?.addListener(_handleOnSelectableRegionChanged); + } + + @override + void dispose() { + _selectableRegionScope?.removeListener(_handleOnSelectableRegionChanged); + _selectableRegionScope = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SelectionListener( + selectionNotifier: widget.selectionNotifier, + child: const Text('This is some text under a SelectionArea that can be selected.'), + ); + } +} diff --git a/examples/api/lib/material/selection_area/selection_area.2.dart b/examples/api/lib/material/selection_area/selection_area.2.dart new file mode 100644 index 0000000000..a4b7dfe4c0 --- /dev/null +++ b/examples/api/lib/material/selection_area/selection_area.2.dart @@ -0,0 +1,407 @@ +// 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. + +import 'dart:math'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +/// Flutter code sample for [SelectionArea]. + +void main() => runApp(const SelectionAreaColorTextRedExampleApp()); + +class SelectionAreaColorTextRedExampleApp extends StatelessWidget { + const SelectionAreaColorTextRedExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title}); + + final String title; + + @override + State createState() => _MyHomePageState(); +} + +typedef LocalSpanRange = ({int startOffset, int endOffset}); + +class _MyHomePageState extends State { + final SelectionListenerNotifier _selectionNotifier = SelectionListenerNotifier(); + final ContextMenuController _menuController = ContextMenuController(); + final GlobalKey selectionAreaKey = GlobalKey(); + + // The data of the top level TextSpans. Each TextSpan is mapped to a LocalSpanRange, + // which is the range the textspan covers relative to the SelectionListener it is under. + Map dataSourceMap = {}; + // The data of the bulleted list contained within a WidgetSpan. Each bullet is mapped + // to a LocalSpanRange, being the range the bullet covers relative to the SelectionListener + // it is under. + Map bulletSourceMap = {}; + Map> widgetSpanMaps = >{}; + // The origin data used to restore the demo to its initial state. + late final Map originSourceData; + late final Map originBulletSourceData; + + void _initData() { + const String bulletListTitle = 'This is some bulleted list:\n'; + final List bullets = [ + for (int i = 1; i <= 7; i += 1) + '• Bullet $i' + ]; + final TextSpan bulletedList = TextSpan( + text: bulletListTitle, + children: [ + WidgetSpan( + child: Column( + children: [ + for (final String bullet in bullets) + Padding( + padding: const EdgeInsets.only(left: 20.0), + child: Text(bullet), + ) + ], + ), + ), + ], + ); + + int currentOffset = 0; + // Map bulleted list span to a local range using its concrete length calculated + // from the length of its title and each individual bullet. + dataSourceMap[(startOffset: currentOffset, endOffset: bulletListTitle.length + bullets.join().length)] = bulletedList; + currentOffset += bulletListTitle.length; + widgetSpanMaps[currentOffset] = bulletSourceMap; + // Map individual bullets to a local range. + for (final String bullet in bullets) { + bulletSourceMap[(startOffset: currentOffset, endOffset: currentOffset + bullet.length)] = TextSpan(text: bullet); + currentOffset += bullet.length; + } + + const TextSpan secondTextParagraph = TextSpan( + text: 'This is some text in a text widget.', + children: [TextSpan(text: ' This is some more text in the same text widget.')], + ); + const TextSpan thirdTextParagraph = TextSpan(text: 'This is some text in another text widget.'); + // Map second and third paragraphs to local ranges. + dataSourceMap[(startOffset: currentOffset, endOffset: currentOffset + secondTextParagraph.toPlainText(includeSemanticsLabels: false).length)] = secondTextParagraph; + currentOffset += secondTextParagraph.toPlainText(includeSemanticsLabels: false).length; + dataSourceMap[(startOffset: currentOffset, endOffset: currentOffset + thirdTextParagraph.toPlainText(includeSemanticsLabels: false).length)] = thirdTextParagraph; + + // Save the origin data so we can revert our changes. + originSourceData = {}; + for (final MapEntry entry in dataSourceMap.entries) { + originSourceData[entry.key] = entry.value; + } + originBulletSourceData = {}; + for (final MapEntry entry in bulletSourceMap.entries) { + originBulletSourceData[entry.key] = entry.value; + } + } + + void _handleSelectableRegionStatusChanged(SelectableRegionSelectionStatus status) { + if (_menuController.isShown) { + ContextMenuController.removeAny(); + } + if (_selectionNotifier.selection.status != SelectionStatus.uncollapsed + || status != SelectableRegionSelectionStatus.finalized) { + return; + } + if (selectionAreaKey.currentState == null + || !selectionAreaKey.currentState!.mounted + || selectionAreaKey.currentState!.selectableRegion.contextMenuAnchors.secondaryAnchor == null) { + return; + } + final SelectedContentRange? selectedContentRange = _selectionNotifier.selection.range; + if (selectedContentRange == null) { + return; + } + _menuController.show( + context: context, + contextMenuBuilder: (BuildContext context) { + return TapRegion( + onTapOutside: (PointerDownEvent event) { + if (_menuController.isShown) { + ContextMenuController.removeAny(); + } + }, + child: AdaptiveTextSelectionToolbar.buttonItems( + buttonItems: [ + ContextMenuButtonItem( + onPressed: () { + ContextMenuController.removeAny(); + _colorSelectionRed( + selectedContentRange, + dataMap: dataSourceMap, + coloringChildSpan: false, + ); + selectionAreaKey.currentState!.selectableRegion.clearSelection(); + }, + label: 'Color Text Red', + ), + ], + anchors: TextSelectionToolbarAnchors(primaryAnchor: selectionAreaKey.currentState!.selectableRegion.contextMenuAnchors.secondaryAnchor!), + ), + ); + }, + ); + } + + void _colorSelectionRed( + SelectedContentRange selectedContentRange, { + required Map dataMap, + required bool coloringChildSpan, + }) { + for (final MapEntry entry in dataMap.entries) { + final LocalSpanRange entryLocalRange = entry.key; + final int normalizedStartOffset = min(selectedContentRange.startOffset, selectedContentRange.endOffset); + final int normalizedEndOffset = max(selectedContentRange.startOffset, selectedContentRange.endOffset); + if (normalizedStartOffset > entryLocalRange.endOffset) { + continue; + } + if (normalizedEndOffset < entryLocalRange.startOffset) { + continue; + } + // The selection details is covering the current entry so let's color the range red. + final TextSpan rawSpan = entry.value; + // Determine local ranges relative to rawSpan. + final int clampedLocalStart = normalizedStartOffset < entryLocalRange.startOffset ? entryLocalRange.startOffset : normalizedStartOffset; + final int clampedLocalEnd = normalizedEndOffset > entryLocalRange.endOffset ? entryLocalRange.endOffset : normalizedEndOffset; + final int startOffset = (clampedLocalStart - entryLocalRange.startOffset).abs(); + final int endOffset = startOffset + (clampedLocalEnd - clampedLocalStart).abs(); + final List beforeSelection = []; + final List insideSelection = []; + final List afterSelection = []; + int count = 0; + rawSpan.visitChildren((InlineSpan child) { + if (child is TextSpan) { + final String? rawText = child.text; + if (rawText != null) { + if (count < startOffset) { + final int newStart = min(startOffset - count, rawText.length); + final int globalNewStart = count + newStart; + // Collect spans before selection. + beforeSelection.add( + TextSpan( + style: child.style, + text: rawText.substring(0, newStart), + ), + ); + // Check if this span also contains the selection. + if (globalNewStart == startOffset && newStart < rawText.length) { + final int newStartAfterSelection = min(newStart + (endOffset - startOffset), rawText.length); + final int globalNewStartAfterSelection = count + newStartAfterSelection; + insideSelection.add( + TextSpan( + style: const TextStyle(color: Colors.red).merge(entry.value.style), + text: rawText.substring(newStart, newStartAfterSelection), + ), + ); + // Check if this span contains content after the selection. + if (globalNewStartAfterSelection == endOffset && newStartAfterSelection < rawText.length) { + afterSelection.add( + TextSpan( + style: child.style, + text: rawText.substring(newStartAfterSelection), + ), + ); + } + } + } else if (count >= endOffset) { + // Collect spans after selection. + afterSelection.add(TextSpan(style: child.style, text: rawText)); + } else { + // Collect spans inside selection. + final int newStart = min(endOffset - count, rawText.length); + final int globalNewStart = count + newStart; + insideSelection.add(TextSpan(style: const TextStyle(color: Colors.red), text: rawText.substring(0, newStart))); + // Check if this span contains content after the selection. + if (globalNewStart == endOffset && newStart < rawText.length) { + afterSelection.add(TextSpan(style: child.style, text: rawText.substring(newStart))); + } + } + count += rawText.length; + } + } else if (child is WidgetSpan) { + if (!widgetSpanMaps.containsKey(count)) { + // We have arrived at a WidgetSpan but it is unaccounted for. + return true; + } + final Map widgetSpanSourceMap = widgetSpanMaps[count]!; + if (count < startOffset && count + (widgetSpanSourceMap.keys.last.endOffset - widgetSpanSourceMap.keys.first.startOffset).abs() < startOffset) { + // When the count is less than the startOffset and we are at a widgetspan + // it is still possible that the startOffset is somewhere within the widgetspan, + // so we should try to color the selection red for the widgetspan. + // + // If the calculated widgetspan length would not extend the count past the + // startOffset then add this widgetspan to the beforeSelection, and + // continue walking the tree. + beforeSelection.add(child); + count += (widgetSpanSourceMap.keys.last.endOffset - widgetSpanSourceMap.keys.first.startOffset).abs(); + return true; + } else if (count >= endOffset) { + afterSelection.add(child); + count += (widgetSpanSourceMap.keys.last.endOffset - widgetSpanSourceMap.keys.first.startOffset).abs(); + return true; + } + // Update widgetspan data. + _colorSelectionRed( + selectedContentRange, + dataMap: widgetSpanSourceMap, + coloringChildSpan: true, + ); + // Re-create widgetspan. + if (count == 28) { // The index where the bulleted list begins. + insideSelection.add( + WidgetSpan( + child: Column( + children: [ + for (final MapEntry entry in widgetSpanSourceMap.entries) + Padding( + padding: const EdgeInsets.only(left: 20.0), + child: Text.rich( + widgetSpanSourceMap[entry.key]!, + ), + ) + ], + ), + ), + ); + } + count += (widgetSpanSourceMap.keys.last.endOffset - widgetSpanSourceMap.keys.first.startOffset).abs(); + return true; + } + return true; + }); + dataMap[entry.key] = TextSpan( + style: dataMap[entry.key]!.style, + children: [ + ...beforeSelection, + ...insideSelection, + ...afterSelection, + ], + ); + } + // Avoid clearing the selection and setting the state + // before we have colored all parts of the selection. + if (!coloringChildSpan) { + setState(() {}); + } + } + + @override + void initState() { + super.initState(); + _initData(); + } + + @override + void dispose() { + _selectionNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: Text(widget.title), + ), + body: SelectionArea( + key: selectionAreaKey, + child: MySelectableTextColumn( + selectionNotifier: _selectionNotifier, + dataSourceMap: dataSourceMap, + onChanged: _handleSelectableRegionStatusChanged, + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + setState(() { + // Resets the state to the origin data. + for (final MapEntry entry in originSourceData.entries) { + dataSourceMap[entry.key] = entry.value; + } + for (final MapEntry entry in originBulletSourceData.entries) { + bulletSourceMap[entry.key] = entry.value; + } + }); + }, + child: const Icon(Icons.undo), + ), + ); + } +} + +class MySelectableTextColumn extends StatefulWidget { + const MySelectableTextColumn({ + super.key, + required this.selectionNotifier, + required this.dataSourceMap, + required this.onChanged, + }); + + final SelectionListenerNotifier selectionNotifier; + final Map dataSourceMap; + final ValueChanged onChanged; + + @override + State createState() => _MySelectableTextColumnState(); +} + +class _MySelectableTextColumnState extends State { + ValueListenable? _selectableRegionScope; + + void _handleOnSelectableRegionChanged() { + if (_selectableRegionScope == null) { + return; + } + widget.onChanged.call(_selectableRegionScope!.value); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _selectableRegionScope?.removeListener(_handleOnSelectableRegionChanged); + _selectableRegionScope = SelectableRegionSelectionStatusScope.maybeOf(context); + _selectableRegionScope?.addListener(_handleOnSelectableRegionChanged); + } + + @override + void dispose() { + _selectableRegionScope?.removeListener(_handleOnSelectableRegionChanged); + _selectableRegionScope = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SelectionListener( + selectionNotifier: widget.selectionNotifier, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final MapEntry entry in widget.dataSourceMap.entries) + Text.rich( + entry.value, + ), + ], + ), + ), + ); + } +} diff --git a/examples/api/test/material/selection_area/selection_area.1_test.dart b/examples/api/test/material/selection_area/selection_area.1_test.dart new file mode 100644 index 0000000000..238c986d65 --- /dev/null +++ b/examples/api/test/material/selection_area/selection_area.1_test.dart @@ -0,0 +1,21 @@ +// 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. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/selection_area/selection_area.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('SelectionArea SelectionListener Example Smoke Test', (WidgetTester tester) async { + await tester.pumpWidget( + const example.SelectionAreaSelectionListenerExampleApp(), + ); + expect(find.byType(Column), findsNWidgets(2)); + expect(find.textContaining('Selection StartOffset:'), findsOneWidget); + expect(find.textContaining('Selection EndOffset:'), findsOneWidget); + expect(find.textContaining('Selection Status:'), findsOneWidget); + expect(find.textContaining('Selectable Region Status:'), findsOneWidget); + expect(find.textContaining('This is some text under a SelectionArea that can be selected.'), findsOneWidget); + }); +} diff --git a/examples/api/test/material/selection_area/selection_area.2_test.dart b/examples/api/test/material/selection_area/selection_area.2_test.dart new file mode 100644 index 0000000000..319162ae2a --- /dev/null +++ b/examples/api/test/material/selection_area/selection_area.2_test.dart @@ -0,0 +1,125 @@ +// 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. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_api_samples/material/selection_area/selection_area.2.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('SelectionArea Color Text Red Example Smoke Test', (WidgetTester tester) async { + await tester.pumpWidget( + const example.SelectionAreaColorTextRedExampleApp(), + ); + expect(find.widgetWithIcon(FloatingActionButton, Icons.undo), findsOneWidget); + expect(find.byType(Column), findsNWidgets(2)); + expect(find.textContaining('This is some bulleted list:\n'), findsOneWidget); + for (int i = 1; i <= 7; i += 1) { + expect(find.widgetWithText(Text, '• Bullet $i'), findsOneWidget); + } + expect(find.textContaining('This is some text in a text widget.'), findsOneWidget); + expect(find.textContaining(' This is some more text in the same text widget.'), findsOneWidget); + expect(find.textContaining('This is some text in another text widget.'), findsOneWidget); + }); + + testWidgets('SelectionArea Color Text Red Example - colors selected range red', (WidgetTester tester) async { + await tester.pumpWidget( + const example.SelectionAreaColorTextRedExampleApp(), + ); + await tester.pumpAndSettle(); + final Finder paragraph1Finder = find.descendant(of: find.textContaining('This is some bulleted list').first, matching: find.byType(RichText).first); + final Finder paragraph3Finder = find.descendant(of: find.textContaining('This is some text in another text widget.'), matching: find.byType(RichText)); + final RenderParagraph paragraph1 = tester.renderObject(paragraph1Finder); + final List bullets = tester.renderObjectList(find.descendant(of: find.textContaining('• Bullet'), matching: find.byType(RichText))).toList(); + expect(bullets.length, 7); + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.textContaining('This is some text in a text widget.'), matching: find.byType(RichText))); + final RenderParagraph paragraph3 = tester.renderObject(paragraph3Finder); + // Drag to select from paragraph 1 position 4 to paragraph 3 position 25. + final TestGesture gesture = await tester.startGesture(tester.getRect(paragraph1Finder).topLeft + const Offset(50.0, 10.0), kind: PointerDeviceKind.mouse); + addTearDown(gesture.removePointer); + await tester.pump(); + await gesture.moveTo(tester.getRect(paragraph3Finder).centerLeft + const Offset(360.0, 0.0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + // Verify selection. + // Bulleted list title. + expect(paragraph1.selections.length, 1); + expect(paragraph1.selections[0], const TextSelection(baseOffset: 4, extentOffset: 27)); + // Bulleted list. + for (final RenderParagraph paragraphBullet in bullets) { + expect(paragraphBullet.selections.length, 1); + expect(paragraphBullet.selections[0], const TextSelection(baseOffset: 0, extentOffset: 10)); + } + // Second text widget. + expect(paragraph2.selections.length, 1); + expect(paragraph2.selections[0], const TextSelection(baseOffset: 0, extentOffset: 83)); + // Third text widget. + expect(paragraph3.selections.length, 1); + expect(paragraph3.selections[0], const TextSelection(baseOffset: 0, extentOffset: 25)); + + // Color selection red. + expect(find.textContaining('Color Text Red'), findsOneWidget); + await tester.tap(find.textContaining('Color Text Red')); + await tester.pumpAndSettle(); + + // Verify selection is red. + final TextSpan paragraph1ResultingSpan = paragraph1.text as TextSpan; + final TextSpan paragraph2ResultingSpan = paragraph2.text as TextSpan; + final TextSpan paragraph3ResultingSpan = paragraph3.text as TextSpan; + // Title of bulleted list is partially red. + expect(paragraph1ResultingSpan.children, isNotNull); + expect(paragraph1ResultingSpan.children!.length, 1); + expect((paragraph1ResultingSpan.children![0] as TextSpan).children, isNotNull); + expect((paragraph1ResultingSpan.children![0] as TextSpan).children!.length, 3); + expect((paragraph1ResultingSpan.children![0] as TextSpan).children![0].style, isNull); + expect((paragraph1ResultingSpan.children![0] as TextSpan).children![1], isA()); + expect(((paragraph1ResultingSpan.children![0] as TextSpan).children![1] as TextSpan).text, isNotNull); + expect(((paragraph1ResultingSpan.children![0] as TextSpan).children![1] as TextSpan).text, ' is some bulleted list:\n'); + expect((paragraph1ResultingSpan.children![0] as TextSpan).children![1].style, isNotNull); + expect((paragraph1ResultingSpan.children![0] as TextSpan).children![1].style!.color, isNotNull); + expect((paragraph1ResultingSpan.children![0] as TextSpan).children![1].style!.color, Colors.red); + expect((paragraph1ResultingSpan.children![0] as TextSpan).children![2], isA()); + // Bullets are red. + for (final RenderParagraph paragraphBullet in bullets) { + final TextSpan resultingBulletSpan = paragraphBullet.text as TextSpan; + expect(resultingBulletSpan.children, isNotNull); + expect(resultingBulletSpan.children!.length, 1); + expect(resultingBulletSpan.children![0], isA()); + expect((resultingBulletSpan.children![0] as TextSpan).children, isNotNull); + expect((resultingBulletSpan.children![0] as TextSpan).children!.length, 1); + expect((resultingBulletSpan.children![0] as TextSpan).children![0], isA()); + expect(((resultingBulletSpan.children![0] as TextSpan).children![0] as TextSpan).style, isNotNull); + expect(((resultingBulletSpan.children![0] as TextSpan).children![0] as TextSpan).style!.color, isNotNull); + expect(((resultingBulletSpan.children![0] as TextSpan).children![0] as TextSpan).style!.color, Colors.red); + } + // Second text widget is red. + expect(paragraph2ResultingSpan.children, isNotNull); + expect(paragraph2ResultingSpan.children!.length, 1); + expect(paragraph2ResultingSpan.children![0], isA()); + expect((paragraph2ResultingSpan.children![0] as TextSpan).children, isNotNull); + for (final InlineSpan span in (paragraph2ResultingSpan.children![0] as TextSpan).children!) { + if (span is TextSpan) { + expect(span.style, isNotNull); + expect(span.style!.color, isNotNull); + expect(span.style!.color, Colors.red); + } + } + // Part of third text widget is red. + expect(paragraph3ResultingSpan.children, isNotNull); + expect(paragraph3ResultingSpan.children!.length, 1); + expect(paragraph3ResultingSpan.children![0], isA()); + expect((paragraph3ResultingSpan.children![0] as TextSpan).children, isNotNull); + expect((paragraph3ResultingSpan.children![0] as TextSpan).children!.length, 2); + expect((paragraph3ResultingSpan.children![0] as TextSpan).children![0], isA()); + expect(((paragraph3ResultingSpan.children![0] as TextSpan).children![0] as TextSpan).text, isNotNull); + expect(((paragraph3ResultingSpan.children![0] as TextSpan).children![0] as TextSpan).text, 'This is some text in ano'); + expect((paragraph3ResultingSpan.children![0] as TextSpan).children![0].style, isNotNull); + expect((paragraph3ResultingSpan.children![0] as TextSpan).children![0].style!.color, isNotNull); + expect((paragraph3ResultingSpan.children![0] as TextSpan).children![0].style!.color, Colors.red); + expect((paragraph3ResultingSpan.children![0] as TextSpan).children![1].style, isNull); + }); +} diff --git a/packages/flutter/lib/src/material/selection_area.dart b/packages/flutter/lib/src/material/selection_area.dart index 41c949032b..e1158a470f 100644 --- a/packages/flutter/lib/src/material/selection_area.dart +++ b/packages/flutter/lib/src/material/selection_area.dart @@ -41,6 +41,8 @@ import 'theme.dart'; /// /// * [SelectableRegion], which provides an overview of the selection system. /// * [SelectableText], which enables selection on a single run of text. +/// * [SelectionListener], which enables accessing the [SelectionDetails] of +/// the selectable subtree it wraps. class SelectionArea extends StatefulWidget { /// Creates a [SelectionArea]. /// diff --git a/packages/flutter/lib/src/rendering/paragraph.dart b/packages/flutter/lib/src/rendering/paragraph.dart index afe956d96d..47d4d3e8c2 100644 --- a/packages/flutter/lib/src/rendering/paragraph.dart +++ b/packages/flutter/lib/src/rendering/paragraph.dart @@ -463,6 +463,9 @@ class RenderParagraph extends RenderBox with ContainerRenderObjectMixin range.end - range.start; + @override Size get size { return _rect.size; diff --git a/packages/flutter/lib/src/rendering/selection.dart b/packages/flutter/lib/src/rendering/selection.dart index f8d3b60a1f..f486529f5b 100644 --- a/packages/flutter/lib/src/rendering/selection.dart +++ b/packages/flutter/lib/src/rendering/selection.dart @@ -90,6 +90,11 @@ abstract class SelectionHandler implements ValueListenable { /// Return `null` if nothing is selected. SelectedContent? getSelectedContent(); + /// Gets the [SelectedContentRange] representing the selected range in this object. + /// + /// When nothing is selected, subclasses should return `null`. + SelectedContentRange? getSelection(); + /// Handles the [SelectionEvent] sent to this object. /// /// The subclasses need to update their selections or delegate the @@ -102,19 +107,113 @@ abstract class SelectionHandler implements ValueListenable { /// See also: /// * [SelectionEventType], which contains all of the possible types. SelectionResult dispatchSelectionEvent(SelectionEvent event); + + /// The length of the content in this object. + int get contentLength; +} + +/// This class stores the range information of the selection under a [Selectable] +/// or [SelectionHandler]. +/// +/// The [SelectedContentRange] for a given [Selectable] or [SelectionHandler] +/// can be retrieved by calling [SelectionHandler.getSelection]. +@immutable +class SelectedContentRange with Diagnosticable { + /// Creates a [SelectedContentRange] with the given values. + const SelectedContentRange({ + required this.startOffset, + required this.endOffset, + }) : assert((startOffset >= 0 && endOffset >= 0)); + + /// The start of the selection relative to the start of the content. + /// + /// {@template flutter.rendering.selection.SelectedContentRange.selectionOffsets} + /// For example a [Text] widget's content is in the format of an [TextSpan] tree. + /// + /// Take the [Text] widget and [TextSpan] tree below: + /// + /// {@tool snippet} + /// ```dart + /// const Text.rich( + /// TextSpan( + /// text: 'Hello world, ', + /// children: [ + /// WidgetSpan( + /// child: Text('how are you today? '), + /// ), + /// TextSpan( + /// text: 'Good, thanks for asking.', + /// ), + /// ], + /// ), + /// ) + /// ``` + /// {@end-tool} + /// + /// If we select from the beginning of 'world' to the end of the '.' + /// at the end of the [TextSpan] tree, the [SelectedContentRange] from + /// [SelectionHandler.getSelection] will be relative to the text of the + /// [TextSpan] tree, with [WidgetSpan] content being flattened. The [startOffset] + /// will be 6, and [endOffset] will be 56. This takes into account the + /// length of the content in the [WidgetSpan], which is 19, making the overall + /// length of the content 56. + /// {@endtemplate} + final int startOffset; + + /// The end of the selection relative to the start of the content. + /// + /// {@macro flutter.rendering.selection.SelectedContentRange.selectionOffsets} + final int endOffset; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + if (other.runtimeType != runtimeType) { + return false; + } + return other is SelectedContentRange + && other.startOffset == startOffset + && other.endOffset == endOffset; + } + + @override + int get hashCode { + return Object.hash( + startOffset, + endOffset, + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(IntProperty('startOffset', startOffset)); + properties.add(IntProperty('endOffset', endOffset)); + } } /// The selected content in a [Selectable] or [SelectionHandler]. // TODO(chunhtai): Add more support for rich content. // https://github.com/flutter/flutter/issues/104206. -class SelectedContent { +@immutable +class SelectedContent with Diagnosticable { /// Creates a selected content object. /// /// Only supports plain text. - const SelectedContent({required this.plainText}); + const SelectedContent({ + required this.plainText, + }); /// The selected content in plain text format. final String plainText; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('plainText', plainText)); + } } /// A mixin that can be selected by users when under a [SelectionArea] widget. @@ -138,7 +237,7 @@ class SelectedContent { /// {@macro flutter.rendering.SelectionHandler} /// /// See also: -/// * [SelectionArea], which provides an overview of selection system. +/// * [SelectableRegion], which provides an overview of selection system. mixin Selectable implements SelectionHandler { /// {@macro flutter.rendering.RenderObject.getTransformTo} Matrix4 getTransformTo(RenderObject? ancestor); @@ -628,7 +727,7 @@ enum SelectionStatus { /// The positions in geometry are in local coordinates of the [SelectionHandler] /// or [Selectable]. @immutable -class SelectionGeometry { +class SelectionGeometry with Diagnosticable { /// Creates a selection geometry object. /// /// If any of the [startSelectionPoint] and [endSelectionPoint] is not null, @@ -728,6 +827,16 @@ class SelectionGeometry { hasContent, ); } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('startSelectionPoint', startSelectionPoint)); + properties.add(DiagnosticsProperty('endSelectionPoint', endSelectionPoint)); + properties.add(IterableProperty('selectionRects', selectionRects)); + properties.add(EnumProperty('status', status)); + properties.add(DiagnosticsProperty('hasContent', hasContent)); + } } /// The geometry information of a selection point. diff --git a/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_web.dart b/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_web.dart index 5de83bb309..e18e41187f 100644 --- a/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_web.dart +++ b/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_web.dart @@ -14,7 +14,7 @@ import 'platform_view.dart'; import 'selection_container.dart'; const String _viewType = 'Browser__WebContextMenuViewType__'; -const String _kClassName = 'web-electable-region-context-menu'; +const String _kClassName = 'web-selectable-region-context-menu'; // These css rules hides the dom element with the class name. const String _kClassSelectionRule = '.$_kClassName::selection { background: transparent; }'; const String _kClassRule = ''' diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart index 430eb31674..a0f2df72df 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -186,6 +186,28 @@ const double _kSelectableVerticalComparingThreshold = 3.0; /// child selection area can not extend past its subtree, and the selection of /// the parent selection area can not extend inside the child selection area. /// +/// ## Selection status +/// +/// A [SelectableRegion]s [SelectableRegionSelectionStatus] is used to indicate whether +/// the [SelectableRegion] is actively changing the selection, or has finalized it. For +/// example, during a mouse click + drag, the [SelectableRegionSelectionStatus] will be +/// set to [SelectableRegionSelectionStatus.changing], and when the mouse click is released +/// the status will be set to [SelectableRegionSelectionStatus.finalized]. +/// +/// The default value of [SelectableRegion]s selection status +/// is [SelectableRegionSelectionStatus.finalized]. +/// +/// To access the [SelectableRegionSelectionStatus] of a parent [SelectableRegion] +/// use [SelectableRegionSelectionStatusScope.maybeOf] and retrieve the value from +/// the [ValueListenable]. +/// +/// One can also listen for changes to the [SelectableRegionSelectionStatus] by +/// adding a listener to the [ValueListenable] retrieved from [SelectableRegionSelectionStatusScope.maybeOf] +/// through [ValueListenable.addListener]. In Stateful widgets this is typically +/// done in [State.didChangeDependencies]. Remove the listener when no longer +/// needed, typically in your Stateful widgets [State.dispose] method through +/// [ValueListenable.removeListener]. +/// /// ## Tests /// /// In a test, a region can be selected either by faking drag events (e.g. using @@ -208,6 +230,8 @@ const double _kSelectableVerticalComparingThreshold = 3.0; /// selection events. /// * [SelectionContainer], which collects selectable widgets in the subtree /// and provides api to dispatch selection event to the collected widget. +/// * [SelectionListener], which enables accessing the [SelectionDetails] of +/// the selectable subtree it wraps. class SelectableRegion extends StatefulWidget { /// Create a new [SelectableRegion] widget. /// @@ -217,10 +241,10 @@ class SelectableRegion extends StatefulWidget { super.key, this.contextMenuBuilder, this.focusNode, - required this.selectionControls, - required this.child, this.magnifierConfiguration = TextMagnifierConfiguration.disabled, this.onSelectionChanged, + required this.selectionControls, + required this.child, }); /// The configuration for the magnifier used with selections in this region. @@ -375,6 +399,9 @@ class SelectableRegionState extends State with TextSelectionDe FocusNode? _localFocusNode; FocusNode get _focusNode => widget.focusNode ?? (_localFocusNode ??= FocusNode(debugLabel: 'SelectableRegion')); + /// Notifies its listeners when the selection state in this [SelectableRegion] changes. + final _SelectableRegionSelectionStatusNotifier _selectionStatusNotifier = _SelectableRegionSelectionStatusNotifier._(); + @protected @override void initState() { @@ -463,6 +490,8 @@ class SelectableRegionState extends State with TextSelectionDe // case we want to retain the selection so it remains when we return to // the Flutter application. clearSelection(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; } } if (kIsWeb) { @@ -658,9 +687,12 @@ class SelectableRegionState extends State with TextSelectionDe final bool isShiftPressedValid = _isShiftPressed && _selectionDelegate.value.startSelectionPoint != null; if (isShiftPressedValid) { _selectEndTo(offset: details.globalPosition); - return; + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + break; } + clearSelection(); _collapseSelectionAt(offset: details.globalPosition); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; } case 2: switch (defaultTargetPlatform) { @@ -671,6 +703,7 @@ class SelectableRegionState extends State with TextSelectionDe break; } _selectWordAt(offset: details.globalPosition); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; if (details.kind != null && !_isPrecisePointerDevice(details.kind!)) { _showHandles(); } @@ -680,6 +713,7 @@ class SelectableRegionState extends State with TextSelectionDe case TargetPlatform.linux: case TargetPlatform.windows: _selectWordAt(offset: details.globalPosition); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; } case 3: switch (defaultTargetPlatform) { @@ -690,11 +724,13 @@ class SelectableRegionState extends State with TextSelectionDe // Triple tap on static text is only supported on mobile // platforms using a precise pointer device. _selectParagraphAt(offset: details.globalPosition); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; } case TargetPlatform.macOS: case TargetPlatform.linux: case TargetPlatform.windows: _selectParagraphAt(offset: details.globalPosition); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; } } _updateSelectedContentIfNeeded(); @@ -708,6 +744,7 @@ class SelectableRegionState extends State with TextSelectionDe return; } _selectStartTo(offset: details.globalPosition); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; } _updateSelectedContentIfNeeded(); } @@ -720,6 +757,7 @@ class SelectableRegionState extends State with TextSelectionDe return; } _selectEndTo(offset: details.globalPosition, continuous: true); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; case 2: switch (defaultTargetPlatform) { case TargetPlatform.android: @@ -728,6 +766,7 @@ class SelectableRegionState extends State with TextSelectionDe // pointer device or when not on the web. if (!kIsWeb || details.kind != null && _isPrecisePointerDevice(details.kind!)) { _selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; } case TargetPlatform.iOS: if (kIsWeb && details.kind != null && !_isPrecisePointerDevice(details.kind!) && _doubleTapOffset != null) { @@ -737,6 +776,7 @@ class SelectableRegionState extends State with TextSelectionDe _doubleTapOffset = null; } _selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; if (details.kind != null && !_isPrecisePointerDevice(details.kind!)) { _showHandles(); } @@ -744,6 +784,7 @@ class SelectableRegionState extends State with TextSelectionDe case TargetPlatform.linux: case TargetPlatform.windows: _selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.word); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; } case 3: switch (defaultTargetPlatform) { @@ -754,11 +795,13 @@ class SelectableRegionState extends State with TextSelectionDe // a precise pointer device. if (details.kind != null && _isPrecisePointerDevice(details.kind!)) { _selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.paragraph); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; } case TargetPlatform.macOS: case TargetPlatform.linux: case TargetPlatform.windows: _selectEndTo(offset: details.globalPosition, continuous: true, textGranularity: TextGranularity.paragraph); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; } } _updateSelectedContentIfNeeded(); @@ -789,6 +832,7 @@ class SelectableRegionState extends State with TextSelectionDe } _finalizeSelection(); _updateSelectedContentIfNeeded(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; } void _handleMouseTapUp(TapDragUpDetails details) { @@ -811,11 +855,13 @@ class SelectableRegionState extends State with TextSelectionDe case TargetPlatform.iOS: hideToolbar(); _collapseSelectionAt(offset: details.globalPosition); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; case TargetPlatform.macOS: case TargetPlatform.linux: case TargetPlatform.windows: // On desktop platforms the selection is set on tap down. - break; + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; } case 2: final bool isPointerPrecise = _isPrecisePointerDevice(details.kind); @@ -830,6 +876,10 @@ class SelectableRegionState extends State with TextSelectionDe } case TargetPlatform.iOS: if (!isPointerPrecise) { + if (kIsWeb) { + // Double tap on iOS web only triggers when a drag begins after the double tap. + return; + } // On iOS, a double tap will only show the selection toolbar after // the following tap up when the pointer device kind is not precise. _showToolbar(); @@ -841,14 +891,35 @@ class SelectableRegionState extends State with TextSelectionDe // on a double click. break; } + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; + case 3: + switch (defaultTargetPlatform) { + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.iOS: + if (_isPrecisePointerDevice(details.kind)) { + // Triple tap on static text is only supported on mobile + // platforms using a precise pointer device, so we should + // only update the SelectableRegionSelectionStatus in that case. + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; + } + case TargetPlatform.macOS: + case TargetPlatform.linux: + case TargetPlatform.windows: + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; + } } _updateSelectedContentIfNeeded(); } void _updateSelectedContentIfNeeded() { - if (_lastSelectedContent?.plainText != _selectable?.getSelectedContent()?.plainText) { - _lastSelectedContent = _selectable?.getSelectedContent(); - widget.onSelectionChanged?.call(_lastSelectedContent); + if (widget.onSelectionChanged == null) { + return; + } + final SelectedContent? content = _selectable?.getSelectedContent(); + if (_lastSelectedContent?.plainText != content?.plainText) { + _lastSelectedContent = content; + widget.onSelectionChanged!.call(_lastSelectedContent); } } @@ -856,6 +927,7 @@ class SelectableRegionState extends State with TextSelectionDe HapticFeedback.selectionClick(); _focusNode.requestFocus(); _selectWordAt(offset: details.globalPosition); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; // Platforms besides Android will show the text selection handles when // the long press is initiated. Android shows the text selection handles when // the long press has ended, usually after a pointer up event is received. @@ -867,12 +939,14 @@ class SelectableRegionState extends State with TextSelectionDe void _handleTouchLongPressMoveUpdate(LongPressMoveUpdateDetails details) { _selectEndTo(offset: details.globalPosition, textGranularity: TextGranularity.word); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; _updateSelectedContentIfNeeded(); } void _handleTouchLongPressEnd(LongPressEndDetails details) { _finalizeSelection(); _updateSelectedContentIfNeeded(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; _showToolbar(); if (defaultTargetPlatform == TargetPlatform.android) { _showHandles(); @@ -902,23 +976,24 @@ class SelectableRegionState extends State with TextSelectionDe // If _lastSecondaryTapDownPosition is within the current selection then // keep the current selection, if not then collapse it. final bool lastSecondaryTapDownPositionWasOnActiveSelection = _positionIsOnActiveSelection(globalPosition: details.globalPosition); - if (!lastSecondaryTapDownPositionWasOnActiveSelection) { - _collapseSelectionAt(offset: _lastSecondaryTapDownPosition!); + if (lastSecondaryTapDownPositionWasOnActiveSelection) { + // Restore _lastSecondaryTapDownPosition since it may be cleared if a user + // accesses contextMenuAnchors. + _lastSecondaryTapDownPosition = details.globalPosition; + _showHandles(); + _showToolbar(location: _lastSecondaryTapDownPosition); + _updateSelectedContentIfNeeded(); + return; } - _showHandles(); - _showToolbar(location: _lastSecondaryTapDownPosition); + _collapseSelectionAt(offset: _lastSecondaryTapDownPosition!); case TargetPlatform.iOS: _selectWordAt(offset: _lastSecondaryTapDownPosition!); - _showHandles(); - _showToolbar(location: _lastSecondaryTapDownPosition); case TargetPlatform.macOS: if (previousSecondaryTapDownPosition == _lastSecondaryTapDownPosition && toolbarIsVisible) { hideToolbar(); return; } _selectWordAt(offset: _lastSecondaryTapDownPosition!); - _showHandles(); - _showToolbar(location: _lastSecondaryTapDownPosition); case TargetPlatform.linux: if (toolbarIsVisible) { hideToolbar(); @@ -930,9 +1005,14 @@ class SelectableRegionState extends State with TextSelectionDe if (!lastSecondaryTapDownPositionWasOnActiveSelection) { _collapseSelectionAt(offset: _lastSecondaryTapDownPosition!); } - _showHandles(); - _showToolbar(location: _lastSecondaryTapDownPosition); } + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; + // Restore _lastSecondaryTapDownPosition since it may be cleared if a user + // accesses contextMenuAnchors. + _lastSecondaryTapDownPosition = details.globalPosition; + _showHandles(); + _showToolbar(location: _lastSecondaryTapDownPosition); _updateSelectedContentIfNeeded(); } @@ -982,9 +1062,9 @@ class SelectableRegionState extends State with TextSelectionDe }, ); } - _stopSelectionStartEdgeUpdate(); - _stopSelectionEndEdgeUpdate(); + _finalizeSelection(); _updateSelectedContentIfNeeded(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; } void _stopSelectionEndEdgeUpdate() { @@ -1059,6 +1139,7 @@ class SelectableRegionState extends State with TextSelectionDe _selectionDelegate.value.startSelectionPoint!, )); _updateSelectedContentIfNeeded(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; } void _handleSelectionEndHandleDragStart(DragStartDetails details) { @@ -1086,6 +1167,7 @@ class SelectableRegionState extends State with TextSelectionDe _selectionDelegate.value.endSelectionPoint!, )); _updateSelectedContentIfNeeded(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; } MagnifierInfo _buildInfoForMagnifier(Offset globalGesturePosition, SelectionPoint selectionPoint) { @@ -1310,6 +1392,8 @@ class SelectableRegionState extends State with TextSelectionDe /// * [_selectParagraphAt], which selects an entire paragraph at the location. /// * [selectAll], which selects the entire content. void _collapseSelectionAt({required Offset offset}) { + // There may be other selection ongoing. + _finalizeSelection(); _selectStartTo(offset: offset); _selectEndTo(offset: offset); } @@ -1410,9 +1494,13 @@ class SelectableRegionState extends State with TextSelectionDe /// for the default context menu buttons. TextSelectionToolbarAnchors get contextMenuAnchors { if (_lastSecondaryTapDownPosition != null) { - return TextSelectionToolbarAnchors( + final TextSelectionToolbarAnchors anchors = TextSelectionToolbarAnchors( primaryAnchor: _lastSecondaryTapDownPosition!, ); + // Clear the state of _lastSecondaryTapDownPosition after use since a user may + // access contextMenuAnchors and receive invalid anchors for their context menu. + _lastSecondaryTapDownPosition = null; + return anchors; } final RenderBox renderBox = context.findRenderObject()! as RenderBox; return TextSelectionToolbarAnchors.fromSelection( @@ -1456,6 +1544,8 @@ class SelectableRegionState extends State with TextSelectionDe ), ); _updateSelectedContentIfNeeded(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; } double? _directionalHorizontalBaseline; @@ -1478,6 +1568,8 @@ class SelectableRegionState extends State with TextSelectionDe ), ); _updateSelectedContentIfNeeded(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; } // [TextSelectionDelegate] overrides. @@ -1509,6 +1601,8 @@ class SelectableRegionState extends State with TextSelectionDe case TargetPlatform.android: case TargetPlatform.fuchsia: clearSelection(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; case TargetPlatform.iOS: hideToolbar(false); case TargetPlatform.linux: @@ -1538,6 +1632,8 @@ class SelectableRegionState extends State with TextSelectionDe case TargetPlatform.android: case TargetPlatform.fuchsia: clearSelection(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; case TargetPlatform.iOS: hideToolbar(false); case TargetPlatform.linux: @@ -1638,6 +1734,8 @@ class SelectableRegionState extends State with TextSelectionDe _showHandles(); } _updateSelectedContentIfNeeded(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; } @Deprecated( @@ -1648,6 +1746,8 @@ class SelectableRegionState extends State with TextSelectionDe void copySelection(SelectionChangedCause cause) { _copy(); clearSelection(); + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; + _selectionStatusNotifier.value = SelectableRegionSelectionStatus.finalized; } @Deprecated( @@ -1713,6 +1813,7 @@ class SelectableRegionState extends State with TextSelectionDe _selectable?.removeListener(_updateSelectionStatus); _selectable?.pushHandleLayers(null, null); _selectionDelegate.dispose(); + _selectionStatusNotifier.dispose(); // In case dispose was triggered before gesture end, remove the magnifier // so it doesn't remain stuck in the overlay forever. _selectionOverlay?.hideMagnifier(); @@ -1728,10 +1829,13 @@ class SelectableRegionState extends State with TextSelectionDe @override Widget build(BuildContext context) { assert(debugCheckHasOverlay(context)); - Widget result = SelectionContainer( - registrar: this, - delegate: _selectionDelegate, - child: widget.child, + Widget result = SelectableRegionSelectionStatusScope._( + selectionStatusNotifier: _selectionStatusNotifier, + child: SelectionContainer( + registrar: this, + delegate: _selectionDelegate, + child: widget.child, + ), ); if (kIsWeb) { result = PlatformSelectableRegionContextMenu( @@ -2577,6 +2681,76 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai ); } + /// The total length of the content under this [SelectionContainerDelegate]. + /// + /// This value is derived from the [Selectable.contentLength] of each [Selectable] + /// managed by this delegate. + @override + int get contentLength => selectables.fold(0, (int sum, Selectable selectable) => sum + selectable.contentLength); + + /// This method calculates a local [SelectedContentRange] based on the list + /// of [selections] that are accumulated from the [Selectable] children under this + /// delegate. This calculation takes into account the accumulated content + /// length before the active selection, and returns null when either selection + /// edge has not been set. + SelectedContentRange? _calculateLocalRange(List<_SelectionInfo> selections) { + if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) { + return null; + } + int startOffset = 0; + int endOffset = 0; + bool foundStart = false; + bool forwardSelection = currentSelectionEndIndex >= currentSelectionStartIndex; + if (currentSelectionEndIndex == currentSelectionStartIndex) { + // Determining selection direction is innacurate if currentSelectionStartIndex == currentSelectionEndIndex. + // Use the range from the selectable within the selection as the source of truth for selection direction. + final SelectedContentRange rangeAtSelectableInSelection = selectables[currentSelectionStartIndex].getSelection()!; + forwardSelection = rangeAtSelectableInSelection.endOffset >= rangeAtSelectableInSelection.startOffset; + } + for (int index = 0; index < selections.length; index++) { + final _SelectionInfo selection = selections[index]; + if (selection.range == null) { + if (foundStart) { + return SelectedContentRange( + startOffset: forwardSelection ? startOffset : endOffset, + endOffset: forwardSelection ? endOffset : startOffset, + ); + } + startOffset += selection.contentLength; + endOffset = startOffset; + continue; + } + final int selectionStartNormalized = min(selection.range!.startOffset, selection.range!.endOffset); + final int selectionEndNormalized = max(selection.range!.startOffset, selection.range!.endOffset); + if (!foundStart) { + startOffset += selectionStartNormalized; + endOffset = startOffset + (selectionEndNormalized - selectionStartNormalized).abs(); + foundStart = true; + } else { + endOffset += (selectionEndNormalized - selectionStartNormalized).abs(); + } + } + assert(foundStart, 'The start of the selection has not been found despite this selection delegate having an existing currentSelectionStartIndex and currentSelectionEndIndex.'); + return SelectedContentRange( + startOffset: forwardSelection ? startOffset : endOffset, + endOffset: forwardSelection ? endOffset : startOffset, + ); + } + + /// Returns a [SelectedContentRange] considering the [SelectedContentRange] + /// from each [Selectable] child managed under this delegate. + /// + /// When nothing is selected or either selection edge has not been set, + /// this method will return `null`. + @override + SelectedContentRange? getSelection() { + final List<_SelectionInfo> selections = <_SelectionInfo>[ + for (final Selectable selectable in selectables) + (contentLength: selectable.contentLength, range: selectable.getSelection()), + ]; + return _calculateLocalRange(selections); + } + // Clears the selection on all selectables not in the range of // currentSelectionStartIndex..currentSelectionEndIndex. // @@ -3005,6 +3179,10 @@ abstract class MultiSelectableSelectionContainerDelegate extends SelectionContai } } +/// The length of the content that can be selected, and the range that is +/// selected. +typedef _SelectionInfo = ({int contentLength, SelectedContentRange? range}); + /// Signature for a widget builder that builds a context menu for the given /// [SelectableRegionState]. /// @@ -3016,3 +3194,277 @@ typedef SelectableRegionContextMenuBuilder = Widget Function( BuildContext context, SelectableRegionState selectableRegionState, ); + +/// The status of the selection under a [SelectableRegion]. +/// +/// This value can be accessed for a [SelectableRegion] by using +/// [SelectableRegionSelectionStatusScope.maybeOf]. +/// +/// This value under a [SelectableRegion] is updated frequently +/// during selection gestures such as clicks and taps to select +/// and keyboard shortcuts. +enum SelectableRegionSelectionStatus { + /// Indicates that the selection under a [SelectableRegion] is changing. + /// + /// A [SelectableRegion]s selection is changing when it is being + /// updated by user through selection gestures and keyboard shortcuts. + /// For example, during a text selection drag with a click + drag, + /// a [SelectableRegion]s selection is considered changing until + /// the user releases the click, then it will be considered finalized. + changing, + + /// Indicates that the selection under a [SelectableRegion] is finalized. + /// + /// A [SelectableRegion]s selection is finalized when it is no longer + /// being updated by the user through selection gestures or keyboard + /// shortcuts. For example, the selection will be finalized on a mouse + /// drag end, touch long press drag end, a single click to collapse the + /// selection, a double click/tap to select a word, ctrl + A / cmd + A to + /// select all, or a triple click/tap to select a paragraph. + finalized, +} + +/// Notifies its listeners when the [SelectableRegion] that created this object +/// is changing or finalizes its selection. +/// +/// To access the [_SelectableRegionSelectionStatusNotifier] from the nearest [SelectableRegion] +/// ancestor, use [SelectableRegionSelectionStatusScope.maybeOf]. +final class _SelectableRegionSelectionStatusNotifier extends ChangeNotifier implements ValueListenable { + _SelectableRegionSelectionStatusNotifier._(); + + SelectableRegionSelectionStatus _selectableRegionSelectionStatus = SelectableRegionSelectionStatus.finalized; + /// The current value of the [SelectableRegionSelectionStatus] of the [SelectableRegion] + /// that owns this object. + /// + /// Defaults to [SelectableRegionSelectionStatus.finalized]. + @override + SelectableRegionSelectionStatus get value => _selectableRegionSelectionStatus; + + /// Sets the [SelectableRegionSelectionStatus] for the [SelectableRegion] that + /// owns this object. + /// + /// Listeners are notified even if the value did not change. + @protected + set value(SelectableRegionSelectionStatus newStatus) { + _selectableRegionSelectionStatus = newStatus; + notifyListeners(); + } +} + +/// Notifies its listeners when the selection under a [SelectableRegion] or +/// [SelectionArea] is being changed or finalized. +/// +/// Use [SelectableRegionSelectionStatusScope.maybeOf], to access the [ValueListenable] of type +/// [SelectableRegionSelectionStatus] under a [SelectableRegion]. Its listeners +/// will be called even when the value of the [SelectableRegionSelectionStatus] +/// does not change. +final class SelectableRegionSelectionStatusScope extends InheritedWidget { + const SelectableRegionSelectionStatusScope._({ + required this.selectionStatusNotifier, + required super.child, + }); + + /// Tracks updates to the [SelectableRegionSelectionStatus] of the owning + /// [SelectableRegion]. + /// + /// Listeners will be called even when the value of the [SelectableRegionSelectionStatus] + /// does not change. The selection under the [SelectableRegion] still may have changed. + final ValueListenable selectionStatusNotifier; + + /// The closest instance of this class that encloses the given context. + /// + /// If there is no enclosing [SelectableRegion] or [SelectionArea] widget, then null is + /// returned. + /// + /// Calling this method will create a dependency on the closest + /// [SelectableRegionSelectionStatusScope] in the [context], if there is one. + static ValueListenable? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()?.selectionStatusNotifier; + } + + @override + bool updateShouldNotify(SelectableRegionSelectionStatusScope oldWidget) { + return selectionStatusNotifier != oldWidget.selectionStatusNotifier; + } +} + +/// A [SelectionContainer] that allows the user to access the [SelectionDetails] and +/// listen to selection changes for the child subtree it wraps under a [SelectionArea] +/// or [SelectableRegion]. +/// +/// The selection updates are provided through the [selectionNotifier], to listen +/// to these updates attach a listener through [SelectionListenerNotifier.addListener]. +/// +/// This widget does not listen to selection changes of nested [SelectionArea]s +/// or [SelectableRegion]s in its subtree because those widgets are self-contained +/// and do not bubble up their selection. To listen to selection changes of a +/// [SelectionArea] or [SelectableRegion] under this [SelectionListener], add +/// an additional [SelectionListener] under each one. +/// +/// {@tool dartpad} +/// This example shows how to use [SelectionListener] to access the [SelectionDetails] +/// under a [SelectionArea] or [SelectableRegion]. +/// +/// ** See code in examples/api/lib/material/selection_area/selection_area.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to color the active selection red under a +/// [SelectionArea] or [SelectableRegion]. +/// +/// ** See code in examples/api/lib/material/selection_area/selection_area.2.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [SelectableRegion], which provides an overview of the selection system. +class SelectionListener extends StatefulWidget { + /// Create a new [SelectionListener] widget. + const SelectionListener({ + super.key, + required this.selectionNotifier, + required this.child, + }); + + /// Notifies listeners when the selection has changed. + final SelectionListenerNotifier selectionNotifier; + + /// The child widget this selection listener applies to. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + @override + State createState() => _SelectionListenerState(); +} + +class _SelectionListenerState extends State { + late final _SelectionListenerDelegate _selectionDelegate = _SelectionListenerDelegate(selectionNotifier: widget.selectionNotifier); + + @override + void didUpdateWidget(SelectionListener oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.selectionNotifier != widget.selectionNotifier) { + _selectionDelegate._setNotifier(widget.selectionNotifier); + } + } + + @override + void dispose() { + _selectionDelegate.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SelectionContainer( + delegate: _selectionDelegate, + child: widget.child, + ); + } +} + +final class _SelectionListenerDelegate extends StaticSelectionContainerDelegate implements SelectionDetails { + _SelectionListenerDelegate({ + required SelectionListenerNotifier selectionNotifier, + }) : _selectionNotifier = selectionNotifier { + _selectionNotifier._registerSelectionListenerDelegate(this); + } + + SelectionGeometry? _initialSelectionGeometry; + + SelectionListenerNotifier _selectionNotifier; + void _setNotifier(SelectionListenerNotifier newNotifier) { + _selectionNotifier._unregisterSelectionListenerDelegate(); + _selectionNotifier = newNotifier; + _selectionNotifier._registerSelectionListenerDelegate(this); + } + + @override + void notifyListeners() { + super.notifyListeners(); + // Skip initial notification if selection is not valid. + if (_initialSelectionGeometry == null && !value.hasSelection) { + _initialSelectionGeometry = value; + return; + } + _selectionNotifier.notifyListeners(); + } + + @override + void dispose() { + _selectionNotifier._unregisterSelectionListenerDelegate(); + _initialSelectionGeometry = null; + super.dispose(); + } + + @override + SelectedContentRange? get range => getSelection(); + + @override + SelectionStatus get status => value.status; +} + +/// A read-only interface for accessing the details of a selection under a [SelectionListener]. +/// +/// This includes information such as the status of the selection indicating +/// if it is collapsed or uncollapsed, the [SelectedContentRange] that includes +/// the start and end offsets of the selection local to the [SelectionListener] +/// that reports this object. +/// +/// This object is typically accessed by providing a [SelectionListenerNotifier] +/// to a [SelectionListener] and retrieving the value from [SelectionListenerNotifier.selection]. +abstract final class SelectionDetails { + /// The computed selection range of the owning [SelectionListener]s subtree. + /// + /// Returns `null` if there is nothing selected. + SelectedContentRange? get range; + + /// The status that indicates whether there is a selection and whether the selection is collapsed. + SelectionStatus get status; +} + +/// Notifies listeners when the selection under a [SelectionListener] has been +/// changed. +/// +/// This object is typically provided to a [SelectionListener]. +final class SelectionListenerNotifier extends ChangeNotifier { + _SelectionListenerDelegate? _selectionDelegate; + + /// The details of the selection under the [SelectionListener] that owns this notifier. + /// + /// Throws an exception if this notifier has not been registered to a [SelectionListener]. + /// To check if a notifier has been registered to a [SelectionListener] use [registered]. + SelectionDetails get selection => _selectionDelegate ?? (throw Exception('Selection client has not been registered to this notifier.')); + + /// Whether this [SelectionListenerNotifier] has been registered to a [SelectionListener]. + bool get registered => _selectionDelegate != null; + + void _registerSelectionListenerDelegate(_SelectionListenerDelegate selectionDelegate) { + assert( + !registered, + 'This SelectionListenerNotifier is already registered to another SelectionListener. Try providing a new SelectionListenerNotifier.', + ); + _selectionDelegate = selectionDelegate; + } + + void _unregisterSelectionListenerDelegate() { + _selectionDelegate = null; + } + + // From ChangeNotifier. + @override + void dispose() { + _unregisterSelectionListenerDelegate(); + super.dispose(); + } + + /// Calls the listener every time the [SelectionGeometry] of the selection changes under + /// a [SelectionListener]. + /// + /// Listeners can be removed with [removeListener]. + @override + void addListener(VoidCallback listener) { + super.addListener(listener); + } +} diff --git a/packages/flutter/lib/src/widgets/selection_container.dart b/packages/flutter/lib/src/widgets/selection_container.dart index bd5e86cde4..a0cafe5476 100644 --- a/packages/flutter/lib/src/widgets/selection_container.dart +++ b/packages/flutter/lib/src/widgets/selection_container.dart @@ -182,6 +182,12 @@ class _SelectionContainerState extends State with Selectable return widget.delegate!.getSelectedContent(); } + @override + SelectedContentRange? getSelection() { + assert(!widget._disabled); + return widget.delegate!.getSelection(); + } + @override SelectionResult dispatchSelectionEvent(SelectionEvent event) { assert(!widget._disabled); @@ -202,6 +208,9 @@ class _SelectionContainerState extends State with Selectable return context.findRenderObject()!.getTransformTo(ancestor); } + @override + int get contentLength => widget.delegate!.contentLength; + @override Size get size => (context.findRenderObject()! as RenderBox).size; diff --git a/packages/flutter/lib/src/widgets/text.dart b/packages/flutter/lib/src/widgets/text.dart index 2768d359e4..0d39862a71 100644 --- a/packages/flutter/lib/src/widgets/text.dart +++ b/packages/flutter/lib/src/widgets/text.dart @@ -766,7 +766,7 @@ class _SelectableTextContainer extends StatefulWidget { required this.selectionColor, }); - final InlineSpan text; + final TextSpan text; final TextAlign textAlign; final TextDirection? textDirection; final bool softWrap; @@ -883,9 +883,7 @@ class _RichText extends StatelessWidget { const double _kSelectableVerticalComparingThreshold = 3.0; class _SelectableTextContainerDelegate extends StaticSelectionContainerDelegate { - _SelectableTextContainerDelegate( - GlobalKey textKey, - ) : _textKey = textKey; + _SelectableTextContainerDelegate(GlobalKey textKey) : _textKey = textKey; final GlobalKey _textKey; RenderParagraph get paragraph => _textKey.currentContext!.findRenderObject()! as RenderParagraph; @@ -1222,6 +1220,73 @@ class _SelectableTextContainerDelegate extends StaticSelectionContainerDelegate return a.right > b.right ? 1 : -1; } + /// This method calculates a local [SelectedContentRange] based on the list + /// of [selections] that are accumulated from the [Selectable] children under this + /// delegate. This calculation takes into account the accumulated content + /// length before the active selection, and returns null when either selection + /// edge has not been set. + SelectedContentRange? _calculateLocalRange(List<_SelectionInfo> selections) { + if (currentSelectionStartIndex == -1 || currentSelectionEndIndex == -1) { + return null; + } + int startOffset = 0; + int endOffset = 0; + bool foundStart = false; + bool forwardSelection = currentSelectionEndIndex >= currentSelectionStartIndex; + if (currentSelectionEndIndex == currentSelectionStartIndex) { + // Determining selection direction is innacurate if currentSelectionStartIndex == currentSelectionEndIndex. + // Use the range from the selectable within the selection as the source of truth for selection direction. + final SelectedContentRange rangeAtSelectableInSelection = selectables[currentSelectionStartIndex].getSelection()!; + forwardSelection = rangeAtSelectableInSelection.endOffset >= rangeAtSelectableInSelection.startOffset; + } + for (int index = 0; index < selections.length; index++) { + final _SelectionInfo selection = selections[index]; + if (selection.range == null) { + if (foundStart) { + return SelectedContentRange( + startOffset: forwardSelection ? startOffset : endOffset, + endOffset: forwardSelection ? endOffset : startOffset, + ); + } + startOffset += selection.contentLength; + endOffset = startOffset; + continue; + } + final int selectionStartNormalized = min(selection.range!.startOffset, selection.range!.endOffset); + final int selectionEndNormalized = max(selection.range!.startOffset, selection.range!.endOffset); + if (!foundStart) { + // Because a RenderParagraph may split its content into multiple selectables + // we have to consider at what offset a selectable starts at relative + // to the RenderParagraph, when the selectable is not the start of the content. + final bool shouldConsiderContentStart = index > 0 && paragraph.selectableBelongsToParagraph(selectables[index]); + startOffset += (selectionStartNormalized - (shouldConsiderContentStart ? paragraph.getPositionForOffset(selectables[index].boundingBoxes.first.centerLeft).offset : 0)).abs(); + endOffset = startOffset + (selectionEndNormalized - selectionStartNormalized).abs(); + foundStart = true; + } else { + endOffset += (selectionEndNormalized - selectionStartNormalized).abs(); + } + } + assert(foundStart, 'The start of the selection has not been found despite this selection delegate having an existing currentSelectionStartIndex and currentSelectionEndIndex.'); + return SelectedContentRange( + startOffset: forwardSelection ? startOffset : endOffset, + endOffset: forwardSelection ? endOffset : startOffset, + ); + } + + /// Returns a [SelectedContentRange] considering the [SelectedContentRange] + /// from each [Selectable] child managed under this delegate. + /// + /// When nothing is selected or either selection edge has not been set, + /// this method will return `null`. + @override + SelectedContentRange? getSelection() { + final List<_SelectionInfo> selections = <_SelectionInfo>[ + for (final Selectable selectable in selectables) + (contentLength: selectable.contentLength, range: selectable.getSelection()) + ]; + return _calculateLocalRange(selections); + } + // From [SelectableRegion]. // Clears the selection on all selectables not in the range of @@ -1267,3 +1332,7 @@ class _SelectableTextContainerDelegate extends StaticSelectionContainerDelegate return currentSelectionStartIndex == -1 ? _initSelection(event, isEnd: false) : _adjustSelection(event, isEnd: false); } } + +/// The length of the content that can be selected, and the range that is +/// selected. +typedef _SelectionInfo = ({int contentLength, SelectedContentRange? range}); diff --git a/packages/flutter/test/widgets/selectable_region_context_menu_test.dart b/packages/flutter/test/widgets/selectable_region_context_menu_test.dart index 0c87f13ea7..a68cd1b675 100644 --- a/packages/flutter/test/widgets/selectable_region_context_menu_test.dart +++ b/packages/flutter/test/widgets/selectable_region_context_menu_test.dart @@ -161,6 +161,14 @@ class RenderSelectionSpy extends RenderProxyBox return const SelectedContent(plainText: 'content'); } + @override + SelectedContentRange? getSelection() { + return null; + } + + @override + int get contentLength => 1; + @override final SelectionGeometry value = const SelectionGeometry( hasContent: true, diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart index c20112d78b..ab83e3e2e1 100644 --- a/packages/flutter/test/widgets/selectable_region_test.dart +++ b/packages/flutter/test/widgets/selectable_region_test.dart @@ -4692,6 +4692,251 @@ void main() { skip: kIsWeb, // [intended] Web uses its native context menu. ); + testWidgets('SelectionListener onSelectionChanged is accurate with WidgetSpans', (WidgetTester tester) async { + final List dataModel = [ + 'Hello world, ', + 'how are you today.', + ]; + final SelectionListenerNotifier selectionNotifier = SelectionListenerNotifier(); + addTearDown(selectionNotifier.dispose); + + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + selectionControls: materialTextSelectionControls, + child: SelectionListener( + selectionNotifier: selectionNotifier, + child: Column( + children: [ + Text.rich( + TextSpan( + text: dataModel[0], + children: [ + WidgetSpan( + child: Text( + dataModel[1], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.textContaining('Hello world'), matching: find.byType(RichText).first)); + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('how are you today.'), matching: find.byType(RichText))); + final TestGesture mouseGesture = await tester.startGesture(textOffsetToPosition(paragraph1, 0), kind: PointerDeviceKind.mouse); + + addTearDown(mouseGesture.removePointer); + await tester.pump(); + + SelectedContentRange? selectedRange; + + // Selection on paragraph1. + await mouseGesture.moveTo(textOffsetToPosition(paragraph1, 1)); + await tester.pumpAndSettle(); + expect(selectionNotifier.selection.status, SelectionStatus.uncollapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 0); + expect(selectedRange.endOffset, 1); + + // Selection on paragraph1. + await mouseGesture.moveTo(textOffsetToPosition(paragraph1, 10)); + await tester.pumpAndSettle(); + expect(selectionNotifier.selection.status, SelectionStatus.uncollapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 0); + expect(selectedRange.endOffset, 10); + + // Selection on paragraph1 and paragraph2. + await mouseGesture.moveTo(textOffsetToPosition(paragraph2, 10)); + await tester.pumpAndSettle(); + expect(selectionNotifier.selection.status, SelectionStatus.uncollapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 0); + expect(selectedRange.endOffset, 23); + await mouseGesture.up(); + await tester.pump(); + expect(selectionNotifier.selection.status, SelectionStatus.uncollapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 0); + expect(selectedRange.endOffset, 23); + + // Collapsed selection. + await mouseGesture.down(textOffsetToPosition(paragraph2, 3)); + await tester.pump(); + await mouseGesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(selectionNotifier.selection.status, SelectionStatus.collapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 16); + expect(selectedRange.endOffset, 16); + + // Backwards selection. + await mouseGesture.down(textOffsetToPosition(paragraph2, 4)); + await tester.pump(); + await mouseGesture.moveTo(textOffsetToPosition(paragraph1, 0)); + await tester.pumpAndSettle(); + expect(selectionNotifier.selection.status, SelectionStatus.uncollapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 17); + expect(selectedRange.endOffset, 0); + await mouseGesture.up(); + await tester.pump(); + expect(selectionNotifier.selection.status, SelectionStatus.uncollapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 17); + expect(selectedRange.endOffset, 0); + + // Collapsed selection. + await mouseGesture.down(textOffsetToPosition(paragraph1, 0)); + await tester.pumpAndSettle(); + await mouseGesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(selectionNotifier.selection.status, SelectionStatus.collapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 0); + expect(selectedRange.endOffset, 0); + }); + + testWidgets('onSelectionChanged SelectedContentRange is accurate', (WidgetTester tester) async { + final List dataModel = [ + 'How are you?', + 'Good, and you?', + 'Fine, thank you.', + ]; + final SelectionListenerNotifier selectionNotifier = SelectionListenerNotifier(); + SelectedContentRange? selectedRange; + addTearDown(selectionNotifier.dispose); + + await tester.pumpWidget( + MaterialApp( + home: SelectableRegion( + selectionControls: materialTextSelectionControls, + child: SelectionListener( + selectionNotifier: selectionNotifier, + child: Column( + children: [ + Text( + dataModel[0], + ), + Text( + dataModel[1], + ), + Text( + dataModel[2], + ), + ], + ), + ), + ), + ), + ); + + final RenderParagraph paragraph1 = tester.renderObject(find.descendant(of: find.text('How are you?'), matching: find.byType(RichText))); + final RenderParagraph paragraph2 = tester.renderObject(find.descendant(of: find.text('Good, and you?'), matching: find.byType(RichText))); + final RenderParagraph paragraph3 = tester.renderObject(find.descendant(of: find.text('Fine, thank you.'), matching: find.byType(RichText))); + final TestGesture mouseGesture = await tester.startGesture(textOffsetToPosition(paragraph1, 4), kind: PointerDeviceKind.mouse); + + addTearDown(mouseGesture.removePointer); + await tester.pump(); + + // Selection on paragraph1. + await mouseGesture.moveTo(textOffsetToPosition(paragraph1, 7)); + await tester.pumpAndSettle(); + expect(selectionNotifier.selection.status, SelectionStatus.uncollapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 4); + expect(selectedRange.endOffset, 7); + + // Selection on paragraph1. + await mouseGesture.moveTo(textOffsetToPosition(paragraph1, 10)); + await tester.pumpAndSettle(); + expect(selectionNotifier.selection.status, SelectionStatus.uncollapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 4); + expect(selectedRange.endOffset, 10); + + // Selection on paragraph1 and paragraph2. + await mouseGesture.moveTo(textOffsetToPosition(paragraph2, 10)); + await tester.pumpAndSettle(); + expect(selectionNotifier.selection.status, SelectionStatus.uncollapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 4); + expect(selectedRange.endOffset, 22); + + // Selection on paragraph1, paragraph2, and paragraph3. + await mouseGesture.moveTo(textOffsetToPosition(paragraph3, 10)); + await tester.pumpAndSettle(); + expect(selectionNotifier.selection.status, SelectionStatus.uncollapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 4); + expect(selectedRange.endOffset, 36); + await mouseGesture.up(); + await tester.pump(); + expect(selectionNotifier.selection.status, SelectionStatus.uncollapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 4); + expect(selectedRange.endOffset, 36); + + // Collapsed selection. + await mouseGesture.down(textOffsetToPosition(paragraph1, 3)); + await tester.pump(); + await mouseGesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(selectionNotifier.selection.status, SelectionStatus.collapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 3); + expect(selectedRange.endOffset, 3); + + // Backwards selection. + await mouseGesture.down(textOffsetToPosition(paragraph3, 4)); + await tester.pump(); + await mouseGesture.moveTo(textOffsetToPosition(paragraph1, 0)); + await tester.pumpAndSettle(); + expect(selectionNotifier.selection.status, SelectionStatus.uncollapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 30); + expect(selectedRange.endOffset, 0); + await mouseGesture.up(); + await tester.pump(); + expect(selectionNotifier.selection.status, SelectionStatus.uncollapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 30); + expect(selectedRange.endOffset, 0); + + // Collapsed selection. + await mouseGesture.down(textOffsetToPosition(paragraph1, 0)); + await tester.pumpAndSettle(); + await mouseGesture.up(); + await tester.pumpAndSettle(kDoubleTapTimeout); + expect(selectionNotifier.selection.status, SelectionStatus.collapsed); + selectedRange = selectionNotifier.selection.range; + expect(selectedRange, isNotNull); + expect(selectedRange!.startOffset, 0); + expect(selectedRange.endOffset, 0); + }); + testWidgets('onSelectionChange is called when the selection changes through gestures', (WidgetTester tester) async { SelectedContent? content; @@ -5139,6 +5384,14 @@ class RenderSelectionSpy extends RenderProxyBox return const SelectedContent(plainText: 'content'); } + @override + SelectedContentRange? getSelection() { + return null; + } + + @override + int get contentLength => 1; + @override final SelectionGeometry value = const SelectionGeometry( hasContent: true, @@ -5221,6 +5474,14 @@ class RenderSelectAll extends RenderProxyBox return const SelectedContent(plainText: 'content'); } + @override + SelectedContentRange? getSelection() { + return null; + } + + @override + int get contentLength => 1; + @override SelectionGeometry get value => _value; SelectionGeometry _value = const SelectionGeometry(