2025-12-12 09:11:30 +08:00
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter/cupertino.dart';
|
2026-04-08 15:03:56 +08:00
|
|
|
|
2026-04-21 09:30:13 +08:00
|
|
|
/// 选择结果(单选)
|
2026-04-08 15:03:56 +08:00
|
|
|
class PickerResult<T> {
|
|
|
|
|
final T value;
|
|
|
|
|
final int index;
|
|
|
|
|
|
|
|
|
|
PickerResult(this.value, this.index);
|
|
|
|
|
}
|
2025-12-12 09:11:30 +08:00
|
|
|
|
2026-04-21 09:30:13 +08:00
|
|
|
/// 选择结果(多选)
|
|
|
|
|
class MultiPickerResult<T> {
|
|
|
|
|
final List<T> values;
|
|
|
|
|
final List<int> indices;
|
|
|
|
|
|
|
|
|
|
MultiPickerResult(this.values, this.indices);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 显示样式
|
|
|
|
|
enum BottomPickerStyle {
|
|
|
|
|
wheel,
|
|
|
|
|
list,
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-12 09:11:30 +08:00
|
|
|
/// 通用底部弹窗选择器
|
|
|
|
|
class BottomPicker {
|
2026-04-08 15:03:56 +08:00
|
|
|
static Future<dynamic> show<T>(
|
|
|
|
|
BuildContext context, {
|
|
|
|
|
required List<T> items,
|
|
|
|
|
required Widget Function(T item) itemBuilder,
|
|
|
|
|
int initialIndex = 0,
|
|
|
|
|
double itemExtent = 50.0,
|
|
|
|
|
double height = 250,
|
|
|
|
|
bool withIndex = false,
|
2026-04-21 09:30:13 +08:00
|
|
|
BottomPickerStyle style = BottomPickerStyle.wheel,
|
|
|
|
|
bool multiSelect = false,
|
|
|
|
|
List<int>? initialSelectedIndices,
|
|
|
|
|
|
|
|
|
|
/// 新增:列表样式标题
|
|
|
|
|
String title = '请选择',
|
|
|
|
|
|
|
|
|
|
String cancelText = '取消',
|
|
|
|
|
String confirmText = '确定',
|
|
|
|
|
int maxLines = 3,
|
2026-04-08 15:03:56 +08:00
|
|
|
}) {
|
2025-12-12 09:11:30 +08:00
|
|
|
if (items.isEmpty) return Future.value(null);
|
|
|
|
|
|
|
|
|
|
final safeIndex = initialIndex.clamp(0, items.length - 1);
|
2026-04-21 09:30:13 +08:00
|
|
|
final initialMulti = (initialSelectedIndices ?? const <int>[])
|
|
|
|
|
.where((e) => e >= 0 && e < items.length)
|
|
|
|
|
.toSet();
|
|
|
|
|
|
|
|
|
|
if (style == BottomPickerStyle.wheel) {
|
|
|
|
|
T selected = items[safeIndex];
|
|
|
|
|
int selectedIndex = safeIndex;
|
2026-04-08 15:03:56 +08:00
|
|
|
|
2026-04-21 09:30:13 +08:00
|
|
|
return showModalBottomSheet<dynamic>(
|
|
|
|
|
context: context,
|
|
|
|
|
backgroundColor: Colors.white,
|
|
|
|
|
shape: const RoundedRectangleBorder(
|
|
|
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
|
|
|
|
),
|
|
|
|
|
builder: (ctx) {
|
|
|
|
|
return SizedBox(
|
|
|
|
|
height: height,
|
|
|
|
|
child: Column(
|
|
|
|
|
children: [
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
horizontal: 16,
|
|
|
|
|
vertical: 8,
|
|
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
|
children: [
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () {
|
|
|
|
|
FocusScope.of(context).unfocus();
|
|
|
|
|
Navigator.of(ctx).pop();
|
|
|
|
|
},
|
|
|
|
|
child: Text(
|
|
|
|
|
cancelText,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
color: Colors.black54,
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () {
|
|
|
|
|
FocusScope.of(context).unfocus();
|
|
|
|
|
if (withIndex) {
|
|
|
|
|
Navigator.of(ctx).pop(
|
|
|
|
|
PickerResult<T>(selected, selectedIndex),
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
Navigator.of(ctx).pop(selected);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
child: Text(
|
|
|
|
|
confirmText,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
color: Colors.blue,
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const Divider(height: 1),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: CupertinoPicker(
|
|
|
|
|
scrollController: FixedExtentScrollController(
|
|
|
|
|
initialItem: safeIndex,
|
|
|
|
|
),
|
|
|
|
|
itemExtent: itemExtent,
|
|
|
|
|
onSelectedItemChanged: (index) {
|
|
|
|
|
selected = items[index];
|
|
|
|
|
selectedIndex = index;
|
|
|
|
|
},
|
|
|
|
|
children: items.map((item) {
|
|
|
|
|
return Center(
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
child: DefaultTextStyle.merge(
|
|
|
|
|
style: const TextStyle(fontSize: 16),
|
|
|
|
|
child: itemBuilder(item),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}).toList(),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-12-12 09:11:30 +08:00
|
|
|
|
2026-04-21 09:30:13 +08:00
|
|
|
// 列表样式
|
2026-04-08 15:03:56 +08:00
|
|
|
return showModalBottomSheet<dynamic>(
|
2025-12-12 09:11:30 +08:00
|
|
|
context: context,
|
2026-04-21 09:30:13 +08:00
|
|
|
isScrollControlled: true,
|
2025-12-12 09:11:30 +08:00
|
|
|
backgroundColor: Colors.white,
|
|
|
|
|
shape: const RoundedRectangleBorder(
|
|
|
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
|
|
|
|
),
|
|
|
|
|
builder: (ctx) {
|
2026-04-21 09:30:13 +08:00
|
|
|
int selectedIndex = safeIndex;
|
|
|
|
|
final Set<int> selectedIndices = Set<int>.from(initialMulti);
|
|
|
|
|
|
|
|
|
|
if (!multiSelect && selectedIndices.isEmpty) {
|
|
|
|
|
selectedIndices.add(safeIndex);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
T currentSingleValue = items[selectedIndex];
|
|
|
|
|
|
|
|
|
|
return SafeArea(
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
height: height,
|
|
|
|
|
child: StatefulBuilder(
|
|
|
|
|
builder: (context, setState) {
|
|
|
|
|
return Column(
|
2025-12-12 09:11:30 +08:00
|
|
|
children: [
|
2026-04-21 09:30:13 +08:00
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
horizontal: 16,
|
|
|
|
|
vertical: 10,
|
2026-04-08 15:03:56 +08:00
|
|
|
),
|
2026-04-21 09:30:13 +08:00
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Text(
|
|
|
|
|
title,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () {
|
|
|
|
|
FocusScope.of(context).unfocus();
|
|
|
|
|
Navigator.of(ctx).pop();
|
|
|
|
|
},
|
|
|
|
|
child: Text(
|
|
|
|
|
cancelText,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
color: Colors.black54,
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
TextButton(
|
|
|
|
|
onPressed: () {
|
|
|
|
|
FocusScope.of(context).unfocus();
|
|
|
|
|
|
|
|
|
|
if (multiSelect) {
|
|
|
|
|
final indices = selectedIndices.toList()..sort();
|
|
|
|
|
final values = indices.map((e) => items[e]).toList();
|
2026-04-08 15:03:56 +08:00
|
|
|
|
2026-04-21 09:30:13 +08:00
|
|
|
if (withIndex) {
|
|
|
|
|
Navigator.of(ctx).pop(
|
|
|
|
|
MultiPickerResult<T>(values, indices),
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
Navigator.of(ctx).pop(values);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
if (withIndex) {
|
|
|
|
|
Navigator.of(ctx).pop(
|
|
|
|
|
PickerResult<T>(currentSingleValue, selectedIndex),
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
Navigator.of(ctx).pop(currentSingleValue);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
child: Text(
|
|
|
|
|
confirmText,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
color: Colors.blue,
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const Divider(height: 1),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: ListView.separated(
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
|
|
|
itemCount: items.length,
|
|
|
|
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
|
|
|
|
itemBuilder: (context, index) {
|
|
|
|
|
final item = items[index];
|
|
|
|
|
final isSelected = multiSelect
|
|
|
|
|
? selectedIndices.contains(index)
|
|
|
|
|
: selectedIndex == index;
|
|
|
|
|
return InkWell(
|
|
|
|
|
onTap: () {
|
|
|
|
|
setState(() {
|
|
|
|
|
if (multiSelect) {
|
|
|
|
|
if (selectedIndices.contains(index)) {
|
|
|
|
|
selectedIndices.remove(index);
|
|
|
|
|
} else {
|
|
|
|
|
selectedIndices.add(index);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
selectedIndex = index;
|
|
|
|
|
currentSingleValue = item;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
horizontal: 16,
|
|
|
|
|
vertical: 12,
|
|
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
if (multiSelect)
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.only(top: 2),
|
|
|
|
|
child: Icon(
|
|
|
|
|
isSelected
|
|
|
|
|
? Icons.check_box
|
|
|
|
|
: Icons.check_box_outline_blank,
|
|
|
|
|
color: isSelected ? Colors.blue : Colors.grey,
|
|
|
|
|
size: 22,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
else
|
|
|
|
|
Padding(
|
|
|
|
|
padding: const EdgeInsets.only(top: 2),
|
|
|
|
|
child: Icon(
|
|
|
|
|
isSelected
|
|
|
|
|
? Icons.radio_button_checked
|
|
|
|
|
: Icons.radio_button_off,
|
|
|
|
|
color: isSelected ? Colors.blue : Colors.grey,
|
|
|
|
|
size: 22,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: DefaultTextStyle.merge(
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
color: Colors.black87,
|
|
|
|
|
height: 1.35,
|
|
|
|
|
),
|
|
|
|
|
child: itemBuilder(item),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-04-08 15:03:56 +08:00
|
|
|
);
|
2026-04-21 09:30:13 +08:00
|
|
|
},
|
2026-04-08 15:03:56 +08:00
|
|
|
),
|
2025-12-12 09:11:30 +08:00
|
|
|
),
|
|
|
|
|
],
|
2026-04-21 09:30:13 +08:00
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
2025-12-12 09:11:30 +08:00
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-04-08 15:03:56 +08:00
|
|
|
}
|