소개
아래 조건을 만족하는 드롭 다운 버튼
- 'DropDownModel' 수 만큼 위젯이 생성 될 것,
- 드롭 다운 버튼이 여러개 있을 때, 그 중 하나만 열려있는 상태가 될 것
- 열리고 닫히는 애니메이션이 있을 것
- 선택 값이 색으로 표현 될 것
- 'FormState' 요구하는 다른 위젯에 포함 될 수 있을 것
- 필수가 아닌 값은 '빈값 검증' 제외 될 것
목적
- 쇼핑몰 물건 구매 할 때, "옵션 드롭 다운 위젯" 구현
DropDownModel
class DropDownModel {
final String label;
final List<String> content;
String? selectValue;
final bool? selectOption;
DropDownModel({required this.label, required this.content, this.selectValue, this.selectOption});
bool get getOk => selectValue != null;
Map<String , dynamic> toJson () => {
"label" : label,
"content" : content,
"selectValue" : selectValue,
};
}
class BaseDropdown extends FormField<DropDownModel> {
BaseDropdown({
super.key,
required DropDownModel item,
super.initialValue,
String? hint,
TextStyle? textStyle,
BoxDecoration? dropdownDecoration,
BoxDecoration? buttonDecoration,
BoxDecoration? selectDecoration,
super.onSaved,
super.validator,
ValueChanged<DropDownModel?>? onChanged,
required void Function(bool) btnOpen,
required bool currentBtn,
}) : super(
autovalidateMode: AutovalidateMode.onUserInteraction,
builder: (FormFieldState<DropDownModel> state) {
return _CustomDropdownStateful(
item: item,
items: item.content,
label: item.label,
value: state.value?.selectValue,
hint: hint,
textStyle: textStyle,
dropdownDecoration: dropdownDecoration,
buttonDecoration: buttonDecoration,
onChanged: (DropDownModel? newValue) {
state.didChange(newValue);
onChanged?.call(newValue);
},
errorText: state.errorText,
btnOpen: btnOpen,
currentBtn: currentBtn,
selectDecoration: selectDecoration,
);
},
);
}
class _CustomDropdownStateful extends StatefulWidget {
final List<String> items;
final DropDownModel item;
final String? value;
final String? hint;
final TextStyle? textStyle;
final BoxDecoration? dropdownDecoration;
final BoxDecoration? buttonDecoration;
final BoxDecoration? selectDecoration;
final ValueChanged<DropDownModel?>? onChanged;
final String? errorText;
final void Function(bool) btnOpen;
final bool currentBtn;
final String label;
const _CustomDropdownStateful({
super.key,
required this.items,
required this.item,
this.value,
this.hint,
this.textStyle,
this.selectDecoration,
this.dropdownDecoration,
this.buttonDecoration,
this.onChanged,
this.errorText,
required this.btnOpen,
required this.currentBtn,
required this.label,
});
@override
State<_CustomDropdownStateful> createState() => _CustomDropdownStatefulState();
}
class _CustomDropdownStatefulState extends State<_CustomDropdownStateful>
with SingleTickerProviderStateMixin {
bool isDropdownOpen = false;
late String? _selectValue;
late Animation<double> animation;
late AnimationController _controller;
final Tween<double> _tween = Tween<double>(begin: 0, end: 1);
@override
void initState() {
super.initState();
_selectValue = widget.value;
isDropdownOpen = widget.currentBtn;
_controller =
AnimationController(vsync: this, duration: const Duration(milliseconds: 300));
animation = _tween.animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
}
@override
void didUpdateWidget(covariant _CustomDropdownStateful oldWidget) {
super.didUpdateWidget(oldWidget);
if(oldWidget.currentBtn != widget.currentBtn) {
setState(() {
isDropdownOpen = widget.currentBtn;
if (isDropdownOpen) {
_controller.forward();
} else {
_controller.reverse();
}
});
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggleDropdown() {
setState(() {
if (isDropdownOpen) {
_controller.reverse();
} else {
_controller.forward();
}
isDropdownOpen = !isDropdownOpen;
widget.btnOpen(isDropdownOpen);
});
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.label,
style: widget.textStyle ?? const TextStyle(fontSize: 16),
),
GestureDetector(
onTap: _toggleDropdown,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: widget.buttonDecoration ??
BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_selectValue?.toString() ?? widget.hint ?? 'Select an item',
style: widget.textStyle ?? const TextStyle(fontSize: 16),
),
Icon(
isDropdownOpen
? Icons.arrow_drop_up
: Icons.arrow_drop_down,
color: Colors.grey,
),
],
),
),
),
if (widget.errorText != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
widget.errorText!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
),
AnimatedBuilder(
animation: animation,
builder: (context, child) {
if (animation.value <= 0) {
return SizedBox.shrink();
}
return ClipRect(
child: Align(
heightFactor: animation.value,
child: Opacity(
opacity: animation.value,
child: Container(
margin: const EdgeInsets.only(top: 8),
decoration: widget.dropdownDecoration ??
BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
color: Colors.white,
),
child: ListView(
shrinkWrap: true,
padding: EdgeInsets.zero,
children: widget.items.map((String item) {
final bool isSelected = item == _selectValue;
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
setState(() {
_selectValue = item;
widget.item.selectValue = _selectValue;
});
widget.onChanged?.call(widget.item);
_toggleDropdown();
},
child: Container(
decoration: isSelected ? widget.selectDecoration : widget.selectDecoration?.copyWith(color: Colors.transparent),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
child: Text(
item.toString(),
style: widget.textStyle ?? const TextStyle(fontSize: 16),
),
),
);
}).toList(),
),
),
),
),
);
},
),
],
);
}
}
class DropDownCollectionWidget extends StatefulWidget {
final List<DropDownModel> children;
final FormFieldSetter<DropDownModel>? onSaved;
final GlobalKey<FormState>? singleFormStateKey;
final FormFieldValidator<DropDownModel>? validator;
final BoxDecoration? buttonDecoration;
final BoxDecoration? dropdownDecoration;
final BoxDecoration? selectDecoration;
const DropDownCollectionWidget({
super.key,
this.selectDecoration,
this.buttonDecoration,
this.singleFormStateKey,
this.dropdownDecoration,
required this.onSaved,
required this.validator,
required this.children,
});
@override
State<DropDownCollectionWidget> createState() => _DropDownCollectionWidgetState();
}
class _DropDownCollectionWidgetState extends State<DropDownCollectionWidget> {
late List<bool> _isDropdownOpen;
@override
void initState() {
super.initState();
_isDropdownOpen = List<bool>.filled(widget.children.length, false, growable: false);
}
void _toggleDropdown(int index) {
setState(() {
for (int i = 0; i < _isDropdownOpen.length; i++) {
_isDropdownOpen[i] = i == index ? !_isDropdownOpen[i] : false;
}
});
}
@override
Widget build(BuildContext context) {
return ListView.builder(
shrinkWrap: true,
itemCount: widget.children.length,
itemBuilder: (context, index) {
DropDownModel _dropModel = widget.children[index];
bool _isSelectOption = _dropModel.selectOption == true;
String _hint = _isSelectOption ? "옵션을 선택해 주세요 (선택)" : '옵션을 선택해 주세요.';
return Padding(
padding: const EdgeInsets.all(16.0),
child: BaseDropdown(
key: widget.key,
item: _dropModel,
hint: _hint,
btnOpen: (bool value) {
_toggleDropdown(index);
},
initialValue: _dropModel,
currentBtn: _isDropdownOpen[index],
validator: (DropDownModel? value) {
if(value?.selectOption == true) {
return widget.validator?.call(value);
} else {
if(value?.selectValue == null) return "값을 입력해 주세요.";
return widget.validator?.call(value);
}
} ,
onSaved: widget.onSaved,
buttonDecoration: widget.buttonDecoration ?? BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(8),
),
selectDecoration: widget.selectDecoration ?? BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(8),
),
dropdownDecoration: widget.dropdownDecoration ?? BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
),
),
);
},
);
}
}
사용
final GlobalKey<FormState> _key = GlobalKey<FormState>();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Focus Node Fix Example')),
body: Form(
key: _key,
child: Column(
children: [
DropDownCollectionWidget(
dropdownDecoration: BoxDecoration(
color: Colors.redAccent,
borderRadius: BorderRadius.circular(8)
),
selectDecoration: BoxDecoration(
color: Colors.blueAccent,
borderRadius: BorderRadius.circular(8)
),
children: [
DropDownModel(label: "third", content: ["없음","third option 1", "third option 2", "third option 3"]),
DropDownModel(label: "first", content: ["없음","first option 1", "first option 2", "first option 3", "first option 4"]),
DropDownModel(label: "sec", content: ["없음","sec option 1", "sec option 2"], selectOption: true),
DropDownModel(label: "fou", content: ["없음","fou option 1"]),
],
validator: (DropDownModel? value) {
if(value?.selectValue == "없음") return '없음 오류 발생';
return null;
},
onSaved: (value) {
print("value ---> ${value?.toJson()}");
},
),
TextFormField(
validator: (String? value) {
if(value == null || value.isEmpty) {
return 'null 오류 발생';
}
},
),
ElevatedButton(onPressed: () {
print(_key.currentState?.validate());
}, child: Text("validate")),
ElevatedButton(onPressed: () {
_key.currentState?.save();
}, child: Text("save")),
ElevatedButton(onPressed: () {
_key.currentState?.reset();
}, child: Text("reset")),
],
),
)
);
}
