Created
May 14, 2022 16:33
-
-
Save luiscarch11/f5fb08a30d59bd2d42b8915f02fb77f6 to your computer and use it in GitHub Desktop.
Custom dropdown menu in Flutter
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'dart:math'; | |
import 'package:flutter/material.dart'; | |
class DropdownWidget<T> extends StatefulWidget { | |
const DropdownWidget({ | |
Key? key, | |
required this.items, | |
required this.itemBuilder, | |
required this.onChanged, | |
required this.value, | |
this.header, | |
this.unselectedItemColor, | |
this.width = 170, | |
}) : super(key: key); | |
final List<T> items; | |
final String? header; | |
final void Function(T value) onChanged; | |
final String Function(T item) itemBuilder; | |
final T value; | |
final double width; | |
final Color? unselectedItemColor; | |
@override | |
State<DropdownWidget<T>> createState() => _DropdownWidgetState<T>(); | |
} | |
class _DropdownWidgetState<T> extends State<DropdownWidget<T>> with SingleTickerProviderStateMixin { | |
var _expanded = false; | |
OverlayEntry? overlay; | |
late AnimationController controller; | |
late Animation<double> arrowAngle; | |
final key = GlobalKey(); | |
@override | |
void initState() { | |
controller = AnimationController( | |
vsync: this, | |
duration: const Duration( | |
milliseconds: 100, | |
), | |
); | |
controller.addListener(() { | |
setState(() {}); | |
}); | |
arrowAngle = Tween<double>( | |
begin: 0, | |
end: pi, | |
).animate(controller); | |
super.initState(); | |
} | |
void _changeExpansionStatus() { | |
if (_expanded) { | |
removeOverlay(); | |
} else { | |
insertOverlay(context); | |
} | |
setState(() { | |
_expanded = !_expanded; | |
_expanded ? controller.forward() : controller.reverse(); | |
}); | |
} | |
@override | |
void dispose() { | |
if (_expanded && overlay != null) overlay?.remove(); | |
super.dispose(); | |
} | |
void removeOverlay() { | |
overlay?.remove(); | |
} | |
final layerLink = LayerLink(); | |
void insertOverlay(BuildContext context) { | |
overlay = OverlayEntry( | |
maintainState: true, | |
builder: (context) => Positioned( | |
top: key.currentContext!.position.dy, | |
left: key.currentContext!.position.dx, | |
child: CompositedTransformFollower( | |
showWhenUnlinked: false, | |
offset: const Offset( | |
0, | |
42, | |
), | |
link: layerLink, | |
child: _OverlayDropdown<T>( | |
width: widget.width, | |
selectedValue: widget.value, | |
values: widget.items, | |
color: widget.unselectedItemColor, | |
onSelectedItem: (val) { | |
_changeExpansionStatus(); | |
widget.onChanged.call(val); | |
}, | |
textBuilder: widget.itemBuilder, | |
), | |
), | |
), | |
); | |
Overlay.of(context)?.insert(overlay!); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return Column( | |
crossAxisAlignment: CrossAxisAlignment.start, | |
children: [ | |
if (widget.header != null) ...[ | |
Text( | |
widget.header!, | |
style: StyleConstants.titleStyle.copyWith( | |
color: ColorConstants.buttonEnabledColor, | |
), | |
), | |
const SpaceWidget.h(8), | |
], | |
CompositedTransformTarget( | |
link: layerLink, | |
child: GestureDetector( | |
onTap: _changeExpansionStatus, | |
child: Container( | |
key: key, | |
height: 42, | |
padding: const EdgeInsets.all(12), | |
width: widget.width, | |
decoration: BoxDecoration( | |
border: Border.all( | |
color: ColorConstants.buttonEnabledColor.withOpacity(.7), | |
), | |
borderRadius: BorderRadius.circular(3), | |
), | |
child: Row( | |
crossAxisAlignment: CrossAxisAlignment.center, | |
children: [ | |
SizedBox( | |
width: 110, | |
child: Text( | |
widget.itemBuilder.call(widget.value), | |
style: StyleConstants.nameSTextStyle.copyWith( | |
color: ColorConstants.buttonEnabledColor, | |
), | |
overflow: TextOverflow.ellipsis, | |
maxLines: 1, | |
), | |
), | |
const Spacer(), | |
Transform.rotate( | |
angle: arrowAngle.value, | |
child: Icon( | |
Icons.keyboard_arrow_down, | |
size: 24, | |
color: ColorConstants.buttonEnabledColor.withOpacity(.8), | |
), | |
) | |
], | |
), | |
), | |
), | |
), | |
], | |
); | |
} | |
} | |
class _OverlayDropdown<T> extends StatelessWidget { | |
const _OverlayDropdown({ | |
Key? key, | |
required this.values, | |
required this.textBuilder, | |
required this.selectedValue, | |
required this.onSelectedItem, | |
required this.width, | |
this.color = Colors.transparent, | |
}) : super(key: key); | |
final List<T> values; | |
final Color? color; | |
final double width; | |
final T selectedValue; | |
final String Function(T item) textBuilder; | |
final void Function(T value) onSelectedItem; | |
@override | |
Widget build(BuildContext context) { | |
return Material( | |
color: color ?? Colors.transparent, | |
child: Container( | |
width: width, | |
decoration: BoxDecoration( | |
border: Border.all( | |
color: ColorConstants.buttonEnabledColor.withOpacity(.7), | |
), | |
borderRadius: BorderRadius.circular(3), | |
), | |
child: Column( | |
children: values | |
.map<_OverlayDropdownItem<T>>( | |
(e) => _OverlayDropdownItem<T>( | |
value: e, | |
textBuilder: textBuilder, | |
onTap: onSelectedItem, | |
isSelected: e == selectedValue, | |
), | |
) | |
.toList(), | |
), | |
), | |
); | |
} | |
} | |
class _OverlayDropdownItem<T> extends StatelessWidget { | |
const _OverlayDropdownItem({ | |
Key? key, | |
required this.value, | |
required this.textBuilder, | |
required this.isSelected, | |
required this.onTap, | |
}) : super(key: key); | |
final T value; | |
final bool isSelected; | |
final String Function(T item) textBuilder; | |
final void Function(T value) onTap; | |
@override | |
Widget build(BuildContext context) { | |
return GestureDetector( | |
onTap: () => onTap.call(value), | |
child: Container( | |
width: double.infinity, | |
padding: const EdgeInsets.symmetric( | |
vertical: 6, | |
horizontal: 12, | |
), | |
height: 32, | |
color: isSelected ? ColorConstants.redHeaderColor : Colors.transparent, | |
child: Text( | |
textBuilder.call(value), | |
style: StyleConstants.nameSTextStyle.copyWith( | |
color: ColorConstants.buttonEnabledColor, | |
), | |
), | |
), | |
); | |
} | |
} | |
extension _BuildContextExtension on BuildContext { | |
RenderBox get renderBox => findRenderObject() as RenderBox; | |
Offset get position { | |
final render = renderBox.localToGlobal(Offset.zero); | |
return render; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment