Flutter - 文本撤销和重做按钮
Flutter - Text undo and redo button
你好,我一直在互联网上搜索如何创建重做和撤消按钮并将它们连接到 flutter TextField,但到目前为止我还没有找到任何东西。我希望有人知道如何做到这一点,我希望能得到你的帮助。
您可以查看 undo or replay_bloc 个软件包。
或者,您可以尝试在自己的项目中实现该功能,然后根据您的具体要求对其进行微调。
这是此类功能的实施草案。
支持撤销、重做和重置。
我使用了以下软件包:
- Flutter Hooks,作为 StatefulWidgets 的替代品
- Hooks Riverpod,用于状态管理
- Freezed,为了不变性
- Easy Debounce,声讨历史变迁
您将在本文末尾找到完整的源代码 post。但是,这里有一些重要的亮点:
解决方案的结构:
应用程序
A MaterialApp
封装在 Riverpod ProviderScope
中
HomePage
A HookWidget
维护全局状态:uid
所选报价和 editing
,无论我们是否显示表单。
QuoteView
所选报价单的基本显示。
QuoteForm
此表单用于修改所选报价。在(重新)构建表单之前,我们检查引号是否已更改(这发生在 undo/reset/redo 之后),如果是,我们将重置更改的字段的值(和光标位置)。
UndoRedoResetWidget
这个 Widget 提供了三个按钮来触发 `pendingQuoteProvider 上的撤消/重置和重做。撤消和重做按钮还显示可用的撤消和重做次数。
pendingQuoteProvider
这是一个家庭 StateNotifierProvider(查看 here 以了解有关家庭提供者的更多信息),它可以轻松简单地跟踪每个报价的变化。即使您从一个报价导航到其他报价并返回,它甚至会保留跟踪的更改。您还将看到,在我们的 PendingQuoteNotifier
中,我将更改去抖动 500 毫秒以减少报价历史记录中的状态数。
PendingQuoteModel
这是我们 pendingQuoteProvider
的状态模型。它由一个 List<Quote> history
和一个 index
组成,表示当前历史位置。
Quote
我们报价的基本 class,由 uid
、text
、author
和 year
.
组成
完整源代码
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:easy_debounce/easy_debounce.dart';
part '66288827.undo_redo.freezed.dart';
// APP
void main() {
runApp(
ProviderScope(
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Undo/Reset/Redo Demo',
home: HomePage(),
),
),
);
}
// HOMEPAGE
class HomePage extends HookWidget {
@override
Widget build(BuildContext context) {
final selected = useState(quotes.keys.first);
final editing = useState(false);
return Scaffold(
body: SingleChildScrollView(
child: Container(
padding: EdgeInsets.all(16.0),
alignment: Alignment.center,
child: Column(
children: [
Wrap(
children: quotes.keys
.map((uid) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4.0,
vertical: 2.0,
),
child: ChoiceChip(
label: Text(uid),
selected: selected.value == uid,
onSelected: (_) => selected.value = uid,
),
))
.toList(),
),
const Divider(),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 250),
child: QuoteView(uid: selected.value),
),
const Divider(),
if (editing.value)
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 250),
child: QuoteForm(uid: selected.value),
),
const SizedBox(height: 16.0),
ElevatedButton(
onPressed: () => editing.value = !editing.value,
child: Text(editing.value ? 'CLOSE' : 'EDIT'),
)
],
),
),
),
);
}
}
// VIEW
class QuoteView extends StatelessWidget {
final String uid;
const QuoteView({Key key, this.uid}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('“${quotes[uid].text}”', textAlign: TextAlign.left),
Text(quotes[uid].author, textAlign: TextAlign.right),
Text(quotes[uid].year, textAlign: TextAlign.right),
],
);
}
}
// FORM
class QuoteForm extends HookWidget {
final String uid;
const QuoteForm({Key key, this.uid}) : super(key: key);
@override
Widget build(BuildContext context) {
final quote = useProvider(
pendingQuoteProvider(uid).state.select((state) => state.current));
final quoteController = useTextEditingController();
final authorController = useTextEditingController();
final yearController = useTextEditingController();
useEffect(() {
if (quoteController.text != quote.text) {
quoteController.text = quote.text;
quoteController.selection =
TextSelection.collapsed(offset: quote.text.length);
}
if (authorController.text != quote.author) {
authorController.text = quote.author;
authorController.selection =
TextSelection.collapsed(offset: quote.author.length);
}
if (yearController.text != quote.year) {
yearController.text = quote.year;
yearController.selection =
TextSelection.collapsed(offset: quote.year.length);
}
return;
}, [quote]);
return Form(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
UndoRedoResetWidget(uid: uid),
TextFormField(
decoration: InputDecoration(
labelText: 'Quote',
),
controller: quoteController,
keyboardType: TextInputType.multiline,
maxLines: null,
onChanged: (value) =>
context.read(pendingQuoteProvider(uid)).updateText(value),
),
TextFormField(
decoration: InputDecoration(
labelText: 'Author',
),
controller: authorController,
onChanged: (value) =>
context.read(pendingQuoteProvider(uid)).updateAuthor(value),
),
TextFormField(
decoration: InputDecoration(
labelText: 'Year',
),
controller: yearController,
onChanged: (value) =>
context.read(pendingQuoteProvider(uid)).updateYear(value),
),
],
),
);
}
}
// UNDO / RESET / REDO
class UndoRedoResetWidget extends HookWidget {
final String uid;
const UndoRedoResetWidget({Key key, this.uid}) : super(key: key);
@override
Widget build(BuildContext context) {
final pendingQuote = useProvider(pendingQuoteProvider(uid).state);
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_Button(
iconData: Icons.undo,
info: pendingQuote.hasUndo ? pendingQuote.nbUndo.toString() : '',
disabled: !pendingQuote.hasUndo,
alignment: Alignment.bottomLeft,
onPressed: () => context.read(pendingQuoteProvider(uid)).undo(),
),
_Button(
iconData: Icons.refresh,
disabled: !pendingQuote.hasUndo,
onPressed: () => context.read(pendingQuoteProvider(uid)).reset(),
),
_Button(
iconData: Icons.redo,
info: pendingQuote.hasRedo ? pendingQuote.nbRedo.toString() : '',
disabled: !pendingQuote.hasRedo,
alignment: Alignment.bottomRight,
onPressed: () => context.read(pendingQuoteProvider(uid)).redo(),
),
],
);
}
}
class _Button extends StatelessWidget {
final IconData iconData;
final String info;
final Alignment alignment;
final bool disabled;
final VoidCallback onPressed;
const _Button({
Key key,
this.iconData,
this.info = '',
this.alignment = Alignment.center,
this.disabled = false,
this.onPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
child: Stack(
children: [
Container(
width: 24 + alignment.x.abs() * 6,
height: 24,
decoration: BoxDecoration(
color: Colors.black12,
border: Border.all(
color: Colors.black54, // red as border color
),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(alignment.x == -1 ? 10.0 : 0.0),
topRight: Radius.circular(alignment.x == 1 ? 10.0 : 0.0),
bottomRight: Radius.circular(alignment.x == 1 ? 10.0 : 0.0),
bottomLeft: Radius.circular(alignment.x == -1 ? 10.0 : 0.0),
),
),
),
Positioned.fill(
child: Align(
alignment: Alignment(alignment.x * -.5, 0),
child: Icon(
iconData,
size: 12,
color: disabled ? Colors.black38 : Colors.lightBlue,
),
),
),
Positioned.fill(
child: Align(
alignment: Alignment(alignment.x * .4, .8),
child: Text(
info,
style: TextStyle(fontSize: 6, color: Colors.black87),
),
),
),
],
),
).showCursorOnHover(
disabled ? SystemMouseCursors.basic : SystemMouseCursors.click);
}
}
// PROVIDERS
final pendingQuoteProvider =
StateNotifierProvider.family<PendingQuoteNotifier, String>(
(ref, uid) => PendingQuoteNotifier(quotes[uid]));
class PendingQuoteNotifier extends StateNotifier<PendingQuoteModel> {
PendingQuoteNotifier(Quote initialValue)
: super(PendingQuoteModel().afterUpdate(initialValue));
void updateText(String value) {
EasyDebounce.debounce('quote_${state.current.uid}_text', kDebounceDuration,
() {
state = state.afterUpdate(state.current.copyWith(text: value));
});
}
void updateAuthor(String value) {
EasyDebounce.debounce(
'quote_${state.current.uid}_author', kDebounceDuration, () {
state = state.afterUpdate(state.current.copyWith(author: value));
});
}
void updateYear(String value) {
EasyDebounce.debounce('quote_${state.current.uid}_year', kDebounceDuration,
() {
state = state.afterUpdate(state.current.copyWith(year: value));
});
}
void undo() => state = state.afterUndo();
void reset() => state = state.afterReset();
void redo() => state = state.afterRedo();
}
// MODELS
@freezed
abstract class Quote with _$Quote {
const factory Quote({String uid, String author, String text, String year}) =
_Quote;
}
@freezed
abstract class PendingQuoteModel implements _$PendingQuoteModel {
factory PendingQuoteModel({
@Default(-1) int index,
@Default([]) List<Quote> history,
}) = _PendingModel;
const PendingQuoteModel._();
Quote get current => index >= 0 ? history[index] : null;
bool get hasUndo => index > 0;
bool get hasRedo => index < history.length - 1;
int get nbUndo => index;
int get nbRedo => history.isEmpty ? 0 : history.length - index - 1;
PendingQuoteModel afterUndo() => hasUndo ? copyWith(index: index - 1) : this;
PendingQuoteModel afterReset() => hasUndo ? copyWith(index: 0) : this;
PendingQuoteModel afterRedo() => hasRedo ? copyWith(index: index + 1) : this;
PendingQuoteModel afterUpdate(Quote newValue) => newValue != current
? copyWith(
history: [...history.sublist(0, index + 1), newValue],
index: index + 1)
: this;
}
// EXTENSIONS
extension HoverExtensions on Widget {
Widget showCursorOnHover(
[SystemMouseCursor cursor = SystemMouseCursors.click]) {
return MouseRegion(cursor: cursor, child: this);
}
}
// CONFIG
const kDebounceDuration = Duration(milliseconds: 500);
// DATA
final quotes = {
'q_5374': Quote(
uid: 'q_5374',
text: 'Always pass on what you have learned.',
author: 'Minch Yoda',
year: '3 ABY',
),
'q_9534': Quote(
uid: 'q_9534',
text: "It’s a trap!",
author: 'Admiral Ackbar',
year: "2 BBY",
),
'q_9943': Quote(
uid: 'q_9943',
text: "It’s not my fault.",
author: 'Han Solo',
year: '7 BBY',
),
};
你好,我一直在互联网上搜索如何创建重做和撤消按钮并将它们连接到 flutter TextField,但到目前为止我还没有找到任何东西。我希望有人知道如何做到这一点,我希望能得到你的帮助。
您可以查看 undo or replay_bloc 个软件包。
或者,您可以尝试在自己的项目中实现该功能,然后根据您的具体要求对其进行微调。
这是此类功能的实施草案。
支持撤销、重做和重置。
我使用了以下软件包:
- Flutter Hooks,作为 StatefulWidgets 的替代品
- Hooks Riverpod,用于状态管理
- Freezed,为了不变性
- Easy Debounce,声讨历史变迁
您将在本文末尾找到完整的源代码 post。但是,这里有一些重要的亮点:
解决方案的结构:
应用程序
A
中MaterialApp
封装在 RiverpodProviderScope
HomePage
A
HookWidget
维护全局状态:uid
所选报价和editing
,无论我们是否显示表单。QuoteView
所选报价单的基本显示。
QuoteForm
此表单用于修改所选报价。在(重新)构建表单之前,我们检查引号是否已更改(这发生在 undo/reset/redo 之后),如果是,我们将重置更改的字段的值(和光标位置)。
UndoRedoResetWidget
这个 Widget 提供了三个按钮来触发 `pendingQuoteProvider 上的撤消/重置和重做。撤消和重做按钮还显示可用的撤消和重做次数。
pendingQuoteProvider
这是一个家庭 StateNotifierProvider(查看 here 以了解有关家庭提供者的更多信息),它可以轻松简单地跟踪每个报价的变化。即使您从一个报价导航到其他报价并返回,它甚至会保留跟踪的更改。您还将看到,在我们的
PendingQuoteNotifier
中,我将更改去抖动 500 毫秒以减少报价历史记录中的状态数。PendingQuoteModel
这是我们
pendingQuoteProvider
的状态模型。它由一个List<Quote> history
和一个index
组成,表示当前历史位置。Quote
我们报价的基本 class,由
组成uid
、text
、author
和year
.
完整源代码
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:easy_debounce/easy_debounce.dart';
part '66288827.undo_redo.freezed.dart';
// APP
void main() {
runApp(
ProviderScope(
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Undo/Reset/Redo Demo',
home: HomePage(),
),
),
);
}
// HOMEPAGE
class HomePage extends HookWidget {
@override
Widget build(BuildContext context) {
final selected = useState(quotes.keys.first);
final editing = useState(false);
return Scaffold(
body: SingleChildScrollView(
child: Container(
padding: EdgeInsets.all(16.0),
alignment: Alignment.center,
child: Column(
children: [
Wrap(
children: quotes.keys
.map((uid) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4.0,
vertical: 2.0,
),
child: ChoiceChip(
label: Text(uid),
selected: selected.value == uid,
onSelected: (_) => selected.value = uid,
),
))
.toList(),
),
const Divider(),
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 250),
child: QuoteView(uid: selected.value),
),
const Divider(),
if (editing.value)
ConstrainedBox(
constraints: BoxConstraints(maxWidth: 250),
child: QuoteForm(uid: selected.value),
),
const SizedBox(height: 16.0),
ElevatedButton(
onPressed: () => editing.value = !editing.value,
child: Text(editing.value ? 'CLOSE' : 'EDIT'),
)
],
),
),
),
);
}
}
// VIEW
class QuoteView extends StatelessWidget {
final String uid;
const QuoteView({Key key, this.uid}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('“${quotes[uid].text}”', textAlign: TextAlign.left),
Text(quotes[uid].author, textAlign: TextAlign.right),
Text(quotes[uid].year, textAlign: TextAlign.right),
],
);
}
}
// FORM
class QuoteForm extends HookWidget {
final String uid;
const QuoteForm({Key key, this.uid}) : super(key: key);
@override
Widget build(BuildContext context) {
final quote = useProvider(
pendingQuoteProvider(uid).state.select((state) => state.current));
final quoteController = useTextEditingController();
final authorController = useTextEditingController();
final yearController = useTextEditingController();
useEffect(() {
if (quoteController.text != quote.text) {
quoteController.text = quote.text;
quoteController.selection =
TextSelection.collapsed(offset: quote.text.length);
}
if (authorController.text != quote.author) {
authorController.text = quote.author;
authorController.selection =
TextSelection.collapsed(offset: quote.author.length);
}
if (yearController.text != quote.year) {
yearController.text = quote.year;
yearController.selection =
TextSelection.collapsed(offset: quote.year.length);
}
return;
}, [quote]);
return Form(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
UndoRedoResetWidget(uid: uid),
TextFormField(
decoration: InputDecoration(
labelText: 'Quote',
),
controller: quoteController,
keyboardType: TextInputType.multiline,
maxLines: null,
onChanged: (value) =>
context.read(pendingQuoteProvider(uid)).updateText(value),
),
TextFormField(
decoration: InputDecoration(
labelText: 'Author',
),
controller: authorController,
onChanged: (value) =>
context.read(pendingQuoteProvider(uid)).updateAuthor(value),
),
TextFormField(
decoration: InputDecoration(
labelText: 'Year',
),
controller: yearController,
onChanged: (value) =>
context.read(pendingQuoteProvider(uid)).updateYear(value),
),
],
),
);
}
}
// UNDO / RESET / REDO
class UndoRedoResetWidget extends HookWidget {
final String uid;
const UndoRedoResetWidget({Key key, this.uid}) : super(key: key);
@override
Widget build(BuildContext context) {
final pendingQuote = useProvider(pendingQuoteProvider(uid).state);
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
_Button(
iconData: Icons.undo,
info: pendingQuote.hasUndo ? pendingQuote.nbUndo.toString() : '',
disabled: !pendingQuote.hasUndo,
alignment: Alignment.bottomLeft,
onPressed: () => context.read(pendingQuoteProvider(uid)).undo(),
),
_Button(
iconData: Icons.refresh,
disabled: !pendingQuote.hasUndo,
onPressed: () => context.read(pendingQuoteProvider(uid)).reset(),
),
_Button(
iconData: Icons.redo,
info: pendingQuote.hasRedo ? pendingQuote.nbRedo.toString() : '',
disabled: !pendingQuote.hasRedo,
alignment: Alignment.bottomRight,
onPressed: () => context.read(pendingQuoteProvider(uid)).redo(),
),
],
);
}
}
class _Button extends StatelessWidget {
final IconData iconData;
final String info;
final Alignment alignment;
final bool disabled;
final VoidCallback onPressed;
const _Button({
Key key,
this.iconData,
this.info = '',
this.alignment = Alignment.center,
this.disabled = false,
this.onPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
child: Stack(
children: [
Container(
width: 24 + alignment.x.abs() * 6,
height: 24,
decoration: BoxDecoration(
color: Colors.black12,
border: Border.all(
color: Colors.black54, // red as border color
),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(alignment.x == -1 ? 10.0 : 0.0),
topRight: Radius.circular(alignment.x == 1 ? 10.0 : 0.0),
bottomRight: Radius.circular(alignment.x == 1 ? 10.0 : 0.0),
bottomLeft: Radius.circular(alignment.x == -1 ? 10.0 : 0.0),
),
),
),
Positioned.fill(
child: Align(
alignment: Alignment(alignment.x * -.5, 0),
child: Icon(
iconData,
size: 12,
color: disabled ? Colors.black38 : Colors.lightBlue,
),
),
),
Positioned.fill(
child: Align(
alignment: Alignment(alignment.x * .4, .8),
child: Text(
info,
style: TextStyle(fontSize: 6, color: Colors.black87),
),
),
),
],
),
).showCursorOnHover(
disabled ? SystemMouseCursors.basic : SystemMouseCursors.click);
}
}
// PROVIDERS
final pendingQuoteProvider =
StateNotifierProvider.family<PendingQuoteNotifier, String>(
(ref, uid) => PendingQuoteNotifier(quotes[uid]));
class PendingQuoteNotifier extends StateNotifier<PendingQuoteModel> {
PendingQuoteNotifier(Quote initialValue)
: super(PendingQuoteModel().afterUpdate(initialValue));
void updateText(String value) {
EasyDebounce.debounce('quote_${state.current.uid}_text', kDebounceDuration,
() {
state = state.afterUpdate(state.current.copyWith(text: value));
});
}
void updateAuthor(String value) {
EasyDebounce.debounce(
'quote_${state.current.uid}_author', kDebounceDuration, () {
state = state.afterUpdate(state.current.copyWith(author: value));
});
}
void updateYear(String value) {
EasyDebounce.debounce('quote_${state.current.uid}_year', kDebounceDuration,
() {
state = state.afterUpdate(state.current.copyWith(year: value));
});
}
void undo() => state = state.afterUndo();
void reset() => state = state.afterReset();
void redo() => state = state.afterRedo();
}
// MODELS
@freezed
abstract class Quote with _$Quote {
const factory Quote({String uid, String author, String text, String year}) =
_Quote;
}
@freezed
abstract class PendingQuoteModel implements _$PendingQuoteModel {
factory PendingQuoteModel({
@Default(-1) int index,
@Default([]) List<Quote> history,
}) = _PendingModel;
const PendingQuoteModel._();
Quote get current => index >= 0 ? history[index] : null;
bool get hasUndo => index > 0;
bool get hasRedo => index < history.length - 1;
int get nbUndo => index;
int get nbRedo => history.isEmpty ? 0 : history.length - index - 1;
PendingQuoteModel afterUndo() => hasUndo ? copyWith(index: index - 1) : this;
PendingQuoteModel afterReset() => hasUndo ? copyWith(index: 0) : this;
PendingQuoteModel afterRedo() => hasRedo ? copyWith(index: index + 1) : this;
PendingQuoteModel afterUpdate(Quote newValue) => newValue != current
? copyWith(
history: [...history.sublist(0, index + 1), newValue],
index: index + 1)
: this;
}
// EXTENSIONS
extension HoverExtensions on Widget {
Widget showCursorOnHover(
[SystemMouseCursor cursor = SystemMouseCursors.click]) {
return MouseRegion(cursor: cursor, child: this);
}
}
// CONFIG
const kDebounceDuration = Duration(milliseconds: 500);
// DATA
final quotes = {
'q_5374': Quote(
uid: 'q_5374',
text: 'Always pass on what you have learned.',
author: 'Minch Yoda',
year: '3 ABY',
),
'q_9534': Quote(
uid: 'q_9534',
text: "It’s a trap!",
author: 'Admiral Ackbar',
year: "2 BBY",
),
'q_9943': Quote(
uid: 'q_9943',
text: "It’s not my fault.",
author: 'Han Solo',
year: '7 BBY',
),
};