// 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 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), ], ), ), ); } }