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

How to Create a Flexible Custom Wrapper for Widgets to overriding some default properties? #3777

Open
talpx0 opened this issue May 2, 2024 · 4 comments
Labels
request Requests to resolve a particular developer problem

Comments

@talpx0
Copy link

talpx0 commented May 2, 2024

I'm currently developing a custom wrapper for widgets in Flutter that enables extensive customization, similar to how {...rest} works in TypeScript with React components. However, Dart doesn't support the spread operator for widget properties, so I'm struggling to find an effective way to forward a wide range of properties to a base widget like IconButton.

Here's an example of what I might do in TypeScript:

const CustomIconButton: React.FC = ({
  accessibilityLabel,
  ...rest
}) => {
  return (
    <IconButton {...rest} aria-label={"go to next"} color={"red"}/>
  );
};

In Flutter, I'm looking for a way to achieve something similar. How can I create a wrapper that allows users to customize any widget with multiple properties without manually specifying each one? Here's a basic approach in Dart, but it requires explicitly forwarding each property, which isn't scalable. For example, an IconButton can have additional properties like color and border. Manually copying all of them can make the code redundant. Here's my current approach:

import 'package:flutter/material.dart';

class CustomIconButton extends StatelessWidget {
  final VoidCallback onPressed;
  final IconData icon;
  final IconButtonConfig config;

  const CustomIconButton({
    Key? key,
    required this.onPressed,
    required this.icon,
    this.config = const IconButtonConfig(),
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return IconButton(
      icon: Icon(icon),
      onPressed: onPressed,
      tooltip: config.tooltip,
      color: config.color,
      // add other IconButton properties here using the config object
    );
  }
}

class IconButtonConfig {
  final String tooltip;
  final Color color;
  final double iconSize;

  const IconButtonConfig({
    this.tooltip = '',
    this.color = Colors.black,
    this.iconSize = 24.0,
  });
}

How can I achieve similar behavior to React Native in Flutter? Any insights would be greatly appreciated!

I try to use extend and extension , but they all looks like required you copy all the properties.

@talpx0 talpx0 added the request Requests to resolve a particular developer problem label May 2, 2024
@mraleph
Copy link
Member

mraleph commented May 3, 2024

The short answer is: you can't avoid manually forwarding the properties. Dart does not have a way to abstract over parameter/argument lists and say "this function has all parameters of that other function"

See also #1781

@tatumizer
Copy link

tatumizer commented May 3, 2024

If $arguments are implemented (see #3742) then forwarding might be within reach, we just need an extra annotation and a spread operator.

foo (int a, int b, {int? c, int?d}) {
   @ignoreRedundant bar(0, b, c:2, ...$arguments); // generally, ...myRecord
}

Annotation @ignoreRedundant will instruct the compiler to take the first occurrence of the parameter in a parameter list (ignoring repetitions that come from the spread). 

All mandatory positional parameters are supposed to be passed explicitly , including the optional ones (the compiler verifies that).  All positional parameters coming from the spread are ignored.

If some "unexpected" (by bar) named parameter occurs in the spread - error.

The spread operator is allowed only at the end of the parameter list.

The case when $arguments contain some named parameters invalid for bar remains unsolved. (Perhaps those can be ignored, too?).

Since every name and type are verified statically, the mechanism appears safe. (I'm not sure the annotation is even necessary. It can be assumed whenever we use the spread operator)

@tatumizer
Copy link

tatumizer commented May 4, 2024

Take 2 (trying to put it more coherently)

Spread operator in parameter list
Let r be a record of statically known type.
The expression foo(...r) "unwraps" r and puts all its elements (both positional and named) into the parameter list.
The resulting list is checked against the signature of foo. All named parameters that are not supported by foo are removed from the list by the compiler (maybe with lint warning).

Obtaining the list of actual parameters as a record
Inside a function f, we can obtain the list of parameters passed by a caller as a record, denoted as $arguments (exact syntax - TBD)
The record contains all parameters defined in the signature of f.
For optional parameters that were not passed, the corresponding element in $arguments is set to null (regardless of whether the parameter, as declared by f, has a default value or not ).

Merging records
Let's introduce a new operator + on records. Suppose r1 and r2 are records of statically known types T1 and T2.
Operator r1 + r2 creates a new record in which all positional elements are taken from r1, and the named elements are merged (those present in r1 take priority).

Q: why all positional elements of r2 are ignored, instead of being merged with the positional elements of r1?
A: because it's impossible to define what "merge" means in this case. It can mean concatenation of the list of positional parameters. It can also mean prioritization of elements of r1 over corresponding elements of r2. The number of positional parameters is usually small - it's not hard to write them manually and put them in r1.

Call forwarding
Example:

foo (int a, int b, {int? c, int?d}) {
   bar(... (0, b, c:2)+$arguments); 
}

EDIT: Removed the section about copyWith. I thought copyWith can also be implemented similarly - by merging two records - but I was probably mistaken. Not sure.
What's even worse is that because of possible nullable parameters with non-null default values, the idea of representing omitted parameters as nulls in $arguments won't work in a general case. Too bad :(.

@tatumizer
Copy link

tatumizer commented May 6, 2024

The idea can still be resurrected by introducing a type NoneOr<T> along the lines of FutureOr. This was discussed previously, but there was no appetite for this type because no one wants another null. I think the following design addresses this concern.

The type NoneOr can be defined so that it cannot be meaningfully used for anything other than passing parameters. There's no easy way even to tell whether the object contains a value. The methods defined for NoneOr are those inherited from Object, plus only one specific method: orElse(value) (where value, generally, can again be of type NoneOr). The semantics of the method are obvious (omitted for brevity).

When the object of type NoneOr is passed as an optional parameter, the compiler behind the scenes invokes orElse(defaultValue) automatically, so the callee will never see it as NoneOr.

However, to implement forwarding (or copyWith, etc.) correctly(*), the function must be able to obtain the "raw" list of parameters before the substitution of default values, and this list must include those NoneOrs that correspond to omitted parameters. The (pseudo-)variable  $arguments contains such a list.

As an example, the implementation of copyWith may look like this:

class A {
   final int a, b;   
   final int? c; // c is nullable!
   A({required this.a, required this.b, this.c}){}
   A copyWith({late int a, late int b, late int? c}) {
      var args=$arguments;     
      return A(args.a.orElse(this.a), args.b.orElse(this.b), args.c.orElse(this.c)); 
      // note that every parameter of `A(...)` constructor call has a correct static type.
   }
}

Here, we are reusing an idea of "late" introduced in #3680, but without the ?? operator.
Forwarding can be implemented similarly

foo({late int b, late int c}) {
   //  forward b and c to bar, replacing b with zero if not set   
  var args = $arguments;   
  bar(b: args.b.orElse(0), c: args.c);
}

This works without extra sugar, but we must manually pass every parameter, which quickly becomes boring and error-prone. To address this, we can introduce operations on records along the lines of the previous comment.

(*) otherwise, we can't distinguish between omitted parameters and those explicitly passed by null, and, as a result, have to impose some unnatural restrictions.

EDIT: forgot to mention another use case for NoneOr: passing parameters conditionally:

foo(a: if (cond) 42); // the "if" expression has type NoneOr<int>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
request Requests to resolve a particular developer problem
Projects
None yet
Development

No branches or pull requests

3 participants