feat: add simple dropdown component (#7986)

* feat: add simple dropdown component

* chore: code style
This commit is contained in:
Richard Shiue 2025-05-27 17:31:16 +08:00 committed by GitHub
parent a92e59c693
commit dc5f463e84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 494 additions and 4 deletions

View File

@ -3,7 +3,6 @@ import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
extension MobileSearchIconItemExtension on ResultIconPB {
Widget? buildIcon(BuildContext context) {
final theme = AppFlowyTheme.of(context);

View File

@ -1,9 +1,10 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:appflowy_ui_example/src/avatar/avatar_page.dart';
import 'package:appflowy_ui_example/src/menu/menu_page.dart';
import 'package:flutter/material.dart';
import 'src/avatar/avatar_page.dart';
import 'src/buttons/buttons_page.dart';
import 'src/dropdown_menu/dropdown_menu_page.dart';
import 'src/menu/menu_page.dart';
import 'src/modal/modal_page.dart';
import 'src/textfield/textfield_page.dart';
@ -71,6 +72,7 @@ class _MyHomePageState extends State<MyHomePage> {
Tab(text: 'Modal'),
Tab(text: 'Avatar'),
Tab(text: 'Menu'),
Tab(text: 'Dropdown Menu'),
];
@override
@ -106,6 +108,7 @@ class _MyHomePageState extends State<MyHomePage> {
ModalPage(),
AvatarPage(),
MenuPage(),
DropdownMenuPage(),
],
),
bottomNavigationBar: TabBar(

View File

@ -0,0 +1,122 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
class AFDropDownMenuItem with AFDropDownMenuMixin {
const AFDropDownMenuItem({
required this.label,
});
@override
final String label;
}
class DropdownMenuPage extends StatefulWidget {
const DropdownMenuPage({super.key});
@override
State<DropdownMenuPage> createState() => _DropdownMenuPageState();
}
class _DropdownMenuPageState extends State<DropdownMenuPage> {
List<AFDropDownMenuItem> selectedItems = [];
bool isDisabled = false;
bool isMultiselect = false;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Stack(
children: [
Positioned(
left: theme.spacing.xxl,
top: theme.spacing.xxl,
child: Container(
padding: EdgeInsets.all(
theme.spacing.m,
),
decoration: BoxDecoration(
color: theme.backgroundColorScheme.primary,
borderRadius: BorderRadius.circular(theme.borderRadius.m),
boxShadow: theme.shadow.medium,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildOption(
'is disabled',
isDisabled,
(value) {
setState(() => isDisabled = value);
},
),
// _buildOption(
// 'multiselect',
// isMultiselect,
// (value) {
// setState(() => isMultiselect = value);
// },
// ),
],
),
),
),
Center(
child: SizedBox(
width: 240,
child: AFDropDownMenu(
items: items,
selectedItems: selectedItems,
isDisabled: isDisabled,
// isMultiselect: isMultiselect,
onSelected: (value) {
if (value != null) {
setState(() {
if (isMultiselect) {
if (selectedItems.contains(value)) {
selectedItems.remove(value);
} else {
selectedItems.add(value);
}
} else {
selectedItems
..clear()
..add(value);
}
});
}
},
),
),
),
],
);
}
Widget _buildOption(
String label,
bool value,
void Function(bool) onChanged,
) {
return Row(
children: [
SizedBox(
width: 200,
child: Text(label),
),
Switch(
value: value,
onChanged: onChanged,
),
],
);
}
static const items = [
AFDropDownMenuItem(label: 'Item 1'),
AFDropDownMenuItem(label: 'Item 2'),
AFDropDownMenuItem(label: 'Item 3'),
AFDropDownMenuItem(label: 'Item 4'),
AFDropDownMenuItem(label: 'Item 5'),
];
}

View File

@ -25,6 +25,8 @@ class AFBaseButton extends StatefulWidget {
this.backgroundColor,
this.ringColor,
this.disabled = false,
this.autofocus = false,
this.showFocusRing = true,
});
final VoidCallback? onTap;
@ -36,6 +38,8 @@ class AFBaseButton extends StatefulWidget {
final EdgeInsetsGeometry padding;
final double borderRadius;
final bool disabled;
final bool autofocus;
final bool showFocusRing;
final Widget Function(
BuildContext context,
@ -81,6 +85,7 @@ class _AFBaseButtonState extends State<AFBaseButton> {
onFocusChange: (isFocused) {
setState(() => this.isFocused = isFocused);
},
autofocus: widget.autofocus,
child: MouseRegion(
cursor: widget.onTap == null
? SystemMouseCursors.basic
@ -94,7 +99,7 @@ class _AFBaseButtonState extends State<AFBaseButton> {
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(widget.borderRadius),
border: isFocused
border: isFocused && widget.showFocusRing
? Border.all(
color: ringColor,
width: 2,

View File

@ -1,4 +1,5 @@
export 'button/button.dart';
export 'dropdown_menu/dropdown_menu.dart';
export 'separator/divider.dart';
export 'modal/modal.dart';
export 'textfield/textfield.dart';

View File

@ -0,0 +1,360 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
mixin AFDropDownMenuMixin {
String get label;
}
class AFDropDownMenu<T extends AFDropDownMenuMixin> extends StatefulWidget {
const AFDropDownMenu({
super.key,
required this.items,
required this.selectedItems,
this.onSelected,
this.closeOnSelect,
this.controller,
this.isClearEnabled = true,
this.errorText,
this.isRequired = false,
this.isDisabled = false,
this.emptyLabel,
this.clearIcon,
this.dropdownIcon,
this.selectedIcon,
});
final List<T> items;
final List<T> selectedItems;
final void Function(T? value)? onSelected;
final bool? closeOnSelect;
final AFPopoverController? controller;
final String? errorText;
final bool isRequired;
final bool isDisabled;
final bool isMultiselect = false;
final bool isClearEnabled;
final String? emptyLabel;
final Widget? clearIcon;
final Widget? dropdownIcon;
final Widget? selectedIcon;
@override
State<AFDropDownMenu<T>> createState() => _AFDropDownMenuState<T>();
}
class _AFDropDownMenuState<T extends AFDropDownMenuMixin>
extends State<AFDropDownMenu<T>> {
late final AFPopoverController controller;
bool isHovering = false;
bool isOpen = false;
@override
void initState() {
super.initState();
controller = widget.controller ?? AFPopoverController();
controller.addListener(popoverListener);
}
@override
void dispose() {
if (widget.controller == null) {
controller.dispose();
} else {
controller.removeListener(popoverListener);
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return LayoutBuilder(
builder: (context, constraints) {
return AFPopover(
controller: controller,
padding: EdgeInsets.zero,
anchor: AFAnchor(
childAlignment: Alignment.topCenter,
overlayAlignment: Alignment.bottomCenter,
offset: Offset(0, theme.spacing.xs),
),
decoration: BoxDecoration(
color: theme.surfaceColorScheme.layer01,
borderRadius: BorderRadius.circular(theme.borderRadius.m),
boxShadow: theme.shadow.small,
),
popover: (popoverContext) {
return ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth,
maxHeight: 300,
),
child: _DropdownPopoverContents(
items: widget.items,
onSelected: (item) {
widget.onSelected?.call(item);
if ((widget.closeOnSelect == null && !widget.isMultiselect) ||
widget.closeOnSelect == true) {
controller.hide();
}
},
selectedItems: widget.selectedItems,
selectedIcon: widget.selectedIcon,
isMultiselect: widget.isMultiselect,
),
);
},
child: MouseRegion(
onEnter: (_) => setState(() => isHovering = true),
onExit: (_) => setState(() => isHovering = false),
child: GestureDetector(
onTap: () {
if (widget.isDisabled) {
return;
}
if (controller.isOpen) {
controller.hide();
} else {
controller.show();
}
},
child: Container(
constraints: const BoxConstraints.tightFor(height: 32),
decoration: BoxDecoration(
border: Border.all(
color: widget.isDisabled
? theme.borderColorScheme.primary
: isOpen
? theme.borderColorScheme.themeThick
: isHovering
? theme.borderColorScheme.primaryHover
: theme.borderColorScheme.primary,
),
color: widget.isDisabled
? theme.fillColorScheme.contentHover
: null,
borderRadius: BorderRadius.circular(theme.borderRadius.m),
),
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.m,
vertical: theme.spacing.xs,
),
child: Row(
spacing: theme.spacing.xs,
children: [
Expanded(
child: _DropdownButtonContents(
items: widget.selectedItems,
isMultiselect: widget.isMultiselect,
isDisabled: widget.isDisabled,
emptyLabel: widget.emptyLabel,
),
),
if (widget.isClearEnabled &&
isOpen &&
widget.clearIcon != null)
widget.clearIcon!,
widget.dropdownIcon ??
SizedBox.square(
dimension: 20,
child: Icon(
Icons.arrow_drop_down,
size: 16,
),
),
],
),
),
),
),
);
},
);
}
void popoverListener() {
setState(() {
isOpen = controller.isOpen;
});
}
}
class _DropdownButtonContents<T extends AFDropDownMenuMixin>
extends StatelessWidget {
const _DropdownButtonContents({
super.key,
required this.items,
this.isDisabled = false,
this.isMultiselect = false,
this.emptyLabel,
});
final List<T> items;
final bool isMultiselect;
final bool isDisabled;
final String? emptyLabel;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
if (isMultiselect) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero,
child: Row(
spacing: theme.spacing.xs,
children: [
...items.map((item) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.spacing.s),
color: theme.surfaceContainerColorScheme.layer02,
),
padding: EdgeInsetsDirectional.fromSTEB(
theme.spacing.m,
1.0,
theme.spacing.s,
1.0,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
item.label,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
),
Icon(
Icons.cancel,
size: 16,
color: theme.iconColorScheme.tertiary,
),
],
),
);
}),
TextField(
enabled: !isDisabled,
decoration: InputDecoration(
hintText: items.isEmpty ? emptyLabel ?? "(optional)" : null,
hintStyle: theme.textStyle.body.standard(
color: theme.textColorScheme.tertiary,
),
border: InputBorder.none,
constraints: const BoxConstraints(maxWidth: 120),
isCollapsed: true,
isDense: true,
),
style: theme.textStyle.body.standard(
color: isDisabled
? theme.textColorScheme.tertiary
: theme.textColorScheme.primary,
),
),
],
),
);
}
return Text(
items.isEmpty ? emptyLabel ?? "(optional)" : items.first.label,
style: theme.textStyle.body.standard(
color: isDisabled || items.isEmpty
? theme.textColorScheme.tertiary
: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
);
}
}
class _DropdownPopoverContents<T extends AFDropDownMenuMixin>
extends StatelessWidget {
const _DropdownPopoverContents({
super.key,
required this.items,
this.selectedItems = const [],
this.onSelected,
this.isMultiselect = false,
this.selectedIcon,
});
final List<T> items;
final List<T> selectedItems;
final void Function(T? value)? onSelected;
final bool isMultiselect;
final Widget? selectedIcon;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return FocusScope(
autofocus: true,
child: ListView.builder(
itemCount: items.length,
physics: const ClampingScrollPhysics(),
padding: EdgeInsets.all(theme.spacing.m),
shrinkWrap: true,
itemBuilder: itemBuilder,
),
);
}
Widget itemBuilder(BuildContext context, int index) {
final theme = AppFlowyTheme.of(context);
final item = items[index];
return AFBaseButton(
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
horizontal: theme.spacing.m,
),
borderRadius: theme.borderRadius.m,
borderColor: (context, isHovering, disabled, isFocused) {
return Colors.transparent;
},
showFocusRing: false,
builder: (context, _, __) {
return Row(
spacing: theme.spacing.m,
children: [
Expanded(
child: Text(
item.label,
style: theme.textStyle.body
.standard(color: theme.textColorScheme.primary)
.copyWith(overflow: TextOverflow.ellipsis),
),
),
if (selectedItems.contains(item) && isMultiselect)
selectedIcon ??
Icon(
Icons.check,
color: theme.fillColorScheme.themeThick,
size: 20.0,
),
],
);
},
backgroundColor: (context, isHovering, _) {
if (selectedItems.contains(item) && !isMultiselect) {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.contentHover;
}
return Colors.transparent;
},
onTap: () {
onSelected?.call(item);
},
);
}
}