Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal for a pipe-like operator to chain constructor/method invocations without nesting #4211

Open
rrousselGit opened this issue Dec 18, 2024 · 143 comments
Labels
feature Proposed language feature that solves one or more problems

Comments

@rrousselGit
Copy link

rrousselGit commented Dec 18, 2024

Problem:

One common complaint with Flutter is how widget trees tend to get very nested.
There are various attempts at solving this, such as using the builder pattern. Cf: https://www.reddit.com/r/FlutterDev/comments/hmwpgm/dart_extensions_to_flatten_flutters_deep_nested/

More recently, this was suggested during the Flutter in production livestream

But relying on chained methods to solve the problem has numerous issues:

  • We loose the ability to use const constructors
  • We reverse the reading order. a(b(c())) becomes c().b().a()
  • It involves a lot of extra work to allow people to create their own widgets.
    Using builders, we need to define both a class, and an extension method that maps to said class:
    class MyWidget extends StatelessWidget {
      const MyWidget({this.child});
    }
    
    // Necessary extension for every custom widget, including any necessary parameter
    extension on Widget {
      MyWidget myWidget() => MyWidget(child: this);
    }
  • A widget is sometimes used as Class() and sometimes as .class(), which feels a bit inconsistent
  • There's no way to support named constructors. For example, ListView has ListView() and ListView.builder(). But as methods, at best we'd have .listView() vs .listViewBuilder()
  • It breaks "go-to-definition", "rename" and "find all reference" for widgets. Using "go to definition" on .myWidget() redirects to the extension, and renaming .myWidget() won't rename MyWidget()

Proposal:

I suggest introducing two things:

  • a new "pipe-like" operator, for chaining constructor/method class
  • a keyword placed on parameters to tell the "pipe" operator how the pipe should perform.
    Such keyword could be used on any parameter. Positional or named, and required or optional. But the keyword can be specified at most once per constructor/function, as only a single parameter can be piped.

The idea is that one parameter per constructor/function definition can be marked with pipable. For example:

class Center extends StatelessWidget {
  Center({
    super.key,
    required pipable this.child, // We mark "child" as pipable
  });
  final Widget child;
}

When a parameter is marked as such, the associated constructor/function becomes usable in combination with a new "pipe" operator.
We could therefore write:

Center()
  |> DecoratedBox(color: Colors.red)
  |> Text('Hello world');

Internally, this would be strictly identical to:

Center(
  child: DecoratedBox(
    color: Colors.red,
    child: Text('Hello world'),
  )
)

In fact, we could support the const keyword:

const Center()
  |> DecoratedBox(color: Colors.red)
  |> Text('Hello world');

Note:
Since in our Center example, the child parameter is required, it would be a compilation error to not use the pipe operator.
As such, this is invalid:

Center();

Similarly, the |>operator isn't mandatory to use our Center widget. We are free to use it the "normal" way and write Center(chid: ...).

In a sense, the combo of the pipable keyword + |> operator enables a different way to pass a single parameter, for the sake of simple chaining.

Conclusion:
This should solve all of the problems mentioned at the top of the page.

  • We can still use const on a complex widget tree
  • The reading order is presserved (top-down instead of bottom-up)
  • Supporting this syntax doesn't involve creating a new extension everytimes
  • Widgets are always used through their constructor
  • Named cosntructors are supported
  • IDE-specific operation such as "renaming/go-to-definition/..." keep working.

As a bonus, the syntax is fully backward compatible. It is not a breaking change to allow a class to be used this way, as the class can still be used as is this feature didn't exist.

@rrousselGit rrousselGit added the feature Proposed language feature that solves one or more problems label Dec 18, 2024
@maks
Copy link

maks commented Dec 19, 2024

On first glance I like this proposal because conceptually it seems similar (for me) to the way that this is passed in as a hidden (or even somtimes not hidden) parameter to methods in OOP languages and that makes it easy for me to quickly get my head around the concept 👍🏻

@kevmoo
Copy link
Member

kevmoo commented Dec 19, 2024

See also #1246 (Feb 17, 2014)

@hasimyerlikaya
Copy link

I like the idea, but I think we should consider using a different operator. Since I’m using a Turkish layout keyboard, pressing four keys to type the |> operator is a bit inconvenient. It might also be the case for other keyboard layouts. Perhaps we could use something like this instead:

const Center()
  - DecoratedBox(color: Colors.red)
  - Text('Hello world');

@rrousselGit
Copy link
Author

rrousselGit commented Dec 19, 2024

@kevmoo
fwiw, although the concept is similar, widgets would be a tough to use with just #1246

The "pipable" keyword discussed here is key to simplifying widget usage

Andf the result wouldn't be const either when possible

@htetlynnhtun
Copy link

Pipe is a bit pain to type. Colon?

const Center()
  :> DecoratedBox(color: Colors.red)
  :> Text('Hello world');

@4e6anenk0
Copy link

Yes, I also think that it is very important to maintain the order of widgets. And we also need some simplifications. And I like the proposed approach. It will allow me to choose how I want to write the code without losing the overall logic and structure of the widgets. What I mean:

For example:

Example 1:
  1. I can use the flattest writing style when necessary:
Container(width: 200, height: 200, alignment: .center, color: .green)
    |> DefaultTextStyle(style: TextStyle(color: .black))
    |> Text('Bloc', textAlign: .center, textScaler: TextScaler.linear(2));
  1. Or vertical, if I need to describe more parameters, for example:
Container(
	width: 200, 
	height: 200, 
	color: .green)
    |> DefaultTextStyle(
            style: TextStyle(color: Colors.black),
    )
    |> Text(
            'Hello World',
            textAlign: TextAlign.center,
    );

Some additional thoughts:

t may also be interesting to extend the logic of working with the |> operator. For example, we can introduce an additional context for the with operator, which will be used to define more complex logic for building parameters, callbacks, or builders. This will increase the readability and separation of the logic from the UI:

Example 2:
  1. For the logic of parameters:
Container(width: 200, height: 200, color: Colors.grey)
      |> with (
          color: isActive ? Colors.green : Colors.red, // logic for parameter color
          alignment: Alignment.center,
          child: (child) => Padding(
            padding: EdgeInsets.all(isActive ? 16 : 8),
            child: child,
          ),
      )
      |> Text(label, style: TextStyle(color: Colors.white));
  1. To derive the logic of the handlers:
Center(
  |> ElevatedButton(style: ButtonStyle(padding: EdgeInsets.all(5.0)),)
  |> with (
    onPressed: () {}, // separated callback
    child: (child) => Text("Tap Me!"),
    );

This would allow us to have a separate description of UI components from handlers, which looks quite convenient and clean. It looks like a kind of paired extension class or functional mutator.

As I understand it, this will work too?

Example 3:
Column(mainAxisAlignment: MainAxisAlignment.center)
  |> [
    Text('Item 1'),
    Text('Item 2'),
    Text('Item 3'),
  ]
  |> Padding(padding: EdgeInsets.all(8));

It would also be interesting to have branching in a pipable container, for example, something like this:

Example 4:
Container(width: 200, height: 200)
  |> (isActive ? Text('Active') : Text('Inactive'));

Although, in theory, it could be part of the general “with” extension:

Container(width: 200, height: 200)
  |> with (child: (_) => isActive ? Text('Active') : Text('Inactive'));

These are just a few ideas for discussing how to simplify and improve UI and code composition. And my impressions of the proposed approach

@escamoteur
Copy link

I REALLY like this proposal!!! (Sorry for the !!! but I'm really excited)

  • It is way more concise while preserving the tree structure
  • It will remove the child/children parameter from the tree which I was previously hoping to get via positional parameters, but this is even better as it also removes a lot of noise in the the formatting

If we could combine that with automatic currying of the last parameter of function calls that get used with the piping operator we wouldn't even need a new pipable keyword but only to move the pipable parameter to the last position of the constructor.

On the question of keyboard layouts I want to reply, that for German keyboards |> isn't ideal to type either BUT if this would be added the first thing I would do is a keyboard-shortcut in VS code. Making the pipe operator similar as in other languages and clearly distinctive while reading is more important IMHO. Plust with the progress of AI you probably won't need to type that yourself anymore

@tenhobi
Copy link

tenhobi commented Dec 19, 2024

I really like this as it

  • preserves the top-down structure, which is easier to read than proposed decorators bottom-up
  • adding a widget in a deep tree structure is just 1 line git diff
  • It's basically Nested with language support

Example with a bit more nested structure:

Column(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    Center(
      child: Padding(
        padding: EdgeInsets.all(5),
        child: ColoredBox(
          color: Colors.red,
          child: SizedBox(
            width: 50,
            child: Text('Item 1'),
          )
        ),
      )
    ),
    Text('Item 2'),
    Text('Item 3'),
  ],
)

vs

Column(mainAxisAlignment: MainAxisAlignment.center)
  |> [
    Center()
      |> Padding(padding: EdgeInsets.all(5))
      |> ColoredBox(color: Colors.red)
      |> SizedBox(width: 50),
      |> Text('Item 1'),
    Text('Item 2'),
    Text('Item 3'),
  ]

and just an idea, while it is not really important now, about syntax, maybe formater might not even put space in front of |> since its already big enough and IMO still readable?

Column(mainAxisAlignment: MainAxisAlignment.center)
|> [
  Center()
  |> Padding(padding: EdgeInsets.all(5))
  |> ColoredBox(color: Colors.red)
  |> SizedBox(width: 50),
  |> Text('Item 1'),
  Text('Item 2'),
  Text('Item 3'),
]

@escamoteur
Copy link

@tenhobi The side effect on how much easier it would get to review UI PRs with Diffs is a bit plus

@dickermoshe
Copy link

I find this proposal very compelling. It looks amazing from a Flutter perspective.
I'm just wondering if any other language has such a syntax.

@Wdestroier
Copy link

I agree the language should support beautiful Flutter code, but this solution doesn't look good imo. I'd claim state management must have special language support too (like Svelte had).

@tenhobi Would it work? Reading the proposal:

a keyword placed on constructor parameters
Center(required pipable this.child,)

I read var children = [Text('Hi')] as:

var children = new List<Text>();
children.add(new Text('Hi'));

I guess it could work, but the proposal would need to be expanded to have an "operator |>" and add it in the List class.

@dickermoshe
Copy link

@Wdestroier That would make all widgets not be const and child not to be final.

@mraleph If we just threw out const would this make Flutter apps slower? Would it also guarantee that future improvements to the dart compiler for const widgets never help Widget code?

The Flutter Maintainers would just need to add pipable and add a dart fix and call it a day. However this looks like a massive language feature. @rrousselGit is cooking today!

@tenhobi
Copy link

tenhobi commented Dec 19, 2024

@Wdestroier I don't understand what wouldn't work? It would work just as Flutter works today, you can just put a parameter marked as pipeable outside after the constructor/function call. Basically just a syntactic sugar, right?

If you put it in print (just a stupid example), then:

void print(pipeable String text);

print()
  |> 'Hello world';

If you put it in Column, then

class Column ... {
  const Column({required pipeable List<Widget> children});
  
  ...
}

const Column()
  |> [
    Text('a'),
    Text('b'),
  ]
  
Column()
  |> [
    const Text('a'),
    Text('b = $value'),
  ]  

@lrhn
Copy link
Member

lrhn commented Dec 19, 2024

This has the opposite direction of #1246.
That pipe would require you to write
Text(…)|>DecoratedBox(…)|>Center()
Which has the same inversion of order issue as chained methods.

This sounds like a way to place one argument outside of the argument list, presumably in order to indent it less.

It feels similar to cascades to me, just at the argument list level. It's a way to keep doing something without indenting for each instance.

Center()::child:
  DecoratedBox(color: .red):: child:
  Text(theText)

or

Center()
  :::child: DecoratedBox(color: .red)
  :: child: Text(theText)

Maybe what is needed is just a different formatting, not a language feature? Probably hard to do something consistent. Still, something like

  Center(child:
  DecoratedBox(
    color: .red, child:
  Text(theText)
  ))

which the formatter divines from some hint put on the child parameter.

@Wdestroier
Copy link

@tenhobi Can you explain the green arrow?

image

I took the original example:

Center()
  |> DecoratedBox(color: Colors.red)
  |> Text('Hello world');

and changed the arrow direction:

Center()
  <| DecoratedBox(color: Colors.red)
  <| Text('Hello world');

It looks more understandable, I guess.

@escamoteur
Copy link

using <| would also fix the correctly pointed out problem by @lrhn that piping would work in the wrong direction

@tenhobi
Copy link

tenhobi commented Dec 19, 2024

@Wdestroier i corrected the code, it was a mistake (the last |> Padding...)

@SaltySpaghetti
Copy link

Sorry if I step in, but imho using that syntax inside the Widget tree is very unreadable and confusionary. I'd love to have the pipe operator (|>) in order to write functions inside the business logic, but certainly not for creating Widgets.

@dickermoshe
Copy link

dickermoshe commented Dec 19, 2024

@lrhn

Center()::child:
  DecoratedBox(color: .red):: child:
  Text(theText)

or

Center()
  :::child: DecoratedBox(color: .red)
  :: child: Text(theText)

Making ALL dart code have the ability to pass arguments in 2 different ways:

  • print() |>"Hello"
  • print("Hello")

would divide the Flutter/Dart community, which is one of the major drawbacks of the entire decorators proposals. (besides for the entire argument about how it's actually harder to understand blah blah blah)

I don't want to see a Tabs vs Spaces fight for no good reason.

Adding a pipable argument is a significant ask & (as far as I know) there is nothing like it in any other language, but unless there are technical reasons why it doesn't solve the problem I think this an awesome way to go.

  1. Backswords Compatible
  2. No Crazy Indentation
  3. No trailing );}]); at the end of complex widget trees.

However from a language perspective I imagine this would require massive changes to the analyzer and all the code generation libraries.

@SaltySpaghetti
Copy link

SaltySpaghetti commented Dec 19, 2024

Making ALL dart code have the ability to pass arguments in 2 different ways:

* `print() |>"Hello"`

* `print("Hello")`

@dickermoshe That's not how the pipe operator works.

print("Hello") becomes "Hello" |> print() and not the opposite, because you pipe the value you have before |> as the first argument of the next function

@orestesgaolin
Copy link

orestesgaolin commented Dec 19, 2024

Some other ideas for the operator, e.g. -> would be quite easy to type in most keyboard layouts. I also think we should maintains at least basic nesting to represent the hierarchy.

Column(mainAxisAlignment: MainAxisAlignment.center, spacing: 8)
-> [
  Center()
  -> Padding(padding: EdgeInsets.all(5))
   -> ColoredBox(color: Colors.red)
    -> SizedBox(width: 50),
     -> Text('Item 1'),
  Text('Item 2'),
  Text('Item 3'),
]

with ligatures:
screenshot_20241219_094119

or

Center()
  { DecoratedBox(decoration: ...)
   { Padding(...)
    { Text('Hello world'); }}}

@escamoteur
Copy link

I think we should indeed use the <| to correctly show what it passed into which else and the indentation created the tree structure.

@dickermoshe
Copy link

dickermoshe commented Dec 19, 2024

@orestesgaolin Let's not get caught up on semantics yet, we don't know if dads letting us get a dog so don't start coming up with names for him yet

@rrousselGit
Copy link
Author

Yeah the name of that pipable keyword or the |> used in the example don't really matter at this stage.

Maybe what is needed is just a different formatting, not a language feature? Probably hard to do something consistent. Still, something like

  Center(child:
  DecoratedBox(
    color: .red, child:
  Text(theText)
  ))

which the formatter divines from some hint put on the child parameter.

A core problem is also how () are simplified.
Flutter tends to have code that ends in )}})))}) 🤷

Changing the formatting wouldn't help that. There's already a new formating with similar ideas in mind coming to Dart 3.7, and it's not enough

@rrousselGit
Copy link
Author

rrousselGit commented Dec 19, 2024

I do like the idea of viewing this as a cascade-like operator though. But a cascade-like operator would raise the topic of named VS positional parameters

And the formatting is likely still doing to be very indented for that.

I assume we'd have:

Center()
  :: child = DecotedBox(color: .red)
      :: child = MyButton();

If we format everything on the same indent level, that'd raise the question of "which class are we initializing"?

Unless that cascade operator would only allow a single initialization? But then that doesn't really feel cascade-like.

So something more specialized makes sense IMO. It's important to remove those indents

@mraleph
Copy link
Member

mraleph commented Dec 19, 2024

First two notes about the syntax:

  • Right associativity of |> makes it confusing. All other operators in Dart are left associative. a * b * c are (a * b) * c. But a |> b |> c is actually a |> (b |> c).
  • Direction of the arrow is confusing - the data actually flows right to left, but the arrow points in the opposite direction.

I don't think everything needs to be a special syntax / language feature. Constructors are great because they are just a language feature, which exists in one form or another in most languages. Decorators (as explored by @loic-sharma and the team) are just extensions methods, which fundamentally also means they are just calls. Easy to understand again. Center() |> Text() or Center() <| Text()- well, that is extremely dense and specific. It is unreadable if you don't know what you are reading. Compare this to Text().centered() or Center(child: Text(...)) - both of which are much more readable (without knowing much about Dart/Flutter even).

FWIW we could (almost) experiment with this style without any special language features:

class PipeWidget<P extends Widget, C extends Widget> extends StatelessWidget {
  final P parent;
  final C child;

  const PipeWidget({required this.parent, required this.child});

  @override
  Widget build(BuildContext context) {
    return wrap(parent, child);
  }
}

extension WrapInWidget1<P extends Widget> on P {
  PipeWidget<P, Widget> operator <<(Widget child) {
    return PipeWidget(parent: this, child: child);
  }
}

extension WrapInWidget2<P extends Widget, W extends Widget>
    on PipeWidget<P, W> {
  PipeWidget<P, Widget> operator <<(Widget innerChild) {
    return PipeWidget(
      parent: parent,
      child: PipeWidget(parent: child, child: innerChild),
    );
  }
}

Widget wrap(Widget p, Widget w) {
  return switch (p) {
    Center() => Center(child: w),
    _ => throw UnsupportedError('Unsupported parent widget: ${p.runtimeType}'),
  };
}

This makes Center() << Text() work. It is of course far from what you would want in the real world code, but I think it is an interesting exercise. We would hit some limitations (e.g. operators can't be generic), so maybe we can approximate with a method instead. wrap itself could be a method on a marker interface in the Widget hierarchy:

abstract interface class SingleChildWidget<W extends SingleChildWidget<W>> implements Widget {
  W wrap(Widget child);
}

abstract interface class MultiChildWidget<W extends MultiChildWidget<W>> implements Widget {
  W wrap(List<Widget> children);
}

@rrousselGit
Copy link
Author

rrousselGit commented Dec 19, 2024

I am aware that we could implement this with custom operators.
Although it's important to note that we still lose the const operator because of it.

IMO there's a lot of value in being able to write const Center(child: Text('foo')). This can drastically simplify performance optimisation of Flutter apps.

So being able to write const Center() < Text('foo') would be cool ; which isn't doable using custom operators.


There's also a major drawback to a custom operator: It wouldn't be backward compatible.

If we can write Center() < Tex(), we likely can't also write Center(child: Text()) if child is required. Meaning we have to make a breaking change to support such a syntax, or fork every existing widgets

@mraleph
Copy link
Member

mraleph commented Dec 19, 2024

FWIW as an alternative to const you can just move it to a static final field to achieve very similar effect.

If we can write Center() < Text(), we likely can't also write Center(child: Text()) if child is required. Meaning we have to make a breaking change to support such a syntax, or fork every existing widgets

That is true. You can still make it work by having a placeholder: final $ = const Placeholder();) and then Center(child: $) << Text().

Not that I really think you should write code like this. I am simply suggesting to use this as a vehicle for experimentation: it is relatively easy to concoct an approximation of proposed syntax from existing language features, so you can experiment with this syntax today. Maybe even ship it as a package. Better implementation would likely requires changes to Flutter framework, but I can also imagine some variants which don't.

I must be completely transparent that aesthetically this sort of syntax - no matter how it is implemented (be it a complicated concoction of extensions and helper classes or a language feature as proposed here) - does not really appeal to me in the slightest. It is too obscure and magical. Even more magical than Swift's result builders or whatever compiler plugin stuff Compose is doing.

@rrousselGit
Copy link
Author

Agreed. I started working on a proof-of-concept. I'll share something later today with custom operators. I have a pretty good solution

@bivens-dev
Copy link

bivens-dev commented Dec 26, 2024

I can clearly see how we have less code but I also feel like we are really going backwards in terms of readability and understandability of the code at the same time and it just feels very much against the idea that Dart is an approachable language with this kind of magic syntax.

I strongly dislike the tradeoffs and would really prefer that this kind of approach had a chance to be implemented using the existing language constructs like @mraleph suggested inside of a package as a way to test some of the assumptions here first before going too far down the path of considering this at the language level.

@dickermoshe
Copy link

dickermoshe commented Dec 26, 2024

@bivens-dev I'm working on a package which generates wrappers for common widgets.
It will allow you to create custom copies of built-in widgets that you can further customize.
It's heavily inspired by this article.

I will include an option to make a widget pipable. I will work on a custom_lint plugin which will help avoid null safety issues and maybe even forking the formatter like DCM did.

If this gains traction then we can discuss this further.

@hectorAguero
Copy link

hectorAguero commented Dec 26, 2024

I still think this approach is better than the proposed Builder pattern, but the enums shorthands would be something required to make the code readable, so properties can be in just one line like Column(crossAxisAlignment: .center)

Regarding the code samples, they're difficult to see mostly because we don't know what would be the right formatter, I just tried a bit with the dartpad sunflower example:

Normal Sample
home: Scaffold(
        appBar: AppBar(
          title: const Text('Sunflower'),
        ),
        body: Center(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              Expanded(
                child: SunflowerWidget(seeds),
              ),
              const SizedBox(height: 20),
              Text('Showing ${seeds.round()} seeds'),
              SizedBox(
                width: 300,
                child: Slider(
                  min: 1,
                  max: maxSeeds.toDouble(),
                  value: seeds.toDouble(),
                  onChanged: (val) {
                    setState(() => seeds = val.round());
                  },
                ),
              ),
              const SizedBox(height: 20),
            ],
          ),
        ),
      ),

Pipe Sample

 home: Scaffold(
        appBar: AppBar(
          title: const Text('Sunflower'),
        ),
        // Should be the pipe be formatted similar to "child:"? 
        body: Center()
          |>  Column(crossAxisAlignment: .center) 
              |>  [  // Are the brackets still needed?
              Expanded()
              |>  SunflowerWidget(seeds),
              const SizedBox(height: 20),
              Text('Showing ${seeds.round()} seeds'),
              // Tried my best formatting this one
              SizedBox(width: 300)
              |>  Slider(
                   min: 1,
                   max: maxSeeds.toDouble(),
                   value: seeds.toDouble(),
                   onChanged: (val) {
                     setState(() => seeds = val.round());
                   },
                  ),
              const SizedBox(height: 20),
            ],
         ),

Also it would be interesting to know what the Flutter team thinks about the drawbacks of the builder pattern pointed here, after all not implementing either of them is still an option.

@l1qu1d
Copy link

l1qu1d commented Dec 26, 2024

So, I ended-up writing a code-generator that forks Flutter to create widgets with >>/>>> operator overrides:

Center()
  >> DecoratedBox(decoration: ...)
  >> Padding(...)
  >>> Text('Hello world');

(using >> for intermediary steps and and >>> for the last step)

I used >>/>>> because:

  • there's no <<<
  • we need two operators (one for chains, one for converting a chain to a widget)
  • other operators format super badly when passed to dartfmt or have the wrong priority

That's cool and all. But it's a pain to use because of constant import conflicts.

If we want to go down that road, I think the only reliable approach would be to add a random $ in the name:

$Center()
  >> $DecoratedBox(decoration: ...)
  >> $Padding(...)
  >>> Text('Hello world');

Overall cool experiment, but I'm not sure that's worth publishing.

I really like this format the best so far. It's very clean visually and easy to type.

@dickermoshe
Copy link

Package Release:
https://pub.dev/packages/rabbit

If you would like to try out this new syntax using your own flutter project, head to the pipeable section for a quick way to check this out.

Gonna see if can fork a formatter and create a linter.

@dickermoshe
Copy link

dickermoshe commented Dec 29, 2024

I'm trying to see if I can get 90% of the way there with 10% of the work.

The dart formatter doesn't resolve the AST so it won't be possible to format an object based on an object type.
The only way to have indentations is based on the name of the widget constructor alone, gonna go with the "$" prefix for this.

The dart_style library is really easy to understand. I hope to have a formatter working soon.
Together with a linter I think this would give us a good idea of how this would look.

However, the more I work on this, the more I hate the syntax.

@tatumizer
Copy link

tatumizer commented Jan 5, 2025

I think I understand where the idea a single literal comes from. The catch is that the composition cannot work well enough when we want to assemble the whole thing out of pieces. As an example, suppose we have a Column:

//... part of a bigger literal
child: Column (
  crossAxisAlignment: CrossAxisAlignment.center,
  mainAxisAlignment: something,
  textBaseline: something,
  children: [
     Expanded(...),
     const SizedBox(...),
     Text(...),
     SizedBox(...),
  ]
).

All nested widgets (Expanded, SizedBox, etc) are also written as (potentially large) literals. The program is difficult to read: the structure of the top widget gets lost in a forest of minor details (parameters, parentheses, brackets etc).
Can we refactor it by defining simpler parts and then assembling them into the whole?

Turns out, there's no obvious way to do that. E.g. we could try

final _column = Column (
  crossAxisAlignment: CrossAxisAlignment.center,
  mainAxisAlignment: something,
  textBaseline: something,
  children: [ 
    // same as above
  ]
);

and then insert the reference to _column in the larger literal. The caveat is that while our _column succeeds in hiding minor details of column layout (crossAxisAlignment etc.), it achieves too much: it hides also the nested widgets (Expanded, SizedBox. etc). This is more than we wanted. Can we separate flies from cutlets?

There's, of course, an option to resort to "partial function application", so that _column becomes a function with a single remaining parameter ("`children"), which, for convenience, might be turned into a positional parameter

final _column(List<Widget> children) => Column (
  crossAxisAlignment: CrossAxisAlignment.center,
  mainAxisAlignment: something,
  textBaseline: something,
  children: children
);

And then, while inserting the _column into the larger literal, we can pass all "children" explicitly:

child: _column ([ 
     Expanded(...),
     const SizedBox(...),
     Text(...),
     SizedBox(...),
  ]
),   

This could work, but:

  1. manually writing the definitions of the components like _column might be tedious.
  2. we still have parentheses (...), and in a large literal, they might accumulate forming a long tail
      ...
      )
    )
  )
);

Let's try to address the second problem.
Suppose whenever we have an 1-arg function, we can call it either as f(x), or as f of x.
So we can write var y = sin of x;, but also

child: _column of [ 
     Expanded(...),
     const SizedBox(...),
     Text(...),
     SizedBox(...),
  ],   

This will eliminate the closing ) and thus prevent the tail from forming.
Let's leave problem 1 as an issue for discussion (the post is getting too long anyway)

@tatumizer
Copy link

tatumizer commented Jan 7, 2025

Assuming the language can (somehow) add support for the "of" clause for widgets with child or children parameters (ad-hoc),
the program becomes much cleaner:

home: Scaffold(
  appBar: AppBar(title: const Text('Sunflower')),
  body: Center of
     Column(crossAxisAlignment: CrossAxisAlignment.center) of [
         Expanded of SunflowerWidget(seeds),
         const SizedBox(height: 20),
         Text('Showing ${seeds.round()} seeds'),
         SizedBox(width: 300) of Slider(
              min: 1, 
              max: maxSeeds.toDouble(), 
              value: seeds.toDouble(), 
              onChanged: (val) {setState(() => seeds = val.round());}
          ),
          const SizedBox(height: 20),
     ],
),

This program is almost 2 times shorter than the original. The savings come from two sources:

  1. elimination of the tail formed by )))))
  2. widgets often have just 1 or 2 parameters (other than child/children). Now that child/children get moved out of (...), we can write WidgetName(parameter) on the same line. In other words, whatever used to be coded as
Column(
   crossAxisAlignment: CrossAxisAlignment.center,
   children: [
     // ...
   ]
)

now becomes

Column(crossAxisAlignment: CrossAxisAlignment.center) of [
     // ...
]

@MelbourneDeveloper
Copy link

MelbourneDeveloper commented Jan 9, 2025

I think there is some confusion in this thread about how F#'s forward pipe operator works. It just passes the result of the left side to the function on the right side, so you can already do this in Dart with an operator overload.

However, I don't think it makes sense in this scenario:

Center()
  |> DecoratedBox(color: Colors.red)
  |> Text('Hello world');

Because Center widget would be passed to DecoratedBox constructor, but that doesn't make sense because decorated box should be inside the Center, not the other way around. And, in the second case, the DecoratedBox is passed to the Text constructor, but again, that doesn't make sense because the DecoratedBox is the parent, not the child.

However, we can do this in Dart already. It hinges on Dart tear-offs and operator overriding, which provide most of the the functionality of F#'s |> I think.

DecoratedBox redDecoratedBox(Widget child) => DecoratedBox(
      decoration: const BoxDecoration(color: Colors.red),
      child: child,
    );

Center center(Widget child) => Center(child: child);

final centered = (center | redDecoratedBox)(const Text('Hello world'))

Here is a more complete example. You can try it out in Dartpad here.

extension ForwardPipeOperator<T extends Widget, T2 extends Widget> on T
    Function(Widget) {
  /// Returns a new function that applies the given transformation to the result
  /// of this function
  T Function(T2) operator |(Widget Function(Widget) next) =>
      (w) => this(next(w));
}

// These functions take advantage of Dart's tear-off syntax to achieve a 
// a similar result to F#'s forward pipe operator

MaterialApp materialApp(Widget home) => MaterialApp(
      debugShowCheckedModeBanner: false,
      home: home,
    );

Scaffold scaffold(Widget body) => Scaffold(
      body: body,
    );

Center center(Widget body) => Center(
      child: body,
    );

// This is the cool part
void main() => runApp(
      (materialApp |
          scaffold |
          Rotating.new |
          center)(const Text('Hello world')),
    );

class Rotating extends StatefulWidget {
  // ignore: public_member_api_docs, use_key_in_widget_constructors
  const Rotating(this.child);

  final Widget child;

  @override
  State<Rotating> createState() => _RotatingState();
}

class _RotatingState extends State<Rotating>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    )..repeat();
  }

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

  @override
  Widget build(BuildContext context) => AnimatedBuilder(
        animation: _controller,
        builder: (context, child) => Transform.rotate(
          angle: _controller.value * 2 * pi,
          child: Transform.scale(
            scale: 0.8 + (_controller.value * 0.4),
            child: child,
          ),
        ),
        child: widget.child,
      );
}

There are some other similar approaches like this:

test('Animated transformation chain', () {
  final fade = const FlutterLogo(size: 100)
      .pipe(rotatingBox)
      .pipe(bounceTransition)
      .pipe(center)
      .pipe(fadeTransition);

  final centerWidget = fade.child! as Center;
  final bounce = centerWidget.child! as AnimatedBuilder;
  final rotating = bounce.child! as AnimatedBuilder;
  final logo = rotating.child! as FlutterLogo;

  expect(logo.size, equals(100));
});

But, this is kinda inside out.

@rrousselGit

@mraleph , this is why I think that it would be good to allow arbitrary operator symbols and with type arguments. We are limited by the current symbols. It would be nice to be able to specify the |> operator.

@tatumizer
Copy link

tatumizer commented Jan 15, 2025

I have been thinking about a possible formalization of the of trick suggested in this comment.

Here's one way to do it.

of-parameters

Any function can declare one of its parameters as "of-parameter".
Example:
void func(String a, {Foo? foo, int !b=0} {...} // b is an of-parameter
There're two ways to call this function: one is the usual
func('hello", foo: Foo(), b: 42) // b can be omitted - it's optional
Another way is
func(hello", foo: Foo()) of 42; // 42 becomes the value of b b/c it's an "of-paramerer"

of-parameters in Widgets

Write a script that goes through Flutter codebase and makes every "child" and "children" parameter an of-parameter.
If there are other (childless) places, you can declare more of-parameters there (whenever makes sense)
(The change won't affect any existing code)

Explicit partial application

If we want to partially apply func leaving only an of-parameter "hanging", write
final f = func("hello", foo: Foo) of!;
and then call it like f() of 42 or f of 42 (two equivalent forms).
If you omit "of!", the call func("hello", foo: Foo) will use the default value of b, so instead of "partial application" you will get a result of invocation (which is a current behavior).

Restrictions

There are some obvious restrictions on partial application. E.g. to leave an of-parameter hanging from this declaration
final f = func(actual parameters) of!, you need to pass all required parameters to func, with the possible exception of of-parameter itself.

Example

With these changes, the example from the last comment will work. For better readability, I'll refactor it slightly

final slider =  Slider(
   min: 1, 
   max: maxSeeds.toDouble(), 
   value: seeds.toDouble(), 
   onChanged: (val) {setState(() => seeds = val.round());}
);
...
home: Scaffold(
  appBar: AppBar(title: const Text('Sunflower')),
  body: Center of
    Column(crossAxisAlignment: CrossAxisAlignment.center) of [
      Expanded of SunflowerWidget(seeds),
      const SizedBox(height: 20),
      Text('Showing ${seeds.round()} seeds'),
      SizedBox(width: 300) of slider,
      const SizedBox(height: 20),
    ]
),

For comparison, here's an original ("BEFORE") version

Look Inside
home: Scaffold(
        appBar: AppBar(
          title: const Text('Sunflower'),
        ),
        body: Center(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              Expanded(
                child: SunflowerWidget(seeds),
              ),
              const SizedBox(height: 20),
              Text('Showing ${seeds.round()} seeds'),
              SizedBox(
                width: 300,
                child: Slider(
                  min: 1,
                  max: maxSeeds.toDouble(),
                  value: seeds.toDouble(),
                  onChanged: (val) {
                    setState(() => seeds = val.round());
                  },
                ),
              ),
              const SizedBox(height: 20),
            ],
          ),
        ),
      ),

NOTE: this construction is based on the same idea as OP, with less exotic syntax. The formalization is different though.

@tatumizer
Copy link

tatumizer commented Jan 18, 2025

An interesting perspective of looking into the above mechanism is considering it a variant of tear-off.
With a normal tear-off, we separate the "function name" from the whole list of parameters.
But with of-parameter, we tear off everything, leaving just one of the parameters alive.
There's a whole spectrum of other kinds of (in-between) tear-offs resulting from partial function application, but these two variants are most useful.

@nate-thegrate
Copy link

I'm a bit late to the party, but this is super interesting.

My personal opinion: a messy build method with many nested constructors is still going to be a messy build method after the indentation and/or brackets go away, and it might even become more difficult to visually parse.

I believe the new "tall mode" formatter is already doing a fantastic job of mitigating the )}))]) problem.
If we already had a pipe-like operator, I would feel conflicted about whether I liked it; given the opportunity cost of implementation, at this point I feel strongly inclined to oppose this.


how much less cluttered a widget tree could look like just by allowing child/children to be positional parameter if Dart would allow positional parameters at any position

I would love if we had a straightforward way to get rid of the explicit child and children names. My understanding is that currently we can put a positional parameter after named params, but optional positional parameters and named parameters cannot coexist.

It'd be so nice to go from

Center(
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      Text('okay'),
    ],
  ),
)

to:

Center(
  Column(
    mainAxisSize: MainAxisSize.min,
    [
      Text('Fabulous'),
    ],
  ),
)

IMO the small tweak would be an aesthetic upgrade for every widget tree in existence. I'd be really excited about pursuing either #1076 or #2232, and I imagine it'd be a straightforward migration on the Flutter side:

class Container extends StatelessWidget {
  Container([Widget? $child], {Widget? child}) : child = $child ?? child;

  final Widget? child;
}

@lrhn
Copy link
Member

lrhn commented Jan 31, 2025

One feature that could help is "named positional arguments", being able to pass a positional argument by name instead of position. That does mean that the name becomes significant, so it may need opt-in syntax.

Then you would be able to call, fx, Point(this.x, this.y); as Point(1, 2) or as Point(y: 2, x: 1).
Or Point(y: 2, 1).

It would be based on positional names behind visible in a function type or function signature, without being meaningful in subtyping.

@tatumizer
Copy link

@nate-thegrate: Column has 10 optional parameters (including children) - how are you going to tell the compiler that [ Text('Fabulous') ] refers to children? To trigger a special treatment of this parameter, you must mark it somehow, right?
(No manipulations with "named vs positional" will help here).

@dickermoshe
Copy link

dickermoshe commented Jan 31, 2025

@lrhn
As someone whose coming from python, I found dart simplistic approach to parameters very refreshing.
I can't tell you how many times I've shot myself in the foot because python allows this.

someFunction(bool a, bool b){ }
someFunction(true, false) // Hard to understand

someFunction({required bool a,required bool b}){ }
someFunction(isUtc: true, isToday: false) // Easy to understand

Allowing optional arguments to come 1st sounds like a great idea, but "named positional arguments" induces increased complexity.
In fact, I think this is a prime example how dart's limited flexibility is an asset.

@nate-thegrate
Copy link

@nate-thegrate: Column has 10 optional parameters (including children) - how are you going to tell the compiler that [ Text('Fabulous') ] refers to children? To trigger a special treatment of this parameter, you must mark it somehow, right?
(No manipulations with "named vs positional" will help here).

I believe the solution is for children to transition from a named parameter to an optional positional parameter. During the migration there will be 2 parameters attached to the same value.

Column extends Flex and Flex extends MultiChildRenderObjectWidget, so the migration might look as follows:

// Current setup
abstract class MultiChildRenderObjectWidget extends RenderObjectWidget {
  const MultiChildRenderObjectWidget({super.key, this.children = const <Widget>[]});
}
class Column extends Flex {
  const Column({super.key, super.children});
}


// Migration period
abstract class MultiChildRenderObjectWidget extends RenderObjectWidget {
  const MultiChildRenderObjectWidget([List<Widget>? $children], {super.key, List<Widget>? children})
      : children = $children ?? children ?? const <Widget>[];
}
class Column extends Flex {
  const Column([super.$children], {super.key, super.children});
}


// Migration complete
abstract class MultiChildRenderObjectWidget extends RenderObjectWidget {
  const MultiChildRenderObjectWidget([this.children = const <Widget>[]], {super.key});
}
class Column extends Flex {
  const Column([super.children], {super.key});
}

Due to how prevalent widgets like Column are, perhaps the migration period could extend beyond the normal 1-year timeframe. I don't see much of a downside to that.

@nate-thegrate
Copy link

Allowing optional arguments to come 1st sounds like a great idea, but "named positional arguments" induces increased complexity.

This is a very good point, especially in the context of boolean arguments. I'm glad we have the avoid_positional_boolean_parameters linter rule; perhaps sometime it could be added to the default recommended ruleset.

@escamoteur
Copy link

As someone who proposed moving child/children as positional parameter as last parameter for a long time, I strongly support that we move the discussion in this direction.
If we would allow to pass positional parameters by name as @lrhn mentioned, we could easily move child/children as a last positional parameter while existing code would stay backwards compatible. That way we wouldn't even need to add a second $child parameter and wouldn't need to allow positional optional together with named optional.

@tatumizer
Copy link

Reclassifying child/children as a positional parameter, even with the option to preserve 'named' syntax, won't solve any of the problems. What's the point? The number of lines in your code will remain the same; the ladder of closing ))....) remains the same (no matter how they are formatted); you still can't define Column(...) with all the details outside the literal (leaving just children as a parameter). Arguably, the literal becomes even less readable (try it out on a large literal!)
Allowing the of syntax for the parameter (as proposed above) was supposed to address all those issues.

@nate-thegrate
Copy link

nate-thegrate commented Jan 31, 2025

What's the point? The number of lines in your code will remain the same

I feel you, comrade. For a good portion of the past year I was dedicated to making the Flutter framework more concise; after 42 PRs I managed to get a total reduction of 4,926 LOC.



But other things aside from line count have a big impact on readability, which is why (in my opinion) the ladder of closing brackets isn't necessarily a bad thing.

It'd be so nice to go from

Center(
  child: Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      Text('okay'),
    ],
  ),
)

to:

Center(
  Column(
    mainAxisSize: MainAxisSize.min,
    [
      Text('Fabulous'),
    ],
  ),
)

I personally feel strongly about this, but you (and others) certainly have a right to disagree 🙂

@escamoteur
Copy link

escamoteur commented Jan 31, 2025 via email

@tatumizer
Copy link

tatumizer commented Jan 31, 2025

@escamoteur: I agree that explicit "child"/"children" is annoying. But I think removing it, though a good step (I like your example), is not enough.
I'd like to be able to compose the literal from the smaller building blocks but preserve the overall nested structure.
And indeed. I can do it today:

MyColumn(List<Widget> children) => Column(
   mainAxisAlignment: MainAxisAlignment.start, 
   spacing: 0.1,
   children: children);  // leaving only "children" as a parameter!

Then I could use MyColumn in the literal.

MyColumn([
     Expanded(
        ClipPath(
           // etc..

But:

  1. for some reason, this style of programming is not common. Or maybe it can become more common if children becomes a positional parameter? (Please comment).
  2. Having some sugar while defining MyColumn won't hurt.

@nate-thegrate
Copy link

for some reason, this style of programming is not common. Or maybe it can become more common if children becomes a positional parameter?

Absolutely! If you found yourself repeatedly using the same spacing, you could make a convenient subclass in 3 lines of code.

class MyColumn extends Column {
  const MyColumn([super.children]) : super(spacing: 1);
}

(The same is possible today, but it'd go against the existing constructor signature.)

@escamoteur
Copy link

escamoteur commented Jan 31, 2025 via email

@tatumizer
Copy link

If it indeed turns out that we can't build a big thing out of smaller things without dramatically affecting performance, then we have an issue 10 times worse than the one being discussed on this thread. 😄

@lukepighetti
Copy link

Problem:

One common complaint with Flutter is how widget trees tend to get very nested.

There are various attempts at solving this, such as using the builder pattern. Cf: https://www.reddit.com/r/FlutterDev/comments/hmwpgm/dart_extensions_to_flatten_flutters_deep_nested/

More recently, this was suggested during the Flutter in production livestream

But relying on chained methods to solve the problem has numerous issues:

  • We loose the ability to use const constructors

  • We reverse the reading order. a(b(c())) becomes c().b().a()

  • It involves a lot of extra work to allow people to create their own widgets.

    Using builders, we need to define both a class, and an extension method that maps to said class:

    class MyWidget extends StatelessWidget {
    
      const MyWidget({this.child});
    
    }
    
    
    
    // Necessary extension for every custom widget, including any necessary parameter
    
    extension on Widget {
    
      MyWidget myWidget() => MyWidget(child: this);
    
    }
    
  • A widget is sometimes used as Class() and sometimes as .class(), which feels a bit inconsistent

  • There's no way to support named constructors. For example, ListView has ListView() and ListView.builder(). But as methods, at best we'd have .listView() vs .listViewBuilder()

  • It breaks "go-to-definition", "rename" and "find all reference" for widgets. Using "go to definition" on .myWidget() redirects to the extension, and renaming .myWidget() won't rename MyWidget()

Proposal:

I suggest introducing two things:

  • a new "pipe-like" operator, for chaining constructor/method class

  • a keyword placed on parameters to tell the "pipe" operator how the pipe should perform.

    Such keyword could be used on any parameter. Positional or named, and required or optional. But the keyword can be specified at most once per constructor/function, as only a single parameter can be piped.

The idea is that one parameter per constructor/function definition can be marked with pipable. For example:

class Center extends StatelessWidget {

  Center({

    super.key,

    required pipable this.child, // We mark "child" as pipable

  });

  final Widget child;

}

When a parameter is marked as such, the associated constructor/function becomes usable in combination with a new "pipe" operator.

We could therefore write:

Center()

  |> DecoratedBox(color: Colors.red)

  |> Text('Hello world');

Internally, this would be strictly identical to:

Center(

  child: DecoratedBox(

    color: Colors.red,

    child: Text('Hello world'),

  )

)

In fact, we could support the const keyword:

const Center()

  |> DecoratedBox(color: Colors.red)

  |> Text('Hello world');

Note:

Since in our Center example, the child parameter is required, it would be a compilation error to not use the pipe operator.

As such, this is invalid:

Center();

Similarly, the |>operator isn't mandatory to use our Center widget. We are free to use it the "normal" way and write Center(chid: ...).

In a sense, the combo of the pipable keyword + |> operator enables a different way to pass a single parameter, for the sake of simple chaining.

Conclusion:

This should solve all of the problems mentioned at the top of the page.

  • We can still use const on a complex widget tree

  • The reading order is presserved (top-down instead of bottom-up)

  • Supporting this syntax doesn't involve creating a new extension everytimes

  • Widgets are always used through their constructor

  • Named cosntructors are supported

  • IDE-specific operation such as "renaming/go-to-definition/..." keep working.

As a bonus, the syntax is fully backward compatible. It is not a breaking change to allow a class to be used this way, as the class can still be used as is this feature didn't exist.

a let extension (see Kotlin) is the equivalent to a simple pipe operator. adding that to the sdk would be trivial and many projects already use it. it also works nicely with null coalescing.

widget extensions give us the benefits of the pipe operator examples shown above, are easier to type, and are already widely in use in projects (either a couple extensions or a full suite of styling and layout extensions)

in short: adding a let extension and widget extensions is a totally viable option to a pipe operator, fits commonly used patterns, and doesn't require any paradigm shifts or big reworks

@maks
Copy link

maks commented Feb 1, 2025

@lukepighetti can you give an example of what the syntax for a widget tree would look like with your proposal to use a let extension?

@lukepighetti
Copy link

lukepighetti commented Feb 1, 2025

@lukepighetti can you give an example of what the syntax for a widget tree would look like with your proposal to use a let extension?

you wouldn't use a let extension with Widgets unless you had a function with a single positional parameter with type Widget which is extremely uncommon. you'd use widget extensions.

there are some languages (mintlang) that perform algebra on the arguments passing through the unspecified parameters to the next method in the chain but this is complicated and confusing

@ekuleshov
Copy link

The idea is that one parameter per constructor/function definition can be marked with pipable. ...
When a parameter is marked as such, the associated constructor/function becomes usable in combination with a new "pipe" operator...

It feels like a dejavu for a feature supported in several other languages (Swift, Kotlin, Groovy), which essentially allows you to move the last parameter of a lambda type out of the parameter list into a code block after method call statement like foo(...) { body }.

Dart's lambdas having two forms (args) -> expression and (args) { statements } and if compiler could rewrite and inline a lambda body into a pipable parameters (or even use a builder in case a "statement" form is used). Then syntax like this would get valid:

const Center()
  => DecoratedBox(color: Colors.red)
  => Text('Hello world');

@ekuleshov
Copy link

On the other hand, if pipable worked as variant of a cascade operator .. with constructors and methods, then pipable declaration could support multiple parameters, using an optional parameter name:

const Center()
  |.. DecoratedBox(color: Colors.red)
  |.. ListenableBuilder(listenable: value)
  |..child = Text('Hello world')
  |..builder = (context, child) => Text('Foo');

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature Proposed language feature that solves one or more problems
Projects
None yet
Development

No branches or pull requests