flutter UI控件集锦

734 阅读5分钟

所有代码库github地址:
github.com/crazylii/fl…

1. 上拉刷新下拉加载

效果图:

Video_20220518_111906_886.gif

///上拉刷新下拉加载
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

效果图:

Video_20220518_115037_477.gif

///网格布局
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自定义底部菜单栏,支持未读消息角标显示

效果图:

Video_20220518_025148_923.gif
这里菜单项点击时还可以添加点击放大动画效果,增加体验性,有兴趣的同学可自行扩展添加。

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使用

效果图:

Video_20220518_040149_906.gif
这里做了一个禁止左右滑动切换页面的操作,只能通过点击菜单切换页面。
原因是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. 多样式富文本展示

效果图:

IMG_20220518_161807.jpg

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按钮的使用

效果图:

IMG_20220518_163129.jpg

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 简单确认窗

效果图:

IMG_20220518_163835.jpg

///退出当前账户
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 带有输入框的弹窗

效果图:

IMG_20220518_165027.jpg

//输入框数据回传
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

效果图:

Video_20220518_052000_661.gif

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底部弹出菜单

效果图:

Video_20220520_020310_72.gif

///仿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弹出下拉菜单按钮,三级级联菜单

效果图:

Video_20220519_031714_738.gif

9.1 三级菜单数据

  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": []
            }
          ]
        }
      ]
    }
  ]
}
  1. 加载本地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

  1. 标题颜色变化及小箭头翻转动画
    • 颜色变化使用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,
            ),
          );
        }),
    
  2. 下拉菜单展出动画
    使用Tween补间动画实现下拉菜单高度从0到目标高度的变化;
    _animation =
        Tween(begin: 0.0, end: widget.expandHeight).animate(curvedAnimation);
    
    ClipRect实现动画平滑展出
    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;
      }
    }
    
  3. 背景遮盖层透明度变化动画
    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按钮下方;

  1. 计算标题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,
);
  1. 根据标题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);
  }
}
  1. 创建弹出菜单页遮盖层,插入到当前窗口上面
//创建遮盖层
overlayEntry = OverlayEntry(builder: (context) {
  return CustomSingleChildLayout(
    delegate: _PopupMenuRouteLayoutDelegate(
        textDirection: TextDirection.ltr,
        position: position,
        padding: EdgeInsets.zero),
    child: Container();
  );
});
//插入遮盖层
Overlay.of(context)!.insert(overlayEntry!);
  1. 完整代码
///数据回调
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_20220520_104159_850.gif

添加依赖库:

video_player: ^2.2.17
///播放进度条
flutter_neumorphic: ^3.2.0

由于flutter官方并没有推出音频播放专用播放器AudioPlayer,这里使用官方推出的视频播放器VideoPlayer实现音频播放器。

  1. 播放器代码
///录音播放器
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();
  }
}
  1. 调用示例
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. 自适应高度输入框,支持字符限定,支持预输入文本

效果图:

Video_20220520_114629_344.gif

///自适应输入框
///支持高度自适应,自动换行,
///支持限定最大字数
///支持展示预输入文本
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;
            }
          },
        ),
      ),
    );
  });
}