Flutter - 文本撤销和重做按钮

Flutter - Text undo and redo button

你好,我一直在互联网上搜索如何创建重做和撤消按钮并将它们连接到 flutter TextField,但到目前为止我还没有找到任何东西。我希望有人知道如何做到这一点,我希望能得到你的帮助。

您可以查看 undo or replay_bloc 个软件包。

或者,您可以尝试在自己的项目中实现该功能,然后根据您的具体要求对其进行微调。

这是此类功能的实施草案。

支持撤销、重做和重置。

我使用了以下软件包:

您将在本文末尾找到完整的源代码 post。但是,这里有一些重要的亮点:

解决方案的结构:

  1. 应用程序

    A MaterialApp 封装在 Riverpod ProviderScope

  2. HomePage

    A HookWidget 维护全局状态:uid 所选报价和 editing,无论我们是否显示表单。

  3. QuoteView

    所选报价单的基本显示。

  4. QuoteForm

    此表单用于修改所选报价。在(重新)构建表单之前,我们检查引号是否已更改(这发生在 undo/reset/redo 之后),如果是,我们将重置更改的字段的值(和光标位置)。

  5. UndoRedoResetWidget

    这个 Widget 提供了三个按钮来触发 `pendingQuoteProvider 上的撤消/重置和重做。撤消和重做按钮还显示可用的撤消和重做次数。

  6. pendingQuoteProvider

    这是一个家庭 StateNotifierProvider(查看 here 以了解有关家庭提供者的更多信息),它可以轻松简单地跟踪每个报价的变化。即使您从一个报价导航到其他报价并返回,它甚至会保留跟踪的更改。您还将看到,在我们的 PendingQuoteNotifier 中,我将更改去抖动 500 毫秒以减少报价历史记录中的状态数。

  7. PendingQuoteModel

    这是我们 pendingQuoteProvider 的状态模型。它由一个 List<Quote> history 和一个 index 组成,表示当前历史位置。

  8. Quote

    我们报价的基本 class,由 uidtextauthoryear.

    组成

完整源代码

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