所有代码库github地址:
github.com/crazylii/fl…
1. 上拉刷新下拉加载
效果图:
///上拉刷新下拉加载
Widget refresh() {
return StatefulBuilder(
builder: (BuildContext context, void Function(void Function()) setState) {
RefreshController refreshController = RefreshController();
return SmartRefresher(
enablePullDown: true,
enablePullUp: true,
header: const WaterDropHeader(),
footer: const ClassicFooter(),
controller: refreshController,
onRefresh: () => Future.delayed(const Duration(seconds: 2),
() => refreshController.refreshToIdle()),
onLoading: () => Future.delayed(const Duration(seconds: 2),
() => refreshController.loadComplete()),
child: const SingleChildScrollView(),
);
},
);
}
dependencies:
pull_to_refresh: ^2.0.0
2. 网格布局GridView
效果图:
///网格布局
Widget gridView() {
return Padding(
padding: const EdgeInsets.all(16),
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisSpacing: 12.0,//竖轴item间隔
mainAxisSpacing: 11.0,//横轴item间隔
childAspectRatio: 0.9,//item宽高比例
crossAxisCount: 3 //列数
),
itemBuilder: (BuildContext context, int index) {
return Container(
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(10)),
color: Colors.green
),
);
},
itemCount: 20,
),
);
3. 效仿BottomNavigationBar自定义底部菜单栏,支持未读消息角标显示
效果图:
这里菜单项点击时还可以添加点击放大动画效果,增加体验性,有兴趣的同学可自行扩展添加。
3.1 使用DefaultTabController结合IndexedStack,实现菜单页切换效果
class BottomNavigationBarView extends StatefulWidget {
const BottomNavigationBarView({Key? key}) : super(key: key);
@override
State<StatefulWidget> createState() => _BottomNavigationBarViewState();
}
class _BottomNavigationBarViewState<BottomNavigationBarView> extends State {
//初始化当前页
int _selectedIndex = 0;
//菜单页
final List<Widget> _widgetOptions = [
const Center(
child: Text('page1'),
),
const Center(
child: Text('page2'),
),
const Center(
child: Text('page3'),
),
];
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: _widgetOptions.length,
child: Scaffold(
body: IndexedStack(
index: _selectedIndex,
children: _widgetOptions,
),
bottomNavigationBar: Builder(builder: (context) {
return CusBottomNavigationBar(
items: [
CusBottomNavigationBarItem(
iconData: Icons.desktop_windows,
title: '设备',
),
CusBottomNavigationBarItem(
iconData: Icons.notifications_outlined,
title: '消息',
unreadMsgCount: 666,
),
CusBottomNavigationBarItem(
iconData: Icons.person_outline, title: '我的'),
],
onTap: _onItemTapped,
currentIndex: _selectedIndex,
);
})),
);
}
///点击底部菜单切换页面
void _onItemTapped(int index) {
setState(() {
_selectedIndex = index;
});
}
}
3.2 自定义导航栏菜单
3.2.1 属性介绍
items: 子菜单列表,可根据实际需求自行增减
onTap: 点击菜单回调
selectedColor: 菜单选中颜色
unselectedColor: 菜单没选择颜色
currentIndex: 用于初始化当前菜单页下标
3.2.2 未读消息数角标显示
- 个位数使用圆形角标展示,使用ClipOval裁剪圆形图标
- 两位数及两位数以上的数字显示,使用带圆角矩形展示
///根据角标显示的数字大小选取合适大小的角标
Widget _subscript(int count) {
Widget subscript;
if (count > 0 && count < 10) {//个位数显示
subscript = ClipOval(
child: Container(
width: 16,
height: 16,
color: const Color(0xffff3b3b),
),
);
} else if (count >= 10 && count < 100) {//两位数显示
subscript = Container(
width: 22,
height: 16,
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
color: Color(0xffff3b3b)),
);
} else {//三位数及以上显示
subscript = Container(
width: 29,
height: 16,
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
color: Color(0xffff3b3b)),
);
}
return subscript;
}
3.2.3 导航栏菜单控件完整代码
///自定义底部导航菜单Bar
class CusBottomNavigationBar extends StatefulWidget {
const CusBottomNavigationBar(
{Key? key,
required this.items,
this.onTap,
this.selectedColor,
this.unselectedColor,
this.currentIndex = 0})
: super(key: key);
//菜单列表
final List<CusBottomNavigationBarItem> items;
//点击item回调
final ValueChanged<int>? onTap;
//点击选中颜色
final Color? selectedColor;
//没选中颜色
final Color? unselectedColor;
//初始化当前所属菜单下标
final int? currentIndex;
@override
State<StatefulWidget> createState() => _CusBottomNavigationBarState();
}
class _CusBottomNavigationBarState extends State<CusBottomNavigationBar> {
int? _currentIndex;
@override
void initState() {
super.initState();
_currentIndex = widget.currentIndex;
}
@override
Widget build(BuildContext context) {
return Container(
height: 60,
color: Colors.white,
child: Row(
children: _items(),
),
);
}
///添加子item
List<Widget> _items() {
List<Widget> items = [];
for (int i = 0; i < widget.items.length; i++) {
var item = widget.items.elementAt(i);
items.add(_CusBottomNavigationBarTile(
iconData: item.iconData,
title: item.title,
unreadMsgCount: item.unreadMsgCount,
select: _currentIndex == i ? true : false,
onTap: () {
setState(() {
_currentIndex = i;
});
widget.onTap?.call(i);
},
));
}
return items;
}
}
class CusBottomNavigationBarItem {
CusBottomNavigationBarItem(
{this.unreadMsgCount, required this.iconData, required this.title});
final int? unreadMsgCount;
final IconData iconData;
final String title;
}
///导航菜单子item布局
class _CusBottomNavigationBarTile extends StatefulWidget {
const _CusBottomNavigationBarTile(
{Key? key,
this.unreadMsgCount,
required this.iconData,
required this.title,
this.unselectedColor = const Color(0xff969799),
this.selectedColor = const Color(0xff5974f4),
this.onTap,
required this.select})
: super(key: key);
//显示的未读消息数量
final int? unreadMsgCount;
//菜单icon
final IconData iconData;
//菜单标题
final String title;
final GestureTapCallback? onTap;
final Color? selectedColor;
final Color? unselectedColor;
final bool select;
@override
State<StatefulWidget> createState() => _CusBottomNavigationBarTileState();
}
class _CusBottomNavigationBarTileState
extends State<_CusBottomNavigationBarTile> {
@override
Widget build(BuildContext context) {
return Expanded(
child: InkWell(
onTap: () {
widget.onTap?.call();
},
child: Column(
// mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Stack(
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 60,
height: 30,
child: Icon(
widget.iconData,
size: 30,
color: widget.select
? widget.selectedColor
: widget.unselectedColor,
),
),
Text(widget.title,
style: TextStyle(
fontSize: 14,
color: widget.select
? widget.selectedColor
: widget.unselectedColor))
],
),
if (widget.unreadMsgCount != null && widget.unreadMsgCount != 0)
Positioned(
top: 2,
left: 30,
child: Stack(
alignment: Alignment.center,
children: [
_subscript(widget.unreadMsgCount!),
Text(
'${widget.unreadMsgCount}',
style: const TextStyle(
fontSize: 10, color: Colors.white),
)
],
),
),
],
),
)
],
),
));
}
///根据角标显示的数字大小选取合适大小的角标
Widget _subscript(int count) {
Widget subscript;
if (count > 0 && count < 10) {//个位数显示
subscript = ClipOval(
child: Container(
width: 16,
height: 16,
color: const Color(0xffff3b3b),
),
);
} else if (count >= 10 && count < 100) {//两位数显示
subscript = Container(
width: 22,
height: 16,
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
color: Color(0xffff3b3b)),
);
} else {//三位数及以上显示
subscript = Container(
width: 29,
height: 16,
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
color: Color(0xffff3b3b)),
);
}
return subscript;
}
}
4. 自定义高度的AppBar
属性详解:
- title: 标题
- leading:布尔值类型,是否展示回退按钮
- onPressed:点击回退按钮回调
- backgroundColor: 背景颜色
- actions: 右侧导航菜单集
- shadowColor: 边缘阴影颜色
- elevation: double类型,阴影立体效果参数,数值越大,阴影立体感越强
- bottom: 底部菜单栏
- height:appBar总高度
///统一自定义高度AppBar
PreferredSizeWidget universalAppBar(
//标题
String title,
//是否展示回退图标
bool leading,
//回退按钮点击回调
{VoidCallback? onPressed,
//背景颜色
Color? backgroundColor = const Color(0xe6ffffff),
//右侧导航菜单
List<Widget>? actions,
//阴影颜色
Color? shadowColor = const Color(0x0d000000),
//立体效果参数
double? elevation = 0,
//底部菜单栏
PreferredSizeWidget? bottom,
//appbar总高度
Size? height}) {
return CusHeightAppBar(
appBar: AppBar(
backgroundColor: backgroundColor,
centerTitle: true,
title: Text(
title,
style: const TextStyle(color: Color(0xff000000), fontSize: 17.0),
),
elevation: elevation,
shadowColor: shadowColor,
leading: leading
? Builder(
builder: (BuildContext context) {
return IconButton(
icon: const Icon(
Icons.navigate_before,
color: Color(0xe6000000),
),
onPressed: onPressed,
tooltip:
MaterialLocalizations.of(context).openAppDrawerTooltip,
);
},
)
: null,
actions: actions,
bottom: bottom,
),
height: height,
);
}
///可设置高度和自定义下底部阴影的AppBar,
///去除阴影需[AppBar]的elevation值为0,[shadow]为false
class CusHeightAppBar extends StatelessWidget implements PreferredSizeWidget {
final AppBar appBar;
//是否展示阴影效果
final bool shadow;
const CusHeightAppBar(
{Key? key, required this.appBar, this.shadow = true, this.height})
: super(key: key);
final Size? height;
@override
Widget build(BuildContext context) {
return shadow
? Container(
decoration: const BoxDecoration(boxShadow: [
BoxShadow(
color: Color(0x0d000000),
blurRadius: 15.0,
offset: Offset(0, 2))
]),
child: appBar,
)
: appBar;
}
@override
Size get preferredSize => height ?? const Size.fromHeight(50);
}
5. TabBarView使用
效果图:
这里做了一个禁止左右滑动切换页面的操作,只能通过点击菜单切换页面。
原因是TabBarView没有对外提供页面切换实时回调处理函数,页面左右滑动切换时无法及时做页面数据处理;
如果需要支持左右滑动切换页面,可以考虑使用PageView实现同样效果;PageView提供页面切换实时监听函数,可同时作数据处理;
class CusTabBarView extends StatelessWidget {
CusTabBarView({Key? key}) : super(key: key);
final List<String> tabTitles = ["page1", "page2"];
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: tabTitles.length,
child: Scaffold(
appBar: universalAppBar("TabView", false,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(45),
child: Material(
color: Colors.white,
child: TabBar(
tabs: tabTitles
.map((e) => Padding(
padding: const EdgeInsets.only(top: 13, bottom: 12),
child: Text(e)))
.toList(),
labelColor: const Color(0xff5974f4),
labelStyle: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w500),
unselectedLabelColor: const Color(0xff969799),
unselectedLabelStyle: const TextStyle(fontSize: 14),
indicatorSize: TabBarIndicatorSize.label,
indicatorColor: const Color(0xff5974f4),
onTap: (index) {
//点击切换页面时,在此初始化数据逻辑
},
),
),
),
height: const Size.fromHeight(95)),
body: const TabBarView(
//禁止左右滑动页面
physics: NeverScrollableScrollPhysics(),
children: [
Center(
child: Text('page1'),
),
Center(
child: Text('page2'),
)
],
),
),
);
}
}
6. 多样式富文本展示
效果图:
RichText(
text: TextSpan(
text: '${msg.position}有人在呼救',
style: const TextStyle(
fontSize: 14,
color: Color(0xff999999)),
children: <TextSpan>[
TextSpan(
text: '【${msg.alarmContent}】',
style: const TextStyle(
fontSize: 14,
color: Color(0xff333333),
fontWeight:
FontWeight.bold)),
const TextSpan(
text: ',请及时处理',
style: TextStyle(
fontSize: 14,
color: Color(0xff999999))),
],
),
)
7. ElevatedButton按钮的使用
效果图:
ElevatedButton(
child: const Text(
"退出登录",
style: TextStyle(fontSize: 16, color: Colors.white),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.only(top: 10, bottom: 10),
backgroundColor: const Color(0xff5974f4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
),
onPressed: () {},
);
8. 仿IOS苹果弹窗dialog,各种弹窗
8.1 简单确认窗
效果图:
///退出当前账户
class ExitAppDialog extends Dialog {
const ExitAppDialog({Key? key, required this.content, this.onPressed})
: super(key: key);
final String content;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: 315,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), color: Colors.white),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
height: 60,
),
Text(
content,
style: const TextStyle(
fontSize: 17,
color: Color(0xff323233),
),
),
const SizedBox(
height: 52,
),
SizedBox(
height: 1,
child: Container(
color: const Color(0xffe1e1e1),
),
),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text(
"取消",
style: TextStyle(fontSize: 16, color: Color(0xff69696c)),
),
style: ButtonStyle(
padding: MaterialStateProperty.all(
const EdgeInsets.only(top: 10, bottom: 10))),
)),
Expanded(
child: Container(
decoration: const BoxDecoration(
border: Border(
left:
BorderSide(width: 1, color: Color(0xffe1e1e1)))),
child: TextButton(
onPressed: onPressed,
child: const Text(
"确定",
style: TextStyle(fontSize: 16, color: Color(0xff5974f4)),
),
style: ButtonStyle(
padding: MaterialStateProperty.all(
const EdgeInsets.only(top: 10, bottom: 10))),
),
))
],
)
],
),
),
);
}
}
调用方式:
showDialog(
context: context,
builder: (BuildContext context) => ExitAppDialog(
content: "确认退出当前账号?",
onPressed: () {},
)),
8.2 带有输入框的弹窗
效果图:
//输入框数据回传
typedef OnPositive<T> = void Function(T value);
///单输入框dialog
class SingleEditDialog extends Dialog {
const SingleEditDialog(this.deviceName, {Key? key, this.onPositive})
: super(key: key);
final String deviceName;
final OnPositive? onPositive;
@override
Widget build(BuildContext context) {
String? currentName;
return Center(
child: Container(
width: 315,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), color: Colors.white),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
height: 20,
),
const Text(
"修改设备名称",
style: TextStyle(fontSize: 17, color: Color(0xff323233)),
),
const SizedBox(
height: 20,
),
Padding(
padding: const EdgeInsets.only(left: 30, right: 30),
child: TextField(
style: const TextStyle(fontSize: 16, color: Color(0xff323233)),
decoration: InputDecoration(
contentPadding: const EdgeInsets.only(
top: 12, bottom: 12, left: 16, right: 16),
isCollapsed: true,
hintText: deviceName,
border: const OutlineInputBorder(
borderSide: BorderSide(
color: Color(0xffe1e1e1),
))),
onChanged: (v) {
currentName = v;
},
),
),
const SizedBox(
height: 28,
),
SizedBox(
height: 1,
child: Container(
color: const Color(0xffe1e1e1),
),
),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text(
"取消",
style: TextStyle(fontSize: 16, color: Color(0xff69696c)),
),
style: ButtonStyle(
padding: MaterialStateProperty.all(
const EdgeInsets.only(top: 10, bottom: 10))),
)),
Expanded(
child: Container(
decoration: const BoxDecoration(
border: Border(
left:
BorderSide(width: 1, color: Color(0xffe1e1e1)))),
child: TextButton(
onPressed: () {
Navigator.pop(context);
if (currentName != null) {
onPositive?.call(currentName);
}
},
child: const Text(
"确定",
style: TextStyle(fontSize: 16, color: Color(0xff5974f4)),
),
style: ButtonStyle(
padding: MaterialStateProperty.all(
const EdgeInsets.only(top: 10, bottom: 10))),
),
))
],
)
],
),
),
);
}
}
8.3 自定义转轮式选择弹窗,底部弹窗,CupertinoPicker
效果图:
8.3.1 使用showCupertinoModalPopup从底部显示弹窗
///选择年龄底部弹窗
void showYearPicker(
{required BuildContext context, OnSelectValue? onSelectValue}) =>
showCupertinoModalPopup(
context: context,
builder: (_) => DatePicker(
start: 1920,
onSelectValue: onSelectValue,
));
8.3.2 使用CupertinoPicker选择器展示日期选择
- CupertinoPicker属性解析:
diameterRatio: 转轮曲率,值越小,越凸显;
magnification:当前选择栏缩放系数;大于1放大,小于1缩小;
squeeze: 菜单栏之间间距,值越大,菜单越紧凑;
useMagnifier: 布尔值,是否使用放大显示;实测下来,该值并没起作用。
itemExtent: 当前选择栏高度;
onSelectedItemChanged: 选择的数据回传
selectionOverlay: 当前选择栏widget自定义; - 完整代码:
///年份选择
class DatePicker extends StatefulWidget {
DatePicker({Key? key, required this.start, this.onSelectValue})
: super(key: key);
//开始年份
final int start;
//结束年份
final int end = DateTime.now().year;
//选择的数据回传
final OnSelectValue? onSelectValue;
@override
State<StatefulWidget> createState() => _DataPickerState();
}
class _DataPickerState extends State<DatePicker> {
FixedExtentScrollController? _scrollController;
late int value;
late int length;
@override
void initState() {
super.initState();
length = widget.end - widget.start + 1;
var initialItem = length ~/ 2;
value = widget.start + initialItem;
_scrollController = FixedExtentScrollController(initialItem: initialItem);
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
height: 325,
child: Column(
children: [
SizedBox(
width: double.infinity,
height: 56,
child: Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () {
Navigator.pop(context);
},
child: const Text("取消",
style: TextStyle(
fontSize: 16, color: Color(0xff969799))),
),
),
const Align(
alignment: Alignment.center,
child: Text(
"出生年份",
style: TextStyle(fontSize: 16, color: Color(0xff323233)),
),
),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
//返回年龄
widget.onSelectValue?.call(widget.end - value);
Navigator.pop(context);
},
child: const Text("确定",
style: TextStyle(
fontSize: 16, color: Color(0xff5974f4))),
),
),
],
),
),
Expanded(
child: CupertinoPicker(
scrollController: _scrollController,
diameterRatio: 2,
magnification: 1.3,
squeeze: 0.9,
useMagnifier: true,
itemExtent: 49,
onSelectedItemChanged: (int value) {
this.value = widget.start + value;
},
children: List.generate(length, (index) {
return Center(
child: Text(
(widget.start + index).toString(),
style: const TextStyle(
fontSize: 18, color: Color(0xff323233)),
),
);
}),
selectionOverlay: Container(
decoration: const BoxDecoration(
border: Border(
top: BorderSide(width: 1, color: Color(0xffebebeb)),
bottom: BorderSide(width: 1, color: Color(0xffebebeb)),
)),
),
),
)
],
));
}
}
8.4 仿ios底部弹出菜单
效果图:
///仿ios底部pop弹窗
Widget bottomPop(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 100),
child: Row(
children: [
Expanded(
child: Container(
color: Colors.white,
padding:
const EdgeInsets.only(left: 20, right: 20, top: 6, bottom: 6),
child: ElevatedButton(
onPressed: () {
showCupertinoModalPopup(
barrierColor: const Color(0x66000000),
context: context,
builder: (context) {
return Material(
color: const Color(0x00FFFFFF),
child: Container(
padding: const EdgeInsets.only(right: 7, left: 7),
height: 175,
child: Column(
children: [
Expanded(
child: Container(
decoration: const BoxDecoration(
borderRadius:
BorderRadius.all(Radius.circular(8)),
color: Colors.white),
child: Column(
children: [
Expanded(
child: InkWell(
onTap: () {
//点击菜单处理
Navigator.pop(context);
},
child: const SizedBox(
child: Center(
child: Text(
'误报警',
style: TextStyle(
fontSize: 16,
color: Color(0xff5974f4)),
),
),
),
)),
Container(
height: 1,
color: const Color(0xffebebeb),
),
Expanded(
child: InkWell(
onTap: () {
//点击菜单处理
Navigator.pop(context);
},
child: const SizedBox(
child: Center(
child: Text(
'已处理',
style: TextStyle(
fontSize: 16,
color: Color(0xff5974f4)),
),
),
),
)),
],
),
),
),
const SizedBox(
height: 8,
),
InkWell(
onTap: () => Navigator.pop(context),
child: Container(
decoration: const BoxDecoration(
borderRadius:
BorderRadius.all(Radius.circular(8)),
color: Colors.white),
height: 44,
child: const Center(
child: Text(
'取消',
style: TextStyle(
fontSize: 16, color: Color(0xff323233)),
),
),
),
),
const SizedBox(
height: 34,
),
],
),
),
);
});
},
child: const Text(
'处理报警',
style: TextStyle(fontSize: 16, color: Colors.white),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.only(top: 10, bottom: 10),
backgroundColor: const Color(0xff5974f4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22)),
),
),
))
],
),
);
}
9. 仿DropdownButton pop弹出下拉菜单按钮,三级级联菜单
效果图:
9.1 三级菜单数据
- 模拟数据以本地json文件的形式存储,保存在config/location_data.json文件中,
{
"code": 200,
"success": true,
"message": "操作成功",
"result": [
{
"level": 1,
"location": "1",
"children": [
{
"level": 2,
"location": "1",
"children": [
{
"level": 3,
"location": "1",
"children": []
},
{
"level": 3,
"location": "2",
"children": []
}
]
},
{
"level": 2,
"location": "2",
"children": [
{
"level": 3,
"location": "1",
"children": []
}
]
}
]
},
{
"level": 1,
"location": "2",
"children": [
{
"level": 2,
"location": "1",
"children": [
{
"level": 3,
"location": "1",
"children": []
},
{
"level": 3,
"location": "2",
"children": []
}
]
},
{
"level": 2,
"location": "3",
"children": [
{
"level": 3,
"location": "3",
"children": []
}
]
},
{
"level": 2,
"location": "2",
"children": [
{
"level": 3,
"location": "1",
"children": []
}
]
}
]
},
{
"level": 1,
"location": "3",
"children": [
{
"level": 2,
"location": "2",
"children": [
{
"level": 3,
"location": "1",
"children": []
}
]
}
]
}
]
}
- 加载本地json数据
///位置级联菜单模拟数据实体类
class LocationData {
int? code;
bool? success;
String? message;
List<Result>? result;
LocationData({this.code, this.success, this.message, this.result});
LocationData.fromJson(Map<String, dynamic> json) {
code = json['code'];
success = json['success'];
message = json['message'];
if (json['result'] != null) {
result = <Result>[];
json['result'].forEach((v) {
result!.add(Result.fromJson(v));
});
}
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['code'] = code;
data['success'] = success;
data['message'] = message;
if (result != null) {
data['result'] = result!.map((v) => v.toJson()).toList();
}
return data;
}
}
class Result {
int? level;
String? location;
List<Result>? children;
Result({this.level, this.location, this.children});
Result.fromJson(Map<String, dynamic> json) {
level = json['level'];
location = json['location'];
if (json['children'] != null) {
children = <Result>[];
json['children'].forEach((v) {
children!.add(Result.fromJson(v));
});
}
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['level'] = level;
data['location'] = location;
if (children != null) {
data['children'] = children!.map((v) => v.toJson()).toList();
}
return data;
}
}
///加载json
rootBundle.loadString('config/location_data.json').then((value) {
setState(() {
_jsonData = LocationData.fromJson(jsonDecode(value));
});
});
9.2 相关动画详解
主要分为:按钮标题颜色及小箭头颜色变化动画、按钮小箭头翻转动画、下拉菜单展出动画、背景层颜色透明度变换动画。
这里主要使用AnimatedBuilder来构建动画widget
- 标题颜色变化及小箭头翻转动画
- 颜色变化使用ColorTween补间动画实现,
_colorAnimation = ColorTween(begin: widget.normalColor, end: widget.selectColor) .animate(curvedAnimation); //标题颜色 AnimatedBuilder( animation: _animationController, builder: (BuildContext context, Widget? child) { return Text( widget.title, style: TextStyle( color: _colorAnimation.value, fontSize: widget.titleFontSize), ); }, ),- 小箭头翻转动画
这里使用Matrix4矩阵结合Transform组件,实现小箭头在三维空间上沿x轴翻转变化;
Matrix4矩阵很强大,能够实现x轴、y轴及z轴上的动画变换,可以实现立体空间上的翻转、缩放、深度视觉变化等。
//下拉按钮翻转动画 late final Animation _rotationAnimation; //Transform实现翻转及颜色变化组合动画 AnimatedBuilder( animation: _animationController, builder: (BuildContext context, Widget? child) { return Transform( alignment: FractionalOffset.center, transform: Matrix4.identity() ..rotateX(pi * _rotationAnimation.value), child: Icon( widget.expandIcon, size: widget.expandIconSize, color: _colorAnimation.value, ), ); }), - 下拉菜单展出动画
使用Tween补间动画实现下拉菜单高度从0到目标高度的变化;ClipRect实现动画平滑展出_animation = Tween(begin: 0.0, end: widget.expandHeight).animate(curvedAnimation);AnimatedBuilder( animation: _animationController, builder: (BuildContext context, Widget? child) { return ClipRect( clipper: _ClipperPath(_animation.value), clipBehavior: Clip.antiAlias, child: child, ); }, child: Container( decoration: const BoxDecoration( color: Colors.white, border: Border( top: BorderSide(color: Color(0xffebebeb), width: 1))), child: Container(), ) //下拉伸展菜单展开时,裁剪尺寸计算 class _ClipperPath extends CustomClipper<Rect> { _ClipperPath(this.value); double value; @override Rect getClip(Size size) { return Rect.fromLTRB(0, 0, size.width, value); } @override bool shouldReclip(CustomClipper<Rect> oldClipper) { return true; } } - 背景遮盖层透明度变化动画
Opacity实现透明度变化//颜色补间数据 _backgroundColorAnimation = Tween(begin: 0.0, end: 0.5).animate(curvedAnimation); AnimatedBuilder( animation: _animationController, builder: (BuildContext context, Widget? child) { return Opacity( opacity: _backgroundColorAnimation.value, child: child, ); }, child: Container(), ),
9.3 下拉菜单页展示
下拉菜单页作为一个遮盖层覆盖到当前窗口之上,这里需要计算遮盖层弹出相对位置;这里以标题button按钮作为参照物,遮盖层位于button按钮下方;
- 计算标题button位置
//获取button的渲染对象
final RenderBox button = context.findRenderObject() as RenderBox;
//获取button所在的根overlay
final RenderBox overlay =
Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox;
//计算button的相对位置,即button在当前窗口所在位置
final RelativeRect position = RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(widget.offset, ancestor: overlay),
button.localToGlobal(
button.size.bottomRight(Offset.zero) + widget.offset,
ancestor: overlay),
),
Offset.zero & overlay.size,
);
- 根据标题button按钮位置,计算遮盖层菜单页弹出位置
///弹出菜单位置计算
class _PopupMenuRouteLayoutDelegate extends SingleChildLayoutDelegate {
_PopupMenuRouteLayoutDelegate({
required this.position,
required this.padding,
required this.textDirection,
});
//参照物位置
final RelativeRect position;
final EdgeInsets padding;
// 菜单内容展示方向
final TextDirection textDirection;
@override
bool shouldRelayout(_PopupMenuRouteLayoutDelegate oldDelegate) {
return position != oldDelegate.position || padding != oldDelegate.padding;
}
///尺寸大小限制范围
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return BoxConstraints.loose(constraints.biggest).deflate(
padding,
);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
// size: 遮盖层尺寸
// childSize: 菜单完全打开时的大小,由 getConstraintsForChild 确定。
final double buttonHeight = size.height - position.top - position.bottom;
// 菜单层y轴位置
double y = position.top + buttonHeight;
// 菜单层X轴位置
double x;
if (position.left > position.right) {
// 菜单按钮更靠近右边缘,因此向左增长,与右边缘对齐。
x = size.width - position.right - childSize.width;
} else if (position.left < position.right) {
// 菜单按钮更靠近左边缘,因此向右增长,与左边缘对齐。
x = position.left;
} else {
// 菜单按钮与两个边缘等距,因此在文字阅读方向上延申。
switch (textDirection) {
case TextDirection.rtl:
x = size.width - position.right - childSize.width;
break;
case TextDirection.ltr:
x = position.left;
break;
}
}
// 避免在每个方向上超出屏幕边缘像素的矩形区域。
// if (x < _kMenuScreenPadding + padding.left) {
// x = _kMenuScreenPadding + padding.left;
// } else if (x + childSize.width >
// size.width - _kMenuScreenPadding - padding.right) {
// x = size.width - childSize.width - _kMenuScreenPadding - padding.right;
// }
// if (y < _kMenuScreenPadding + padding.top) {
// y = _kMenuScreenPadding + padding.top;
// } else if (y + childSize.height >
// size.height - _kMenuScreenPadding - padding.bottom) {
// y = size.height - padding.bottom - _kMenuScreenPadding - childSize.height;
// }
return Offset(x, y);
}
}
- 创建弹出菜单页遮盖层,插入到当前窗口上面
//创建遮盖层
overlayEntry = OverlayEntry(builder: (context) {
return CustomSingleChildLayout(
delegate: _PopupMenuRouteLayoutDelegate(
textDirection: TextDirection.ltr,
position: position,
padding: EdgeInsets.zero),
child: Container();
);
});
//插入遮盖层
Overlay.of(context)!.insert(overlayEntry!);
- 完整代码
///数据回调
typedef PopupWindowMenuItemSelected<T> = void Function(T value);
///下拉弹出菜单按钮
class PopupWindowButton extends StatefulWidget {
const PopupWindowButton(
{Key? key,
this.offset = Offset.zero,
required this.title,
required this.menuData,
this.titleFontSize = 16.0,
this.expandIcon = Icons.expand_more,
this.expandIconSize = 16.0,
this.normalColor = Colors.black,
this.selectColor = const Color(0xff5974f4),
this.duration = const Duration(milliseconds: 200),
this.expandHeight = 352.0,
this.padding = const EdgeInsets.only(left: 15, top: 15, bottom: 15),
this.onSelected,
this.onFinished})
: super(key: key);
final Offset offset;
//标题
final String title;
//标题字体尺寸
final double titleFontSize;
//下拉图标
final IconData expandIcon;
//下拉图标大小
final double expandIconSize;
//正常颜色
final Color normalColor;
//展开选中颜色
final Color selectColor;
//动画时间
final Duration duration;
//下拉菜单展开高度
final double expandHeight;
//按钮内边距
final EdgeInsetsGeometry padding;
//菜单所有数据
final LocationDataModel menuData;
//选择数据后回调
final PopupWindowMenuItemSelected<String>? onSelected;
//清空数据时回调
final VoidCallback? onFinished;
@override
State<StatefulWidget> createState() => _PopupWindowButtonState();
}
class _PopupWindowButtonState extends State<PopupWindowButton>
with SingleTickerProviderStateMixin {
//遮盖层展示动画控制器
late final AnimationController _animationController;
//遮盖层展示动画
late final Animation _animation;
//下拉按钮翻转动画
late final Animation _rotationAnimation;
//背景层颜色透明度变换动画
late final Animation _backgroundColorAnimation;
//主体部分按钮、字体颜色变换动画
late final Animation _colorAnimation;
AnimationStatus _currentAnimationStatus = AnimationStatus.dismissed;
//遮盖层,即弹出层
OverlayEntry? overlayEntry;
bool show = false;
///展示伸展菜单
void showPopupMenuLayout() {
_animationController.forward();
final RenderBox button = context.findRenderObject() as RenderBox;
final RenderBox overlay =
Navigator.of(context).overlay!.context.findRenderObject()! as RenderBox;
final RelativeRect position = RelativeRect.fromRect(
Rect.fromPoints(
button.localToGlobal(widget.offset, ancestor: overlay),
button.localToGlobal(
button.size.bottomRight(Offset.zero) + widget.offset,
ancestor: overlay),
),
Offset.zero & overlay.size,
);
overlayEntry = OverlayEntry(builder: (context) {
return CustomSingleChildLayout(
delegate: _PopupMenuRouteLayoutDelegate(
textDirection: TextDirection.ltr,
position: position,
padding: EdgeInsets.zero),
child: Stack(
children: [
//背景阴影层
AnimatedBuilder(
animation: _animationController,
builder: (BuildContext context, Widget? child) {
return Opacity(
opacity: _backgroundColorAnimation.value,
child: child,
);
},
child: Material(
child: InkWell(
onTap: () {
//点击外部背景层时,反向动画
if (_currentAnimationStatus == AnimationStatus.completed) {
_animationController.reverse();
}
},
child: Container(
color: Colors.black,
),
),
),
),
//菜单部件
AnimatedBuilder(
animation: _animationController,
builder: (BuildContext context, Widget? child) {
return ClipRect(
clipper: _ClipperPath(_animation.value),
clipBehavior: Clip.antiAlias,
child: child,
);
},
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: Color(0xffebebeb), width: 1))),
child: _Cascade(
currentTitle: widget.title,
deviceSearchConfig: widget.menuData,
onSelected: (floor) {
//结束动画
if (_animationController.status ==
AnimationStatus.completed) {
_animationController.reverse();
}
widget.onSelected?.call(floor);
},
onFinished: () {
//结束动画
if (_animationController.status ==
AnimationStatus.completed) {
_animationController.reverse();
}
widget.onFinished?.call();
}),
),
)
],
),
);
});
Overlay.of(context)!.insert(overlayEntry!);
}
void dismissPopupMenuLayout() {
if (overlayEntry != null) {
overlayEntry!.remove();
overlayEntry = null;
}
}
@override
void initState() {
super.initState();
_animationController =
AnimationController(vsync: this, duration: widget.duration);
_animationController.addStatusListener((status) {
_currentAnimationStatus = status;
if (status == AnimationStatus.dismissed) {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
dismissPopupMenuLayout();
}
});
}
});
CurvedAnimation curvedAnimation =
CurvedAnimation(parent: _animationController, curve: Curves.linear);
_animation =
Tween(begin: 0.0, end: widget.expandHeight).animate(curvedAnimation);
_rotationAnimation = Tween(begin: 0.0, end: 1.0).animate(curvedAnimation);
_backgroundColorAnimation =
Tween(begin: 0.0, end: 0.5).animate(curvedAnimation);
_colorAnimation =
ColorTween(begin: widget.normalColor, end: widget.selectColor)
.animate(curvedAnimation);
}
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
if (_currentAnimationStatus == AnimationStatus.completed) {
_animationController.reverse();
} else if (_currentAnimationStatus == AnimationStatus.dismissed) {
showPopupMenuLayout();
}
},
child: Padding(
padding: widget.padding,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: _animationController,
builder: (BuildContext context, Widget? child) {
return Text(
widget.title,
style: TextStyle(
color: _colorAnimation.value,
fontSize: widget.titleFontSize),
);
},
),
const SizedBox(
width: 5,
),
AnimatedBuilder(
animation: _animationController,
builder: (BuildContext context, Widget? child) {
return Transform(
alignment: FractionalOffset.center,
transform: Matrix4.identity()
..rotateX(pi * _rotationAnimation.value),
child: Icon(
widget.expandIcon,
size: widget.expandIconSize,
color: _colorAnimation.value,
),
);
}),
],
),
),
);
}
@override
void dispose() {
_animationController.dispose();
dismissPopupMenuLayout();
super.dispose();
}
}
///弹出菜单位置计算
class _PopupMenuRouteLayoutDelegate extends SingleChildLayoutDelegate {
_PopupMenuRouteLayoutDelegate({
required this.position,
required this.padding,
required this.textDirection,
});
final RelativeRect position;
final EdgeInsets padding;
// 菜单内容展示方向
final TextDirection textDirection;
@override
bool shouldRelayout(_PopupMenuRouteLayoutDelegate oldDelegate) {
return position != oldDelegate.position || padding != oldDelegate.padding;
}
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
return BoxConstraints.loose(constraints.biggest).deflate(
padding,
);
}
@override
Offset getPositionForChild(Size size, Size childSize) {
// size: 遮盖层尺寸
// childSize: 菜单完全打开时的大小,由 getConstraintsForChild 确定。
final double buttonHeight = size.height - position.top - position.bottom;
// 菜单层y轴位置
double y = position.top + buttonHeight;
// 菜单层X轴位置
double x;
if (position.left > position.right) {
// 菜单按钮更靠近右边缘,因此向左增长,与右边缘对齐。
x = size.width - position.right - childSize.width;
} else if (position.left < position.right) {
// 菜单按钮更靠近左边缘,因此向右增长,与左边缘对齐。
x = position.left;
} else {
// 菜单按钮与两个边缘等距,因此在文字阅读方向上延申。
switch (textDirection) {
case TextDirection.rtl:
x = size.width - position.right - childSize.width;
break;
case TextDirection.ltr:
x = position.left;
break;
}
}
// 避免在每个方向上超出屏幕边缘像素的矩形区域。
// if (x < _kMenuScreenPadding + padding.left) {
// x = _kMenuScreenPadding + padding.left;
// } else if (x + childSize.width >
// size.width - _kMenuScreenPadding - padding.right) {
// x = size.width - childSize.width - _kMenuScreenPadding - padding.right;
// }
// if (y < _kMenuScreenPadding + padding.top) {
// y = _kMenuScreenPadding + padding.top;
// } else if (y + childSize.height >
// size.height - _kMenuScreenPadding - padding.bottom) {
// y = size.height - padding.bottom - _kMenuScreenPadding - childSize.height;
// }
return Offset(x, y);
}
}
//下拉伸展菜单展开时,裁剪尺寸计算
class _ClipperPath extends CustomClipper<Rect> {
_ClipperPath(this.value);
double value;
@override
Rect getClip(Size size) {
return Rect.fromLTRB(0, 0, size.width, value);
}
@override
bool shouldReclip(CustomClipper<Rect> oldClipper) {
return true;
}
}
///三级级联菜单
class _Cascade extends StatefulWidget {
const _Cascade(
{required this.deviceSearchConfig,
required this.onSelected,
required this.onFinished,
required this.currentTitle});
final LocationDataModel deviceSearchConfig;
final PopupWindowMenuItemSelected<String> onSelected;
final VoidCallback onFinished;
//当前按钮标题
final String currentTitle;
@override
State<StatefulWidget> createState() => _CascadeState();
}
class _CascadeState extends State<_Cascade> {
//是否显示第三级菜单
bool show = false;
//已选择楼栋
int selectBuildingIndex = 0;
//已选择单元
int selectUnitIndex = 0;
//已选择楼层
int selectFloorIndex = 0;
//所有楼栋
late List<String> buildings;
//已选楼的所有单元
late List<String> units;
//已选单元的所有楼层
List<String>? floors;
@override
void initState() {
super.initState();
selectBuildingIndex =
widget.deviceSearchConfig.initBuildingIndex(widget.currentTitle);
buildings = widget.deviceSearchConfig.getBuilding();
units = widget.deviceSearchConfig
.getUnit(buildings.elementAt(selectBuildingIndex));
}
@override
Widget build(BuildContext context) {
return Column(
children: [
SizedBox(
height: 307,
child: Row(
children: [
Container(
decoration: const BoxDecoration(
border: Border(
right: BorderSide(color: Color(0xffebebeb), width: 1))),
width: 100,
child: ListView.builder(
//第一级菜单
padding: EdgeInsets.zero,
itemBuilder: (BuildContext context, int index) {
var name = buildings.elementAt(index);
return Material(
child: InkWell(
onTap: () {
if (selectBuildingIndex != index) {
setState(() {
selectBuildingIndex = index;
//清除第二第三级菜单数据
selectUnitIndex = 0;
selectFloorIndex = 0;
units.clear();
show = false;
floors?.clear();
//重置二级菜单数据
units = widget.deviceSearchConfig.getUnit(name);
});
}
},
child: Container(
color: selectBuildingIndex == index
? const Color(0xfff2f6ff)
: null,
alignment: Alignment.center,
padding: const EdgeInsets.only(top: 12, bottom: 12),
child: Text(
'$name栋',
style: TextStyle(
color: selectBuildingIndex == index
? const Color(0xff5974f4)
: const Color(0xff969696),
fontSize: 14),
),
),
),
);
},
itemCount: widget.deviceSearchConfig.getBuilding().length,
),
),
Expanded(
child: Container(
decoration: show
? const BoxDecoration(
border: Border(
right: BorderSide(
color: Color(0xffebebeb), width: 1)))
: null,
child: ListView.builder(
//第二级菜单
padding: EdgeInsets.zero,
itemBuilder: (BuildContext context, int index) {
String unit;
if (index == 0) {
unit = units.elementAt(index);
} else {
unit = units.elementAt(index) + '单元';
}
return Material(
child: InkWell(
onTap: () {
if (selectUnitIndex != index) {
setState(() {
selectUnitIndex = index;
if (selectUnitIndex == 0) {
show = false;
//清除第三级菜单数据
selectFloorIndex = 0;
floors?.clear();
} else {
show = true;
floors = widget.deviceSearchConfig.getFloor(
buildings.elementAt(selectBuildingIndex),
units.elementAt(selectUnitIndex));
}
});
}
},
child: Container(
color: selectUnitIndex == index
? const Color(0xfff2f6ff)
: null,
alignment: Alignment.center,
padding: const EdgeInsets.only(top: 12, bottom: 12),
child: Text(
unit,
style: TextStyle(
color: selectUnitIndex == index
? const Color(0xff5974f4)
: const Color(0xff969696),
fontSize: 14),
),
),
),
);
},
itemCount: units.length,
),
),
),
if (show)
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemBuilder: (BuildContext context, int index) {
String floor;
if (index == 0) {
floor = floors!.elementAt(index);
} else {
floor = floors!.elementAt(index) + '层';
}
return Material(
child: InkWell(
onTap: () {
if (selectFloorIndex != index) {
setState(() {
selectFloorIndex = index;
});
}
},
child: Container(
color: selectFloorIndex == index
? const Color(0xfff2f6ff)
: null,
alignment: Alignment.center,
padding: const EdgeInsets.only(top: 12, bottom: 12),
child: Text(
floor,
style: TextStyle(
color: selectFloorIndex == index
? const Color(0xff5974f4)
: const Color(0xff969696),
fontSize: 14),
),
),
),
);
},
itemCount: floors!.length,
),
)
],
),
),
Container(
height: 1,
color: const Color(0xffebebeb),
),
Row(
children: [
Expanded(
child: Material(
child: InkWell(
onTap: widget.onFinished,
child: const SizedBox(
height: 43,
child: Center(
child: Text("清空",
style:
TextStyle(fontSize: 16, color: Color(0xff69696c))),
),
),
),
),
),
Expanded(
child: Material(
child: InkWell(
onTap: () {
//选择的菜单数据回调
widget.onSelected.call('');
},
child: Container(
color: const Color(0xff5974f4),
height: 43,
child: const Center(
child: Text(
"确认",
style: TextStyle(fontSize: 16, color: Colors.white),
)),
),
),
),
),
],
)
],
);
}
}
//控件调用示例
Scaffold(
appBar: universalAppBar('三级下拉菜单按钮', false),
body: PopupWindowButton(
title: "区域",
menuData: LocationDataModel(_jsonData),
onSelected: (value) {
// 选择菜单数据回调
},
onFinished: () {
// 点击重置按钮回调
},
),
);
10. 自定义音频播放器小部件
效果图:
添加依赖库:
video_player: ^2.2.17
///播放进度条
flutter_neumorphic: ^3.2.0
由于flutter官方并没有推出音频播放专用播放器AudioPlayer,这里使用官方推出的视频播放器VideoPlayer实现音频播放器。
- 播放器代码
///录音播放器
class AudioPlayerView extends StatefulWidget {
const AudioPlayerView(
{Key? key, this.width, this.height, required this.alarmAudioUrl})
: super(key: key);
//宽
final double? width;
//高
final double? height;
//音频网络地址
final String alarmAudioUrl;
@override
State<StatefulWidget> createState() => _AudioPlayerViewState();
}
class _AudioPlayerViewState extends State<AudioPlayerView> {
VideoPlayerController? _controller;
bool _initializing = true;
@override
void initState() {
super.initState();
//播放器初始化
_controller = VideoPlayerController.network(widget.alarmAudioUrl)
..initialize().then((value) {
if (mounted) {
setState(() {
_initializing = false;
});
}
});
_controller?.addListener(() {
//刷新当前进度条
if (mounted) {
setState(() {});
}
});
}
@override
Widget build(BuildContext context) {
return _initializing
? const Text('正在下载录音...',
style: TextStyle(
fontSize: 15,
color: Color(0xff323233),
fontWeight: FontWeight.w600))
: Container(
width: widget.width,
height: widget.height,
padding:
const EdgeInsets.only(left: 10, right: 10, top: 8, bottom: 8),
decoration: const BoxDecoration(
color: Color(0xfff3f3f3),
borderRadius: BorderRadius.all(Radius.circular(15))),
child: _controller == null || !_controller!.value.isInitialized
? Container()
: Row(
children: [
InkWell(
onTap: () {
//点击播放、暂停按钮
if (_controller!.value.isPlaying) {
_controller?.pause().then((value) {
if (mounted) {
setState(() {});
}
});
} else {
_controller?.play().then((value) {
if (mounted) {
setState(() {});
}
});
}
},
child: _controller!.value.isPlaying
? Image.asset(
'images/icon_stop_record.png',
width: 30,
height: 30,
)
: Image.asset(
'images/icon_start_record.png',
width: 30,
height: 30,
)),
const SizedBox(width: 8),
Expanded(
child: NeumorphicSlider(//进度条
height: 4,
min: 0,
max: _controller!.value.duration.inMilliseconds
.toDouble(),
value: _controller!.value.position.inMilliseconds
.toDouble(),
thumb: ClipOval(//进度游标
child: Container(
width: 6,
height: 5,
color: const Color(0xff5974f4),
),
),
style: const SliderStyle(
// variant: Colors.white,
// accent: Colors.white,
variant: Color(0xffd9d9d9),
accent: Color(0xffd9d9d9),
),
onChanged: (value) {},
),
),
const SizedBox(width: 8),
Text(
"${_controller!.value.duration.inSeconds.toDouble()}s",
style: const TextStyle(
color: Color(0xcc333333), fontSize: 10)),
],
),
);
}
@override
void dispose() {
//释放控制器
_controller?.dispose();
super.dispose();
}
}
- 调用示例
Scaffold(
appBar: universalAppBar('音频播放器控件', false),
body: Padding(
padding: const EdgeInsets.only(left: 15, right: 15, top: 20),
child: Column(
children: [
Row(
children: const [
SizedBox(
child: Text(
'音频播放小控件:',
style: TextStyle(
fontSize: 15, color: Color(0xff323233)),
),
),
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: AudioPlayerView(//播放器控件
width: 160,
height: 30,
alarmAudioUrl: 'http://121.40.242.110:8401/aispeech/ota/6285e81ce4b05c7c8ef04b291652942876549.wav',
),
),
)
],
),
],
),
)
)
11. 视频播放器(暂缓)
12. 自适应高度输入框,支持字符限定,支持预输入文本
效果图:
///自适应输入框
///支持高度自适应,自动换行,
///支持限定最大字数
///支持展示预输入文本
Widget autoAdjustInputBox() {
String text = '这是预输入文本';
return StatefulBuilder(builder: (context, setState) {
return Padding(
padding: const EdgeInsets.only(right: 80, left: 80, top: 60),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(6)),
color: const Color(0xfff7f8fa),
border: Border.all(
color: const Color(0xffebebeb),
width: 1,
style: BorderStyle.solid)),
child: TextField(
controller: text.isEmpty ? null : TextEditingController(text: text),
style: const TextStyle(
fontSize: 14, color: Color(0xff323233), height: 1.5),
maxLines: null,
maxLength: 15,
keyboardType: TextInputType.multiline,
decoration: InputDecoration.collapsed(
hintText: '请输入内容...',
hintStyle: text.isEmpty
? const TextStyle(fontSize: 14, color: Color(0xffcccccc))
: const TextStyle(
fontSize: 14, color: Color(0xff323233), height: 1.5),
),
onChanged: (value) {
if (value.isEmpty) {
setState.call(() {
text = value;
});
} else {
text = value;
}
},
),
),
);
});
}