| // 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), | |
| ), | |
| home: const MyHomePage(title: 'Flutter Demo Home Page'), | |
| ); | |
| } | |
| } | |
| class MyHomePage extends StatefulWidget { | |
| const MyHomePage({super.key, required this.title}); | |
| final String title; | |
| @override | |
| State<MyHomePage> createState() => _MyHomePageState(); | |
| } | |
| typedef LocalSpanRange = ({int startOffset, int endOffset}); | |
| class _MyHomePageState extends State<MyHomePage> { | |
| final SelectionListenerNotifier _selectionNotifier = | |
| SelectionListenerNotifier(); | |
| final ContextMenuController _menuController = ContextMenuController(); | |
| final GlobalKey<SelectionAreaState> selectionAreaKey = | |
| GlobalKey<SelectionAreaState>(); | |
| // 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<LocalSpanRange, TextSpan> dataSourceMap = <LocalSpanRange, TextSpan>{}; | |
| // 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<LocalSpanRange, TextSpan> bulletSourceMap = <LocalSpanRange, TextSpan>{}; | |
| Map<int, Map<LocalSpanRange, TextSpan>> widgetSpanMaps = | |
| <int, Map<LocalSpanRange, TextSpan>>{}; | |
| // The origin data used to restore the demo to its initial state. | |
| late final Map<LocalSpanRange, TextSpan> originSourceData; | |
| late final Map<LocalSpanRange, TextSpan> originBulletSourceData; | |
| void _initData() { | |
| const String bulletListTitle = 'This is some bulleted list:\n'; | |
| final List<String> bullets = <String>[ | |
| for (int i = 1; i <= 7; i += 1) '• Bullet $i', | |
| ]; | |
| final TextSpan bulletedList = TextSpan( | |
| text: bulletListTitle, | |
| children: <InlineSpan>[ | |
| WidgetSpan( | |
| child: Column( | |
| children: <Widget>[ | |
| for (final String bullet in bullets) | |
| Padding(padding: const .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: <InlineSpan>[ | |
| 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 = <LocalSpanRange, TextSpan>{}; | |
| for (final MapEntry<LocalSpanRange, TextSpan> entry | |
| in dataSourceMap.entries) { | |
| originSourceData[entry.key] = entry.value; | |
| } | |
| originBulletSourceData = <LocalSpanRange, TextSpan>{}; | |
| for (final MapEntry<LocalSpanRange, TextSpan> 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>[ | |
| 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<LocalSpanRange, TextSpan> dataMap, | |
| required bool coloringChildSpan, | |
| }) { | |
| for (final MapEntry<LocalSpanRange, TextSpan> 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<InlineSpan> beforeSelection = <InlineSpan>[]; | |
| final List<InlineSpan> insideSelection = <InlineSpan>[]; | |
| final List<InlineSpan> afterSelection = <InlineSpan>[]; | |
| 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<LocalSpanRange, TextSpan> 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: <Widget>[ | |
| for (final MapEntry<LocalSpanRange, TextSpan> entry | |
| in widgetSpanSourceMap.entries) | |
| Padding( | |
| padding: const .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: <InlineSpan>[ | |
| ...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<LocalSpanRange, TextSpan> entry | |
| in originSourceData.entries) { | |
| dataSourceMap[entry.key] = entry.value; | |
| } | |
| for (final MapEntry<LocalSpanRange, TextSpan> 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<LocalSpanRange, TextSpan> dataSourceMap; | |
| final ValueChanged<SelectableRegionSelectionStatus> onChanged; | |
| @override | |
| State<MySelectableTextColumn> createState() => _MySelectableTextColumnState(); | |
| } | |
| class _MySelectableTextColumnState extends State<MySelectableTextColumn> { | |
| ValueListenable<SelectableRegionSelectionStatus>? _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: .center, | |
| children: <Widget>[ | |
| for (final MapEntry<LocalSpanRange, TextSpan> entry | |
| in widget.dataSourceMap.entries) | |
| Text.rich(entry.value), | |
| ], | |
| ), | |
| ), | |
| ); | |
| } | |
| } |