-
Notifications
You must be signed in to change notification settings - Fork 198
Description
TL;DR: Enum value options extensions are generated as global variables instead of being attached to each enum value.
Background
I’m creating a Flutter widget CupertinoSelectFormFieldRow<T> (inspired by CupertinoTextFormFieldRow and CupertinoListTile that lets the user select an option from a drop-down list or a pop-up menu, with each item corresponding to a value of an enum type. I would like to add a human-friendly name to each enum to be shown in a drop-down list or a pop-up menu (related to #919).
I found three solutions, with the first using JSON and enhanced enums in Dart, and the rest two using enums in Protocol Buffers to model the underlying data.
Solution 1: JSON + enhance enums + extra field
In the first solution, I modeled my underlying data with JSON (so that the data can be easily serialized and deserialized) and used enhanced enums in Dart, adding a title property to each enum type, and overriding the toString() method, delegating it to the title property, as shown below:
enum Direction {
up('UP'), down('DOWN'), left('LEFT'), right('RIGHT');
final String title;
const Direction(this.title);
@override
String toString() => title;
}
enum PhoneType {
mobile('Mobile'), home('Home'), work('Work');
final String title;
const PhoneType(this.title);
@override
String toString() => title;
}The complete Flutter code is in this dartpad and the code for CupertinoSelectFormFieldRow is shown below:
class CupertinoSelectFormFieldRow<T extends Enum> extends StatelessWidget {
final Widget title;
final List<T> candidates;
final T initialValue;
final String? Function(T)? validator;
final void Function(T)? onChanged;
final void Function(T)? onSaved;
const CupertinoSelectFormFieldRow({
super.key,
required this.title,
required this.candidates,
required this.initialValue,
this.validator,
this.onChanged,
this.onSaved,
});
@override
Widget build(BuildContext context) {
return FormField<T>(
builder: (field) => CupertinoListTile(
title: title,
additionalInfo: Text(field.value!.toString()),
trailing: const CupertinoListTileChevron(),
onTap: () async {
final selection = await showCupertinoModalPopup(
context: context,
builder: (context) => CupertinoActionSheet(
actions: candidates.map((candidate) {
return CupertinoActionSheetAction(
onPressed: () => Navigator.of(context).pop(candidate),
child: Text(candidate.toString()),
);
}).toList(),
),
);
if (selection == null) return;
field.didChange(selection);
onChanged?.call(selection);
},
),
initialValue: initialValue,
validator: (selection) => validator?.call(selection as T),
onSaved: (selection) => onSaved?.call(selection as T),
);
}
}In the code above, I used candidate.toString() instead of candidate.title or candidate.name. There are two reasons:
titleis not an intrinsic property ofEnumorT,- The
nameproperty of an enum value contains thelowerCamelCasevariable name (up,down,left,right,mobile,home, orwork), which is usually different from the human-friendly name.
The problem of this solution is that it’s difficult to evolve the schema of the data model with JSON.
Solution 2: Protocol Buffers enums + one extra map for each enum type
In the second solution, I modeled my data with Protocol Buffers, representing the choices as protobuf enums, as shown below:
// demo.proto
syntax = "proto3";
enum Direction {
DIRECTION_UNSPECIFIED = 0;
DIRECTION_UP = 1;
DIRECTION_DOWN = 2;
DIRECTION_LEFT = 3;
DIRECTION_RIGHT = 4;
}
enum PhoneType {
PHONE_TYPE_UNSPECIFIED = 0;
PHONE_TYPE_MOBILE = 1;
PHONE_TYPE_HOME = 2;
PHONE_TYPE_WORK = 3;
}
but there is no easy way to add a human-friendly name to each enum value. So I have to manually define two maps like this:
// In main.dart
final directions = {
Direction.DIRECTION_UP: 'UP',
Direction.DIRECTION_DOWN: 'DOWN',
Direction.DIRECTION_LEFT: 'LEFT',
Direction.DIRECTION_RIGHT: 'RIGHT',
};
final phoneTypes = {
PhoneType.PHONE_TYPE_UNSPECIFIED: 'Unspecified',
PhoneType.PHONE_TYPE_MOBILE: 'Mobile',
PhoneType.PHONE_TYPE_HOME: 'Home',
PhoneType.PHONE_TYPE_WORK: 'Work',
};and then add a map argument of type Map<T, String> to the CupertinoSelectFormFieldRow() constructor. The diff of main.dart against solution 1 is shown below:
Toggle code
import 'package:flutter/cupertino.dart';
+import 'package:protobuf/protobuf.dart';
+
+import 'src/demo.pb.dart';
-enum Direction {
- up('UP'),
- down('DOWN'),
- left('LEFT'),
- right('RIGHT');
- final String title;
- const Direction(this.title);
-
- @override
- String toString() => title;
-}
-
-enum PhoneType {
- mobile('Mobile'),
- home('Home'),
- work('Work');
-
- final String title;
-
- const PhoneType(this.title);
-
- @override
- String toString() => title;
-}
+final directions = {
+ Direction.DIRECTION_UNSPECIFIED: 'Unspecified',
+ Direction.DIRECTION_UP: 'UP',
+ Direction.DIRECTION_DOWN: 'DOWN',
+ Direction.DIRECTION_LEFT: 'LEFT',
+ Direction.DIRECTION_RIGHT: 'RIGHT',
+};
+
+final phoneTypes = {
+ PhoneType.PHONE_TYPE_UNSPECIFIED: 'Unspecified',
+ PhoneType.PHONE_TYPE_MOBILE: 'Mobile',
+ PhoneType.PHONE_TYPE_HOME: 'Home',
+ PhoneType.PHONE_TYPE_WORK: 'Work',
+};
void main() => runApp(const MyApp());
@@ -46,16 +37,18 @@ class MyApp extends StatelessWidget {
),
child: CupertinoListSection(
hasLeading: false,
CupertinoSelectFormFieldRow<Direction>(
- candidates: Direction.values,
+ candidates: Direction.values.sublist(1),
+ map: directions,
- initialValue: Direction.up,
+ initialValue: Direction.DIRECTION_UP,
),
CupertinoSelectFormFieldRow<PhoneType>(
- candidates: PhoneType.values,
+ candidates: PhoneType.values.sublist(1),
+ map: phoneTypes,
- initialValue: PhoneType.mobile,
+ initialValue: PhoneType.PHONE_TYPE_MOBILE,
),
],
),
@@ -64,9 +57,11 @@ class MyApp extends StatelessWidget {
}
}
-class CupertinoSelectFormFieldRow<T extends Enum> extends StatelessWidget {
+class CupertinoSelectFormFieldRow<T extends ProtobufEnum> extends StatelessWidget {
final Widget title;
final List<T> candidates;
+ final Map<T, String> map;
final T initialValue;
final String? Function(T)? validator;
final void Function(T)? onChanged;
@@ -76,6 +71,7 @@ class CupertinoSelectFormFieldRow<T extends Enum> extends StatelessWidget {
super.key,
required this.title,
required this.candidates,
+ required this.map,
required this.initialValue,
this.validator,
this.onChanged,
@@ -87,7 +83,7 @@ class CupertinoSelectFormFieldRow<T extends Enum> extends StatelessWidget {
return FormField<T>(
builder: (field) => CupertinoListTile(
title: title,
- additionalInfo: Text(field.value!.toString()),
+ additionalInfo: Text(map[field.value!] ?? 'Unknown'),
trailing: const CupertinoListTileChevron(),
onTap: () async {
final selection = await showCupertinoModalPopup(
@@ -96,7 +92,7 @@ class CupertinoSelectFormFieldRow<T extends Enum> extends StatelessWidget {
actions: candidates.map((candidate) {
return CupertinoActionSheetAction(
onPressed: () => Navigator.of(context).pop(candidate),
- child: Text(candidate.toString()),
+ child: Text(map[candidate] ?? 'Unknown'),
);
}).toList(),
),There are two problems of this solution:
- The definition of the maps are separated into
.protofile and.dartfile, making it difficult to keep them in sync. - It requires an extra
mapargument forCupertinoSelectFormFieldRow(), which is inconvenient and inelegant.
Solution 3: Protocol Buffers enums + enum value options
Finally I came across a post about Alternate names for enumeration values on the Google Groups forum and I thought it’s the best solution. The post says that we can extend google.protobuf.EnumValueOptions and add any extra fields to it, like this:
// demo.proto
syntax = "proto3";
import "google/protobuf/descriptor.proto";
option java_multiple_files = true;
option java_package = "com.example.protobuf";
extend google.protobuf.EnumValueOptions {
string title = 1000;
}
enum Direction {
DIRECTION_UNSPECIFIED = 0 [(title) = "Unspecified"];
DIRECTION_UP = 1 [(title) = "UP"];
DIRECTION_DOWN = 2 [(title) = "DOWN"];
DIRECTION_LEFT = 3 [(title) = "LEFT"];
DIRECTION_RIGHT = 4 [(title) = "RIGHT"];
}
enum PhoneType {
PHONE_TYPE_UNSPECIFIED = 0 [(title) = "Unspecified"];
PHONE_TYPE_MOBILE = 1 [(title) = "Mobile"];
PHONE_TYPE_HOME = 2 [(title) = "Home"];
PHONE_TYPE_WORK = 3 [(title) = "Work"];
}
Then in the Java code we can retrieve the extra title field for any enum value, like this:
// src/main/java/com/example/protobuf/Application.java
package com.example.protobuf;
public class Application {
public static void main(String[] args) {
var up = Direction.DIRECTION_DOWN;
System.out.println(up.getValueDescriptor().getOptions().getExtension(Demo.title));
var mobile = PhoneType.PHONE_TYPE_MOBILE;
System.out.println(mobile.getValueDescriptor().getOptions().getExtension(Demo.title));
}
}It looks great, but when I applied this technique to the Dart code, the protoc Dart plugin generates one enum descriptor blob as a global variable for each protobuf enum type, with each global variable containing all the values of all the extra fields of an enum type, as shown below:
Toggle code
// demo.pbjson.dart
import 'dart:convert' as $convert;
import 'dart:core' as $core;
import 'dart:typed_data' as $typed_data;
@$core.Deprecated('Use directionDescriptor instead')
const Direction$json = {
'1': 'Direction',
'2': [
{'1': 'DIRECTION_UNSPECIFIED', '2': 0, '3': {}},
{'1': 'DIRECTION_UP', '2': 1, '3': {}},
{'1': 'DIRECTION_DOWN', '2': 2, '3': {}},
{'1': 'DIRECTION_LEFT', '2': 3, '3': {}},
{'1': 'DIRECTION_RIGHT', '2': 4, '3': {}},
],
};
/// Descriptor for `Direction`. Decode as a `google.protobuf.EnumDescriptorProto`.
final $typed_data.Uint8List directionDescriptor = $convert.base64Decode(
'CglEaXJlY3Rpb24SKQoVRElSRUNUSU9OX1VOU1BFQ0lGSUVEEAAaDsI+C1Vuc3BlY2lmaWVkEh'
'cKDERJUkVDVElPTl9VUBABGgXCPgJVUBIbCg5ESVJFQ1RJT05fRE9XThACGgfCPgRET1dOEhsK'
'DkRJUkVDVElPTl9MRUZUEAMaB8I+BExFRlQSHQoPRElSRUNUSU9OX1JJR0hUEAQaCMI+BVJJR0'
'hU');
@$core.Deprecated('Use phoneTypeDescriptor instead')
const PhoneType$json = {
'1': 'PhoneType',
'2': [
{'1': 'PHONE_TYPE_UNSPECIFIED', '2': 0, '3': {}},
{'1': 'PHONE_TYPE_MOBILE', '2': 1, '3': {}},
{'1': 'PHONE_TYPE_HOME', '2': 2, '3': {}},
],
};
/// Descriptor for `PhoneType`. Decode as a `google.protobuf.EnumDescriptorProto`.
final $typed_data.Uint8List phoneTypeDescriptor = $convert.base64Decode(
'CglQaG9uZVR5cGUSKgoWUEhPTkVfVFlQRV9VTlNQRUNJRklFRBAAGg7CPgtVbnNwZWNpZmllZB'
'IgChFQSE9ORV9UWVBFX01PQklMRRABGgnCPgZNb2JpbGUSHAoPUEhPTkVfVFlQRV9IT01FEAIa'
'B8I+BEhvbWU=');Since the values of the extra field of the enum value options are stored in separate global variables instead of being attached to each enum value. There is no way in the implementation code of CupertinoSelectFormFieldRow to retrieve the associated title field for a generic enum value. Although we eliminated the maps, an extra enumDescriptor argument has to be added to CupertinoSelectFormFieldRow. The diff of main.dart against solution 2 is shown below:
Toggle code
import 'package:flutter/cupertino.dart';
import 'package:protobuf/protobuf.dart';
+import 'package:protobuf_wellknown/protobuf_wellknown.dart';
import 'src/demo.pb.dart';
+import 'src/demo.pbjson.dart';
-
-final directions = {
- Direction.DIRECTION_UNSPECIFIED: 'Unspecified',
- Direction.DIRECTION_UP: 'UP',
- Direction.DIRECTION_DOWN: 'DOWN',
- Direction.DIRECTION_LEFT: 'LEFT',
- Direction.DIRECTION_RIGHT: 'RIGHT',
-};
-
-final phoneTypes = {
- PhoneType.PHONE_TYPE_UNSPECIFIED: 'Unspecified',
- PhoneType.PHONE_TYPE_MOBILE: 'Mobile',
- PhoneType.PHONE_TYPE_HOME: 'Home',
- PhoneType.PHONE_TYPE_WORK: 'Work',
-};
void main() => runApp(const MyApp());
@@ -25,6 +12,17 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
+ ExtensionRegistry registry = ExtensionRegistry();
+ Demo.registerAllExtensions(registry);
+ final directionEnumDescriptor = EnumDescriptorProto.fromBuffer(
+ directionDescriptor,
+ registry,
+ );
+ final phoneTypeEnumDescriptor = EnumDescriptorProto.fromBuffer(
+ phoneTypeDescriptor,
+ registry,
+ );
+
return CupertinoApp(
debugShowCheckedModeBanner: false,
theme: const CupertinoThemeData(
@@ -41,13 +39,13 @@ class MyApp extends StatelessWidget {
CupertinoSelectFormFieldRow<Direction>(
title: const Text('Direction'),
candidates: Direction.values.sublist(1),
- map: directions,
+ enumDescriptor: directionEnumDescriptor,
initialValue: Direction.DIRECTION_UP,
),
CupertinoSelectFormFieldRow<PhoneType>(
title: const Text('Phone Type'),
candidates: PhoneType.values.sublist(1),
- map: phoneTypes,
+ enumDescriptor: phoneTypeEnumDescriptor,
initialValue: PhoneType.PHONE_TYPE_MOBILE,
),
],
@@ -61,7 +59,7 @@ class CupertinoSelectFormFieldRow<T extends ProtobufEnum>
extends StatelessWidget {
final Widget title;
final List<T> candidates;
- final Map<T, String> map;
+ final EnumDescriptorProto enumDescriptor;
final T initialValue;
final String? Function(T)? validator;
final void Function(T)? onChanged;
@@ -71,7 +69,7 @@ class CupertinoSelectFormFieldRow<T extends ProtobufEnum>
super.key,
required this.title,
required this.candidates,
- required this.map,
+ required this.enumDescriptor,
required this.initialValue,
this.validator,
this.onChanged,
@@ -83,16 +81,24 @@ class CupertinoSelectFormFieldRow<T extends ProtobufEnum>
return FormField<T>(
builder: (field) => CupertinoListTile(
title: title,
- additionalInfo: Text(map[field.value!] ?? 'Unknown'),
+ additionalInfo: Text(
+ enumDescriptor.value
+ .firstWhere((e) => e.number == field.value!.value)
+ .options
+ .getExtension(Demo.title),
+ ),
trailing: const CupertinoListTileChevron(),
onTap: () async {
final selection = await showCupertinoModalPopup(
context: context,
builder: (context) => CupertinoActionSheet(
- actions: candidates.map((candidate) {
+ actions: candidates.asMap().entries.map((entry) {
return CupertinoActionSheetAction(
- onPressed: () => Navigator.of(context).pop(candidate),
+ onPressed: () => Navigator.of(context).pop(entry.value),
- child: Text(map[candidate] ?? 'Unknown'),
+ child: Text(
+ enumDescriptor.value[entry.key + 1].options
+ .getExtension(Demo.title),
+ ),
);
}).toList(),
),In conclusion, solution 3 has the benefit of keeping the definition of human-friendly names next to the definition of the enum values, thus making it easy to keep them in sync, but it also requires an extra enumDescriptor argument (when applying the enum value options technique to Dart), so it’s still not elegant.
My Questions
- Is it possible to generate the enum value options extension in a way that it is associated with each enum value (similar to that in C++ and Java) so we can directly retrieve the extension field from the enum value instead of relying on an external map?
- Why did the team decide to generate the descriptors as global variables?
