language icon indicating copy to clipboard operation
language copied to clipboard

Named mixins

Open 2ZeroSix opened this issue 3 years ago • 14 comments

Add syntax for named mixins to be able to reuse some logic dependent on class life-cycle.

This feature might be a good fit for an ongoing discussion here https://github.com/flutter/flutter/issues/51752

some info from initial discussion

In flutter it's a common pattern to use controllers dependent on State life-cycle: ScrollController, AnimationController, TabController, Streams, etc.

Some of the this use cases have an associated builder widgets: StreamBuilder, TweenAnimationBuilder, ... But this approach introduce deeper nesting of code, which might become less readable.

Some use cases require to be used directly in State to control behavior in life-cycle methods. But this approach is error prone and verbose, it's too easy forget to dispose controller (memory leak), or update something according to changed dependencies or widget configuration (inconsistent state).

Mixin approach is useful here, TickerProviders is a great example.

But, as mentioned by @rrousselGit it's impossible to use them effectively:

But this has different flaws:

  • A mixin can be used only once per class. If our StatefulWidget needs multiple TextEditingController, then we cannot use the mixin approach anymore.
  • The "state" declared by the mixin may conflict with another mixin or the State itself. More specifically, if two mixins declare a member using the same name, there will be a conflict. Worst-case scenario, if the conflicting members have the same type, this will silently fail.

This makes mixins both un-ideal and too dangerous to be a true solution.

Named mixins might solve conflicts issue for mixins that might be used multiple times on single class.

General syntax:

class MyClass {
  // concrete mixin
  with Mixin mixinName;

  // abstract mixin with implementation or mixin with overrides
  with Mixin2 mixinName2 {
    // implementation/overrides
    // might be used as namespace for class members
  }
  
  // syntax for mixins with dependency on other mixins
  // mixin Mixin3 on Mixin {}
  on mixinName with Mixin3 mixinName3;
}
super dispatch:
abstract class MyInterface {
  String interfaceCall();
}

mixin MixinClass on MyInterface {
  String interfaceCall() => 'MixinClass:${super.interfaceCall()}';
}

mixin MixinClass2 on MyInterface {
  String interfaceCall() => 'MixinClass2:${super.interfaceCall()}';
}

class BaseClass implements MyInterface {
  
  String interfaceCall() {
    return 'BaseClass';
  }
}

class MyClass extends BaseClass with MixinClass, MixinClass2 {
  String interfaceCall() {
    return 'MyClass:${super.interfaceCall()}';
  }
}

class MyClassWithNamed extends BaseClass {
  with MixinClass mixin1;
  with MixinClass2 mixin2;
  String interfaceCall() {
    return 'MyClass:${super.interfaceCall()}';
  }
}

// "MyClass:MixinClass2:MixinClass:BaseClass"
void main() => assert(MyClass().interfaceCall() == MyClassWithNamed().interfaceCall());
Flutter example:
mixin TextEditingControllerMixin<T extends StatefulWidget> on State<T> {
  TextEditingController get textEditingController => _textEditingController;
  TextEditingController _textEditingController;

  @override
  void initState() {
    super.initState();
    _textEditingController = TextEditingController();
  }

  @override
  void dispose() {
    _textEditingController.dispose();
    super.dispose();
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DiagnosticsProperty('textEditingController', textEditingController));
  }
}

class Example extends StatefulWidget {
  @override
  _ExampleState createState() => _ExampleState();
}

class _ExampleState extends State<Example> {
  with TextEditingControllerMixin<Example> firstMixin;

  with TextEditingControllerMixin<Example> secondMixin;

  @override
  Widget build(BuildContext context) {
    return Column(children: [
      TextField(
        controller: firstMixin.textEditingController,
      ),
      TextField(
        controller: secondMixin.textEditingController,
      ),
    ]);
  }
}
General example:
abstract class MyInterface {
  String interfaceCall();
}

mixin MixinClass on MyInterface {
  String get field => 'field';
  String interfaceCall() => '$field:${super.interfaceCall()}';
}

mixin AbstractMixin {
  String abstractMixinCall();
}

mixin DependencyMixin on MixinClass {
  void dependencyCall(MixinClass m) => '$field: ${m.field}'; 
}

class BaseClass implements MyInterface {
  String interfaceCall() {
    return 'BaseClass';
  }
}

class MyClass extends BaseClass with MixinClass {
  String get mixinField => 'overridden';

  // mixin with overrides
  with MixinClass mixin1 {
    //  might be either error or feature
    //  * error: not an override
    //  * feature: namespace for class members
    String get something => 'in namespace/error';

    //  valid override
    @override
    String get field => this.mixinField;
  }

  // simple named mixin without overrides, implementations or dependencies
  with MixinClass mixin2;
  
  with AbstractMixin abstractMixin {
    // implementation is required here
    @override
    String abstractMixinCall() => "override abstractMixinCall";
  }

  // proposed syntax for mixins that require other mixins
  on mixin2 with DependencyMixin dependencyMixin;
  
  // same as default implementation
  @override
  String interfaceCall() {
    // same logic as if we declared
    // `with MixinClass(0), MixinClass(1), MixinClass(2)`
    // returns "field:overridden:field:BaseClass"
    return super.interfaceCall();
  }
  
  // probably should be disallowed since DependencyMixin is named
  // and interface doesn't contain dependencyCall
  // void error() => “MyClass” + super.dependencyCall()
  
  void call() => func(mixin1);
}

void func(MixinClass m) {}

I'm pretty sure I might have missed some corner cases. Would really appreciate any feedback.

cc @rrousselGit @dnfield @Hixie

2ZeroSix avatar Aug 09 '20 23:08 2ZeroSix

It's an interesting idea, but that only solves one of the problems of mixins: multiple usages/conflicts

We still have the other problems of mixins, which is being dependent on the lifecycles and the interface

More specifically, I don't see how mixins could pass the flexibility test I gave https://github.com/flutter/flutter/issues/51752#issuecomment-671002248

rrousselGit avatar Aug 09 '20 23:08 rrousselGit

@rrousselGit I'm not really sure I fully understood you.

What do you think of this example implementation of StreamBuilder-like mixin?

Stream mixin example

client code:

class StreamExample extends StatefulWidget {
  @override
  _StreamExampleState createState() => _StreamExampleState();
}

class _StreamExampleState extends State<StreamExample> {
  /// any subset of StreamListenerStateMixin methods might be overridden
  with StreamListenerStateMixin<String, StreamExample> stringListener {
    @override
    String get initialData => /** widget.initialData*/;

    @override
    StreamExtractor<String> get fromContext =>
            () => /** lookup stream in context */;

    @override
    StreamExtractor<String> get fromWidget =>
            () => /** widget.stream */;

    @override
    StreamExtractor<String> get fromState =>
            () => /** this.someField */;
  }

  @override
  Widget build(BuildContext context) {
    return Text(stringListener.snapshot.data);
  }
}

mixin:

typedef StreamExtractor<T> = Stream<T> Function();

mixin StreamListenerStateMixin<T, W extends StatefulWidget> on State<W> {
  /// get current snapshot
  AsyncSnapshot<T> get snapshot => _snapshot;

  /// called from initState
  T get initialData => null;

  /// extract stream from widget
  /// called from didUpdateWidget
  StreamExtractor<T> get fromWidget => null;

  /// extract stream from context
  /// called from didChangeDependencies
  StreamExtractor<T> get fromContext => null;

  /// extract stream from state
  /// called from setState
  StreamExtractor<T> get fromState => null;

  /// copy pasted from _StreamBuilderBaseState
  /// with didChangeDependencies and setState addition

  Stream<T> _stream;
  StreamSubscription<T> _subscription;
  AsyncSnapshot<T> _snapshot;

  @override
  void initState() {
    super.initState();
    _snapshot = _initial();
    _subscribe();
  }

  @override
  void didUpdateWidget(W oldWidget) {
    super.didUpdateWidget(oldWidget);
    _tryResubscribe(fromWidget);
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _tryResubscribe(fromContext);
  }

  @override
  void setState(VoidCallback fn) {
    super.setState(fn);
    _tryResubscribe(fromState);
  }

  @override
  void dispose() {
    _unsubscribe();
    super.dispose();
  }
  
  void _tryResubscribe(StreamExtractor<T> extractor) {
    final newStream = extractor?.call();
    if (extractor != null && newStream != _stream) {
      _stream = newStream;
      if (_subscription != null) {
        _unsubscribe();
        _snapshot = _afterDisconnected(_snapshot);
      }
      _subscribe();
    }
  }

  void _subscribe() {
    if (_stream != null) {
      _subscription = _stream.listen((T data) {
        setState(() {
          _snapshot = _afterData(_snapshot, data);
        });
      }, onError: (Object error) {
        setState(() {
          _snapshot = _afterError(_snapshot, error);
        });
      }, onDone: () {
        setState(() {
          _snapshot = _afterDone(_snapshot);
        });
      });
      _snapshot = _afterConnected(_snapshot);
    }
  }

  void _unsubscribe() {
    if (_subscription != null) {
      _subscription.cancel();
      _subscription = null;
    }
  }

  /// copy pasted from StreamBuilderBase as private members

  AsyncSnapshot<T> _initial() =>
      AsyncSnapshot<T>.withData(ConnectionState.none, initialData);

  AsyncSnapshot<T> _afterConnected(AsyncSnapshot<T> current) =>
      current.inState(ConnectionState.waiting);

  AsyncSnapshot<T> _afterData(AsyncSnapshot<T> current, T data) {
    return AsyncSnapshot<T>.withData(ConnectionState.active, data);
  }

  AsyncSnapshot<T> _afterError(AsyncSnapshot<T> current, Object error) {
    return AsyncSnapshot<T>.withError(ConnectionState.active, error);
  }

  AsyncSnapshot<T> _afterDone(AsyncSnapshot<T> current) =>
      current.inState(ConnectionState.done);

  AsyncSnapshot<T> _afterDisconnected(AsyncSnapshot<T> current) =>
      current.inState(ConnectionState.none);
}

2ZeroSix avatar Aug 10 '20 01:08 2ZeroSix

The idea of namespacing methods is cool 😊

That fromState vs fromContext sounds confusing. It's even more confusing as this is a getter which returns a nullable function, to handle the unimplemented case

We'd likely want to have a single :

Stream<T> get stream =>...

Similarly, what about mixins that implement an interface? Does an object that mix in a named mixin implement the interface?

Also nit but in the case of a StreamBuilder implementation, it would be great to have a way to use named mixins of StatelessWidgets.

rrousselGit avatar Aug 10 '20 05:08 rrousselGit

That fromState vs fromContext sounds confusing. It's even more confusing as this is a getter which returns a nullable function, to handle the unimplemented case

I didn't pay attention to naming in this example, just wanted to show the basic idea. The idea with "getter which returns a nullable function" seems to be really hard to read.

We'd likely want to have a single :

Stream<T> get stream =>...

I can't come up with a solution where single Stream getter is enough. User should be aware of scope where he extract stream. Probably this can be reduced to 2: with ability to call dependOnInheritedWidgetOfExactType (didChangeDependencies) and without (didUpdateWidget, setState).

Also there might be a good reason to add transformer in StreamListenerStateMixin interface and document that transformations should be applied only here, otherwise they'll be re-subscribed even with the same source stream.

Stream<S> transform<T, S>(Stream<T> stream) => stream;

Similarly, what about mixins that implement an interface? Does an object that mix in a named mixin implement the interface?

It's really interesting question! I think it shouldn't, it might lead to collisions, and then the whole idea of reusability would be ruined. Cast to interface should be possible only when we explicitly access mixin by name.

For example there is compile error:

abstract class MyInterface<T> {
  String call();
}

mixin Mixin1 implements MyInterface<int> {  
  String call() => 'Mixin1';
}

mixin Mixin2 implements MyInterface<String> {
  String call() => 'Mixin2';
}

class MyClass with Mixin1, Mixin2 {
  String call() => 'MyClass';
}
The class 'MyClass' cannot implement both 'MyInterface<int*>*' and 'MyInterface<String*>*' because the type arguments are different - line 13

Also nit but in the case of a StreamBuilder implementation, it would be great to have a way to use named mixins of StatelessWidgets.

I don't understand what would I expect of it. Widgets are immutable configurations for Elements. So we won't be able to declare mutable mixins here. It should be possible to do something similar using some custom widget with mixins overriding createElement in super calls chain.

But Elements are much harder to implement, maintain and resulting widget would be almost the same as StatefulWidget, but with hidden life-cycle callbacks. I think user should be aware of what he's really using.

2ZeroSix avatar Aug 10 '20 06:08 2ZeroSix

It's unclear to me how or when you call the named mixin application's initState or dispose methods. There are two of them, both do a super-call, but nobody calls them. That is, it's not at all clear to me what this proposal would actually do.

If my guess is correct, it looks very much like nested objects, with the only difference being that their self invocations (including super invocations) will use the containing object instead of themselves. (If my guess is wrong, then we need a better explanation)

In a prototype based language, this would be easy. A mixin is just a class which takes its prototype as an argument, so you'd just do:

class Widget {
  late final state1 = Mixin1(prototype: this);
  late final state2 = Mixin2(prototype: this);
}

and then each state object would delegate instance methods to the container. Dart is not prototype based, we need to do the mixins at the class level, and two objects can't share a super-instance.

lrhn avatar Aug 10 '20 07:08 lrhn

@lrhn Mixins inheritance hierarchy is flattened and super calls are done in reverse order of thier application to class.

If you don't override dispose, then default behavior is the same as this definition (for any method actually)

void dispose() => super.dispose();

The idea is to allow application of multiple mixins with conflicting/same definition. All of them will be able to react to class life-cycle events, but also contain scoped state and behavior available by name.

mixin super example

this code will output Mixin2:Mixin1:BaseClass

abstract class MyInterface {
  String call();
}

mixin Mixin1 on MyInterface {  
  String call() => 'Mixin1:${super.call()}';
}

mixin Mixin2 on MyInterface {
  String call() => 'Mixin2:${super.call()}';
}

class BaseClass implements MyInterface {
  String call() => 'BaseClass';  
}

class MyClass extends BaseClass with Mixin1, Mixin2 {
}

void main() {
  print(MyClass().call());
}

2ZeroSix avatar Aug 10 '20 07:08 2ZeroSix

In a prototype based language, this would be easy. A mixin is just a class which takes its prototype as an argument, so you'd just do:

class Widget {
  late final state1 = Mixin1(prototype: this);
  late final state2 = Mixin2(prototype: this);
}

and then each state object would delegate instance methods to the container. Dart is not prototype based, we need to do the mixins at the class level, and two objects can't share a super-instance.

If I understand prototypes correctly, this example lacks the main advantage of mixins used in this proposal: flattened chain of super calls

2ZeroSix avatar Aug 10 '20 08:08 2ZeroSix

So, these named mixins will work like normal mixins except that some of their declarations are "namespaced" to avoid conflict with other mixin declarations of the same name. It can't be all of the declarations, because the methods are overriding and having the same name rather than being namespaced to the individual named mixin.

Which names would be namespaced? Getters and setters only? Only those getters and setters coming from instance variables?

I think a more likely approach would be introducing non-virtual declarations and non-virtual self access or non-virtual super-mixin access. For example C# has non-virtual declarations. A declaration marked final is non-virtual, and one marked new or override is virtual (but with new, it's a different virtual than the one in the superclass). You can have multiple new declarations with the same name in the inheritance chain without conflict. So, if your mixin was declared as:

mixin SomeStateWidget on SomeWidget {
  new 
  MyState state;

  override 
  void dispose() {
    super.dispose();
    state.dispose(); // or: self.state.dispose();
  }
}

then it should be possible to mix it in more than once. The only issue is how to access the individual states which are shadowing each other. Maybe:

class TwiceWidget extends SomeWidget with SomeStateWidget as s1, SomeStateWidget as s2 {
   foo() {
     // Need a way to access the two different states.
     use(s1.state, s2.state);
   }
}

I think moving to a C#-like model with non-virtual members is a more likely approach than namespaceing mixins. It's more generally usable.

lrhn avatar Aug 10 '20 08:08 lrhn

A few problems here:

  • mixins can be abstract, and we might want to implement abstract methods differently for each mixin. It's possible to forbid classes with non-virtual members to be abstract, or forbid conflicting applied mixins to be abstract, but it doesn't seems as good solution. I might not understand something here.
  • it's impossible to override method on exact mixin with this approach (like in this comment with StreamBuilder-like mixin https://github.com/dart-lang/language/issues/1140#issuecomment-671126580)
  • what will happen if mixins will implement conflicting interfaces (https://github.com/dart-lang/language/issues/1140#issuecomment-671192337)
quote it might lead to collisions, and then the whole idea of reusability would be ruined. Cast to interface should be possible only when we explicitly access mixin by name.

For example there is compile error:

abstract class MyInterface<T> {
  String call();
}

mixin Mixin1 implements MyInterface<int> {  
  String call() => 'Mixin1';
}

mixin Mixin2 implements MyInterface<String> {
  String call() => 'Mixin2';
}

class MyClass with Mixin1, Mixin2 {
  String call() => 'MyClass';
}
The class 'MyClass' cannot implement both 'MyInterface<int*>*' and 'MyInterface<String*>*' because the type arguments are different - line 13

2ZeroSix avatar Aug 10 '20 08:08 2ZeroSix

Which names would be namespaced? Getters and setters only? Only those getters and setters coming from instance variables?

any method beside declared in classes mentioned after on clause should be namespaced.

2ZeroSix avatar Aug 10 '20 08:08 2ZeroSix

So the override would apply only to the methods of specific interfaces/types, any other members would not be visibile. That sounds like the ability to hide members from an interface (and then access them them through a different name anyway). That sounds like something that could be useful in more generality. Say:

class C extends B with M m as I {  // I must be a supertype of M.
   // only the members of M that are also members of I are included in the interface of C
   // The remaining are hidden, but can be accessed through `m`.
}

That could apply to superclasses too:

class C extends B b as I {
  // only members of B *and* I are inherited into the interface of C, the rest can be accessed through `b` inside `C`.
}

lrhn avatar Aug 10 '20 10:08 lrhn

@lrhn it should work in this basic example:

example
mixin TextEditingControllerMixin<T extends StatefulWidget> on State<T> {
  TextEditingController get textEditingController => _textEditingController;
  TextEditingController _textEditingController;

  @override
  void initState() {
    super.initState();
    _textEditingController = TextEditingController();
  }

  @override
  void dispose() {
    _textEditingController.dispose();
    super.dispose();
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DiagnosticsProperty('textEditingController', textEditingController));
  }
}

class Example extends StatefulWidget {
  @override
  _ExampleState createState() => _ExampleState();
}

class _ExampleState extends State<Example> with
    TextEditingControllerMixin<Example> firstMixin as State<Example>,
    TextEditingControllerMixin<Example> secondMixin as State<Example> {
  @override
  Widget build(BuildContext context) {
    return Column(children: [
      TextField(
        controller: firstMixin.textEditingController,
      ),
      TextField(
        controller: secondMixin.textEditingController,
      ),
    ]);
  }
}

override/implement

But as soon as we want to override some behavior or implement methods differently on concrete mixins we will face language restrictions: mixin can't extend any class except Object, mixin can't be applied to another mixin, so it's impossible to extend mixin with custom methods overrides. Equivalent client code from https://github.com/dart-lang/language/issues/1140#issuecomment-671126580 example would be impossible to implement.

Some way to override methods hidden from resulting class (and override it differently for different mixins) is required. Otherwise the number of use-cases drops drastically, and this wouldn't help to add hook-like behavior at language level.

mixin dependent on another mixin

I can't figure out how would we apply DependencyMixin defined as here:

abstract class MyInterface {
  String interfaceCall();
}

mixin MixinClass on MyInterface {
  String get field => 'field';
  String interfaceCall() => '$field:${super.interfaceCall()}';
}

mixin DependencyMixin on MixinClass {
  void dependencyCall(MixinClass m) => '$field: ${m.field}'; 
}

class BaseClass implements MyInterface {
  String interfaceCall() {
    return 'BaseClass';
  }
}

it's pretty straightforward in my proposal:

class MyClass extends BaseClass {
  with MixinClass mixin1;
  with MixinClass mixin2;

  // proposed syntax for mixins that require other mixins
  // after on clause: list of mixin names separated by `,`,
  // similar syntax to `on` clause in mixin definition
  on mixin1 with DependencyMixin dependencyMixin;
}

This feature might partially solve "override/implement" issue, as we would be able to extend mixins using similar "mixin on mixin" pattern. But such code would be much harder to read and implementation of this pattern is really verbose.

2ZeroSix avatar Aug 11 '20 16:08 2ZeroSix

Hi, just stumbled on this issue and thought I'd share some thoughts:

@2ZeroSix

I can't figure out how would we apply DependencyMixin defined as here:

I believe this should solve your problem:

class BaseClass extends MyInterface with MixinClass, DependencyMixin {

Since DependencyMixin is declared on MixinClass, you must include it yourself. See my comment here: https://github.com/dart-lang/language/issues/540#issuecomment-1032149744 (not sure why it was downvoted, as that's what the compiler directs you to do).

The only problem there is that you get an error saying MixinClass calls super.interfaceCall but there is no concrete implementation for it. That's not an error on Dart's side; that's a problem with the example. See MixinClass:

mixin MixinClass on MyInterface {
  String get field => 'field';
  @override
  String interfaceCall() => '$field:${super.interfaceCall()}';
}

It's trying to say it needs its base class to be a subclass of MyInterface, but since MyInterface is abstract, there is no guarantee that super.interfaceCall is concrete. Remember, super refers to MyInterface, not BaseClass. If you wanted a concrete implementation, you need to change the on type.


Aside from that, the core of this issue seems to be that when mixing in two mixins:

  • some methods, like dispose, override each other, which is good
  • others, like controller, interfere with each other, which is bad
    • At first thought, this isn't so bad, since you'd get a lint if you accidentally override something
    • On second thought, this is (at least part of) why we cannot mix in a mixin twice

In other words, given the following:

import "package:flutter/material.dart";

mixin TextState<T extends StatefulWidget> on State<T> {
  final controller = TextEditingController();

  @override 
  void dispose() {
    controller.dispose();
    super.dispose();
  }
}

class MyWidget extends StatefulWidget {
  @override createState() => MyState();
}

class MyState extends State<MyWidget> with TextState {
  int controller = 0;  // unknowingly overrides `TextState.controller`

  @override
  Widget build(context) => TextField(controller: controller);  // Error, controller is an int
}

We want to automatically inherit dispose, so we don't have to declare it ourselves, but not controller, as that would mess up our definition of controller, and would also stop us from mixin in TextState twice. Interestingly enough, simply using with TextState, TextState doesn't throw an error, which suggests this is fundamentally possible. But we still want access to TextState.controller somehow, because otherwise, what's the point in mixing it in?

I propose modifying the syntax to answer @lrhn's concerns about where to draw the line. This showing/hiding behavior is also found in imports. Similar to mixins, it's not an error to import a package twice under different names, so long as there are no conflicts:

import "package:flutter/material.dart" as flutter_text show Text;
import "package:flutter/material.dart" as flutter_layout show Container;

So, could we do the same here? Adding the as mechanic:

class MyState extends State<MyWidget> 
  with TextState as text1 show dispose,
  with TextState as text2 show dispose 
{
  @override
  Widget build(context) => Column(children: [
    TextField(controller: text1.controller),
    TextField(controller: text2.controller),
  ]);
}

Now, it's clear that TextState is being mixed in as text1 and text2, but both of their dispose methods are being mixed-in without the namespace, as they would today. Thoughts? @lrhn @2ZeroSix

Levi-Lesches avatar Aug 09 '22 04:08 Levi-Lesches

Alternatively, another approach could be to abandon mixins altogether (in this context, of course). Mixins, like superclasses, define an "is a" relationship rather than a "has a" relationship (I guess the technical terms here would be inheritance vs encapsulation, but this expression stuck out to me when I heard it used by the Dart team).

By saying with TextState, you're saying, "This state is a state that controls a TextEditingController", which is fundamentally incompatible with multiple controllers. Instead, you should say, "This state has a TextEditingController", in which case it's no big deal to add more.

All that to say that I think explicitly declaring your TextEditingControllers (or other objects) in initState and dispose is better for readability than mixins or hooks since it highlights when and how your app is managing these dependencies. If you want to reduce boilerplate, you can use a mixin that disposes of items created in initState. If you find the API of an object hard to use (like animations/controllers), you can make your own wrapper object/extension that takes care of this for you in a simple .init() and .dispose() method. These methods sound better to me than modifying the essence of your state to pretend its focus is on managing a TextEditingController rather than managing a StatefulWidget.

Levi-Lesches avatar Aug 09 '22 05:08 Levi-Lesches