Daniel Salas DG
Daniel Salas DG

Reputation: 109

Flutter - Text undo and redo button

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

Answers (1)

Thierry
Thierry

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.

enter image description here

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:

Structure of the solution:

  1. App

    A MaterialApp encapsulated inside a Riverpod ProviderScope

  2. HomePage

    A HookWidget maintaining the global state: uid of the selected quote and editing, whether or not we display the Form.

  3. QuoteView

    Very basic display of the selected Quote.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. Quote

    Basic class for our Quotes, made of uid, text, author, and year.

Full source code

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

Related Questions