Flutter - 文本撤消和重做按钮

问题描述

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

解决方法

您可以查看 undoreplay_bloc 包。

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

enter image description here

这是该功能的实现草案。

支持撤销、重做和重置。

我使用了以下软件包:

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

解决方案的结构:

  1. 应用

    封装在 Riverpod MaterialApp 中的 ProviderScope

  2. HomePage

    维护全局状态的 HookWidget:所选引用的 uidediting,无论我们是否显示表单。

  3. QuoteView

    所选报价的非常基本的显示。

  4. QuoteForm

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

  5. UndoRedoResetWidget

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

  6. pendingQuoteProvider

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

  7. PendingQuoteModel

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

  8. Quote

    我们行情的基础类,由 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),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,],);
  }
}

// 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,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',() {
      state = state.afterUpdate(state.current.copyWith(author: value));
    });
  }

  void updateYear(String value) {
    EasyDebounce.debounce('quote_${state.current.uid}_year',() {
      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',};