mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2025-06-27 02:50:15 +00:00
feat: add simple dropdown component (#7986)
* feat: add simple dropdown component * chore: code style
This commit is contained in:
parent
a92e59c693
commit
dc5f463e84
@ -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);
|
||||
|
@ -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(
|
||||
|
@ -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'),
|
||||
];
|
||||
}
|
@ -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,
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user