mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
I recommend reviewing each commit individually. The following were suppressed instead of migrated to minimize the time the tree is closed: 1. The [`Radio` -> `RadioGroup` migration](https://docs.flutter.dev/release/breaking-changes/radio-api-redesign). Tracked by: https://github.com/flutter/flutter/issues/179088. Part of: https://github.com/flutter/flutter/issues/178827 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. <!-- Links --> [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md
506 lines
17 KiB
Dart
506 lines
17 KiB
Dart
// Copyright 2014 The Flutter Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
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 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: <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 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: <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: MainAxisAlignment.center,
|
|
children: <Widget>[
|
|
for (final MapEntry<LocalSpanRange, TextSpan> entry
|
|
in widget.dataSourceMap.entries)
|
|
Text.rich(entry.value),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|