Reputation: 109
hi i have been searching the internet for how to create redo and undo buttons and connect them to a flutter TextField, but so far i haven't found anything. I hope someone knows how to do this, I hope for your help.
Upvotes: 1
Views: 3406
Reputation: 8383
You may have a look at undo or replay_bloc packages.
Or, you may just try to implement the feature in your own project and fine-tune it to your specific requirements.
Here is a draft implementation of such a feature.
It supports undo, redo and reset.
I used the following packages:
You'll find the full source code at the end of this post. but, here are a few important highlights:
App
A MaterialApp
encapsulated inside a Riverpod ProviderScope
HomePage
A HookWidget
maintaining the global state: uid
of the selected quote and editing
, whether or not we display the Form.
QuoteView
Very basic display of the selected Quote.
QuoteForm
This form is used to modify the selected quote. Before (re)building the form, we check if the quote was changed (this happens after a undo/reset/redo) and if so, we reset the values (and cursor position) of the fields that changed.
UndoRedoResetWidget
This Widget provides three buttons to trigger undo / reset and redo on our `pendingQuoteProvider. The undo and redo buttons also display the number of undo and redo available.
pendingQuoteProvider
This is a family StateNotifierProvider (check here for more info on family providers), it makes it easy and simple to track changes per quote. It even keeps the tracked changes even when you navigate from one quote to other quotes and back. You will also see that, inside our PendingQuoteNotifier
, I debounce the changes for 500 milliseconds to decrease the number of states in the quote history.
PendingQuoteModel
This is the State Model of our pendingQuoteProvider
. It's made of a List<Quote> history
as well as an index
for the current position in history.
Quote
Basic class for our Quotes, made of uid
, text
, author
, and 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',
),
};
Upvotes: 1