06_Flutter自定义锚点分类列表

06_Flutter自定义锚点分类列表

在这里插入图片描述

这样的效果,大家在一些商超应用里,应该也看到过。接下来咱们就用Flutter一步一步的来实现。

一.自定义属性抽取

在这里插入图片描述

  • categoryWidth: 左侧边栏的宽度,右侧区域的宽度填充剩余空间即可。
  • itemCount: 总共有多少个分类项,也就是左侧边栏中有多少个字项。
  • sticky: 滑动过程中,右侧标题是否吸顶。
  • controller: 外部通过controller可以控制左侧边栏中子项的选中以及右侧列表滑动位置的联动,同时监听选中状态。
  • categoryItemBuilder: 创建左侧边栏中的每一个分类项。
  • sectionItemBuilder: 创建右侧滑动列表中的每一个标题项。
  • sectionOfChildrenBuilder: 创建右侧滑动列表中的每一个标题项对应的子列表
class AnchorCategoryController extends ChangeNotifier {
  int selectedIndex = 0;

  void selectTo(int value) {
    selectedIndex = value;
    notifyListeners();
  }

  
  void dispose() {
    selectedIndex = 0;
    super.dispose();
  }
}

class _HomePageState extends State<HomePage> {

  final List<String> _sections = ["标题1", "标题2", "标题3", "标题4", "标题5", "标题6", "标题7", "标题8", "标题9", "标题10"];
  final List<List<String>> _childrenList = [
    ["item1", "item2", "item3", "item4", "item5"],
    ["item1", "item2", "item3"],
    ["item1", "item2", "item3", "item4"],
    ["item1"],
    ["item1", "item2"],
    ["item1", "item2", "item3", "item4", "item5", "item6"],
    ["item1", "item2", "item3", "item4"],
    ["item1", "item2", "item3", "item4", "item5"],
    ["item1", "item2", "item3"],
    ["item1", "item2", "item3", "item4", "item5"]
  ];

  int _selectedSectionsIndex = 0;
  final AnchorCategoryController _controller = AnchorCategoryController();

  
  void initState() {
    super.initState();
    _controller.addListener(_onCategoryChanged);
  }

  void _onCategoryChanged() {
    setState(() {
      _selectedSectionsIndex = _controller.selectedIndex;
    });
  }
  
  
  void dispose() {
    _controller.removeListener(_onCategoryChanged);
    _controller.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: SafeArea(
        child: AnchorCategoryList(
          controller: _controller,
          itemCount: _sections.length,
          sticky: true,
          categoryItemBuilder: (BuildContext context, int index) {
            return AlphaButton(
              onTap: () {
                _controller.selectTo(index);
              },
              child: Container(
                padding: const EdgeInsets.all(10),
                color: _selectedSectionsIndex == index ? const Color(0xFFFFFFFF): const Color(0xFFF2F2F2),
                child: Text(_sections[index]),
              ),
            );
          },
          sectionItemBuilder: (BuildContext context, int index) {
            return Container(
              padding: const EdgeInsets.symmetric(vertical: 10),
              alignment: Alignment.centerLeft,
              color: const Color(0xFFF2F2F2),
              child: Text(_sections[index]),
            );
          },
          sectionOfChildrenBuilder: (BuildContext context, int index) {
            return List<Widget>.generate(_childrenList[index].length, (childIndex) {
              return Container(
                padding: const EdgeInsets.symmetric(vertical: 10),
                alignment: Alignment.centerLeft,
                child: Text(_childrenList[index][childIndex]),
              );
            });
          },
        )
      )
    );
  }
}
二.组件基本布局
class AnchorCategoryList extends StatefulWidget {

  final double categoryWidth;
  final int itemCount;
  final IndexedWidgetBuilder categoryItemBuilder;
  final IndexedWidgetBuilder sectionItemBuilder;
  final IndexedWidgetListBuilder sectionOfChildrenBuilder;
  final bool sticky;
  final AnchorCategoryController? controller;

  const AnchorCategoryList({
    super.key,
    required this.categoryItemBuilder,
    required this.sectionItemBuilder,
    required this.sectionOfChildrenBuilder,
    this.controller,
    double? categoryWidth,
    int? itemCount,
    bool? sticky
  }): categoryWidth = categoryWidth ?? 112,
        itemCount = itemCount ?? 0,
        sticky = sticky ?? true;

  
  State<StatefulWidget> createState() => _AnchorCategoryListState();

}

class _AnchorCategoryListState extends State<AnchorCategoryList> {

  
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.max,
      mainAxisAlignment: MainAxisAlignment.start,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        SizedBox(
          width: widget.categoryWidth,
          child: LayoutBuilder(
            builder: (context, viewportConstraints) {
              return SingleChildScrollView(
                child: ConstrainedBox(
                  constraints: BoxConstraints(
                    minHeight: viewportConstraints.maxHeight != double.infinity ? viewportConstraints.maxHeight:0
                  ),
                  child: Column(
                    mainAxisSize: MainAxisSize.max,
                    mainAxisAlignment: MainAxisAlignment.start,
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: List.generate(widget.itemCount, (index) {
                      return widget.categoryItemBuilder.call(context, index);
                    }),
                  ),
                ),
              );
            },
          )
        ),
        Expanded(
          child: CustomScrollView(
            physics: const ClampingScrollPhysics(),
            slivers: [
              ...(
                List<Widget>.generate(widget.itemCount * 2, (allIndex) {
                  int index = allIndex ~/ 2;
                  if(allIndex.isEven) {
                    //section
                    return SliverToBoxAdapter(
                      child: widget.sectionItemBuilder.call(context, index),
                    );
                  } else {
                    //children
                    return SliverToBoxAdapter(
                      child: Column(
                        children: widget.sectionOfChildrenBuilder.call(context, index),
                      ),
                    );
                  }
                })
              ),
            ]
          )
        )
      ],
    );
  }

}
三.获取并保存标题项、标题项对应子列表的高度

这里获取标题项、标题项对应子列表的高度,需要等到控件build完成后,才能获取到,因此需要自定义一个控件继承SingleChildRenderObjectWidget,并指定一个自定义的RenderBox,在performLayout中通过回调通知外部,控件layout完成了

typedef AfterLayoutCallback = Function(RenderBox ral);

class AfterLayout extends SingleChildRenderObjectWidget {

  final AfterLayoutCallback callback;

  const AfterLayout({
    Key? key,
    required this.callback,
    Widget? child,
  }) : super(key: key, child: child);

  
  RenderObject createRenderObject(BuildContext context) {
    return RenderAfterLayout(callback);
  }

  
  void updateRenderObject(context, RenderAfterLayout renderObject) {
    renderObject.callback = callback;
  }
}

class RenderAfterLayout extends RenderProxyBox {

  AfterLayoutCallback callback;

  RenderAfterLayout(this.callback);

  
  void performLayout() {
    super.performLayout();
    SchedulerBinding.instance
        .addPostFrameCallback((timeStamp) => callback(this));
  }
  
}

使用AfterLayout获取并保存标题项、标题项对应子列表的高度


Widget build(BuildContext context) {
  return Row(
    mainAxisSize: MainAxisSize.max,
    mainAxisAlignment: MainAxisAlignment.start,
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      SizedBox(
        width: widget.categoryWidth,
        child: LayoutBuilder(
          builder: (context, viewportConstraints) {
            return SingleChildScrollView(
              child: ConstrainedBox(
                constraints: BoxConstraints(
                  minHeight: viewportConstraints.maxHeight != double.infinity ? viewportConstraints.maxHeight:0
                ),
                child: Column(
                  mainAxisSize: MainAxisSize.max,
                  mainAxisAlignment: MainAxisAlignment.start,
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: List.generate(widget.itemCount, (index) {
                    return widget.categoryItemBuilder.call(context, index);
                  }),
                ),
              ),
            );
          },
        )
      ),
      Expanded(
        child: CustomScrollView(
          physics: const ClampingScrollPhysics(),
          slivers: [
            ...(
              List<Widget>.generate(widget.itemCount * 2, (allIndex) {
                int index = allIndex ~/ 2;
                if(allIndex.isEven) {
                  //section
                  return SliverToBoxAdapter(
                    child: AfterLayout(
                      callback: (renderBox) {
                        double height = renderBox.size.height;
                        setState(() {
                          if(_sectionHeightList.length > index) {
                            _sectionHeightList[index] = height;
                          } else {
                            _sectionHeightList.add(height);
                          }
                        });
                      },
                      child: widget.sectionItemBuilder.call(context, index),
                    ),
                  );
                } else {
                  //children
                  return SliverToBoxAdapter(
                    child: AfterLayout(
                      callback: (renderBox) {
                        double height = renderBox.size.height;
                        setState(() {
                          if(_childrenHeightList.length > index) {
                            _childrenHeightList[index] = height;
                          } else {
                            _childrenHeightList.add(height);
                          }
                        });
                      },
                      child: Column(
                        children: widget.sectionOfChildrenBuilder.call(context, index),
                      ),
                    ),
                  );
                }
              })
            ),
          ]
        )
      )
    ],
  );
}

计算并保存右侧面板每一项选中时的初始滑动偏移量

在这里插入图片描述


Widget build(BuildContext context) {
  return Row(
    mainAxisSize: MainAxisSize.max,
    mainAxisAlignment: MainAxisAlignment.start,
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      SizedBox(
        width: widget.categoryWidth,
        child: LayoutBuilder(
          builder: (context, viewportConstraints) {
            return SingleChildScrollView(
              child: ConstrainedBox(
                constraints: BoxConstraints(
                  minHeight: viewportConstraints.maxHeight != double.infinity ? viewportConstraints.maxHeight:0
                ),
                child: Column(
                  mainAxisSize: MainAxisSize.max,
                  mainAxisAlignment: MainAxisAlignment.start,
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: List.generate(widget.itemCount, (index) {
                    return widget.categoryItemBuilder.call(context, index);
                  }),
                ),
              ),
            );
          },
        )
      ),
      Expanded(
        child: AfterLayout(
          callback: (renderBox) {
            setState(() {
              for(int i = 0; i < widget.itemCount; i ++) {
                double scrollOffset = 0;
                for(int j=0; j<i; j++) {
                  scrollOffset += _sectionHeightList[j] + _childrenHeightList[j];
                }
                if(_scrollOffsetList.length > i) {
                  _scrollOffsetList[i] = scrollOffset;
                } else {
                  _scrollOffsetList.add(scrollOffset);
                }
              }
              debugPrint("CustomScrollView AfterLayout: $_scrollOffsetList");
            });
          },
          child: CustomScrollView(
            physics: const ClampingScrollPhysics(),
            slivers: [
              ...(
                List<Widget>.generate(widget.itemCount * 2, (allIndex) {
                  int index = allIndex ~/ 2;
                  if(allIndex.isEven) {
                    //section
                    return SliverToBoxAdapter(
                      child: AfterLayout(
                        callback: (renderBox) {
                          double height = renderBox.size.height;
                          setState(() {
                            if(_sectionHeightList.length > index) {
                              _sectionHeightList[index] = height;
                            } else {
                              _sectionHeightList.add(height);
                            }
                          });
                        },
                        child: widget.sectionItemBuilder.call(context, index),
                      ),
                    );
                  } else {
                    //children
                    return SliverToBoxAdapter(
                      child: AfterLayout(
                        callback: (renderBox) {
                          double height = renderBox.size.height;
                          setState(() {
                            if(_childrenHeightList.length > index) {
                              _childrenHeightList[index] = height;
                            } else {
                              _childrenHeightList.add(height);
                            }
                          });
                        },
                        child: Column(
                          children: widget.sectionOfChildrenBuilder.call(context, index),
                        ),
                      ),
                    );
                  }
                })
              ),
            ]
          ),
        )
      )
    ],
  );
}
四.点击选中分类项时,右侧自动滑动至相应位置

首先,这里需要把右侧列表最后一项的高度设置为ViewPort的高度,保证最后能够滑动到最后一项。只需要在右侧列表添加一个空白区域即可。


Widget build(BuildContext context) {
  return Row(
    mainAxisSize: MainAxisSize.max,
    mainAxisAlignment: MainAxisAlignment.start,
    crossAxisAlignment: CrossAxisAlignment.stretch,
    children: [
      ...,
      Expanded(
        child: AfterLayout(
          callback: (renderBox) {
            setState(() {
              ...

              if(widget.itemCount > 0) {
                _extraHeight = max(renderBox.size.height - _childrenHeightList[widget.itemCount - 1], 0);
              } else {
                _extraHeight = 0;
              }
            });
          },
          child: CustomScrollView(
            physics: const ClampingScrollPhysics(),
            slivers: [
              ...,

              SliverToBoxAdapter(
                child: SizedBox(
                  height: _extraHeight,
                ),
              )
            ]
          ),
        )
      )
    ],
  );
}

根据前面确定好初始的滑动偏移量之后,就能很方便的控制右侧列表的滑动了,我们通过给右侧列表指定ScrollController,同时调用ScrollController的animateTo(double offset, {required Duration duration, required Curve curve})方法即可。

class _AnchorCategoryListState extends State<AnchorCategoryList> {

  ...

  final ScrollController _scrollController = ScrollController();
  int _selectedIndex = 0;
  bool _scrollLocked = false;

  
  void initState() {
    super.initState();
    if(widget.controller != null) {
      widget.controller!.addListener(_onIndexChange);
    }
  }

  void _onIndexChange() {
    if(_selectedIndex == widget.controller!.selectedIndex) {
      return;
    }

    _scrollLocked = true;

    _selectedIndex = widget.controller!.selectedIndex;
    widget.controller!.selectTo(_selectedIndex);
    _scrollController.animateTo(
        _scrollOffsetList[widget.controller!.selectedIndex],
        duration: const Duration(milliseconds: 300),
        curve: Curves.linear
    ).then((value) {
      _scrollLocked = false;
    });
  }

  
  void dispose() {
    _scrollController.dispose();
    
    if(widget.controller != null) {
      widget.controller!.removeListener(_onIndexChange);
    }
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.max,
      mainAxisAlignment: MainAxisAlignment.start,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        ...,
        Expanded(
          child: AfterLayout(
            callback: (renderBox) {
              setState(() {
                for(int i = 0; i < widget.itemCount; i ++) {
                  double scrollOffset = 0;
                  for(int j=0; j<i; j++) {
                    scrollOffset += _sectionHeightList[j] + _childrenHeightList[j];
                  }
                  if(_scrollOffsetList.length > i) {
                    _scrollOffsetList[i] = scrollOffset;
                  } else {
                    _scrollOffsetList.add(scrollOffset);
                  }
                }

                if(widget.itemCount > 0) {
                  _extraHeight = max(renderBox.size.height - _childrenHeightList[widget.itemCount - 1], 0);
                } else {
                  _extraHeight = 0;
                }
              });
            },
            child: CustomScrollView(
              physics: const ClampingScrollPhysics(),
              controller: _scrollController,
              slivers: [
                ...
              ]
            ),
          )
        )
      ],
    );
  }

}

在这里插入图片描述

五.右侧列表滚动时,动态改变左侧边栏的选中状态

监听右侧列表的滑动,获取滑动位置,与所有子项的初始滑动偏移量对比,可以计算出左侧边栏的哪一个子项应该被选中,然后通过AnchorCategoryController的selectTo(int value)方法更新选中状态即可。

class _AnchorCategoryListState extends State<AnchorCategoryList> {

  ...

  
  void initState() {
    super.initState();
    if(widget.controller != null) {
      widget.controller!.addListener(_onIndexChange);
    }

    _scrollController.addListener(_onScrollChange);
  }

  ...

  void _onScrollChange() {
    if(_scrollLocked) {
      return;
    }

    double scrollOffset = _scrollController.offset;
    int selectedIndex = 0;
    for(int index = _scrollOffsetList.length - 1; index >= 0; index --) {
      selectedIndex = index;
      if(scrollOffset.roundToDouble() >= _scrollOffsetList[index]) {
        break;
      }
    }

    if(_selectedIndex != selectedIndex) {
      _selectedIndex = selectedIndex;
      widget.controller!.selectTo(selectedIndex);
    }
  }

  
  void dispose() {
    _scrollController.removeListener(_onScrollChange);
    _scrollController.dispose();

    if(widget.controller != null) {
      widget.controller!.removeListener(_onIndexChange);
    }
    super.dispose();
  }
  
  ...

}
六.控制标题项吸顶

将标题项的SliverToBoxAdapter替换成StickySliverToBoxAdapter即可,关于StickySliverToBoxAdapter可以查看这篇文章02_Flutter自定义Sliver组件实现分组列表吸顶效果。

class _AnchorCategoryListState extends State<AnchorCategoryList> {

  ...

  
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.max,
      mainAxisAlignment: MainAxisAlignment.start,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        ...,
        Expanded(
          child: AfterLayout(
            callback: (renderBox) {
              ...
            },
            child: CustomScrollView(
              physics: const ClampingScrollPhysics(),
              controller: _scrollController,
              slivers: [
                ...(
                  List<Widget>.generate(widget.itemCount * 2, (allIndex) {
                    int index = allIndex ~/ 2;
                    if(allIndex.isEven) {
                      //section
                      Widget sectionItem = AfterLayout(
                        callback: (renderBox) {
                          double height = renderBox.size.height;
                          setState(() {
                            if(_sectionHeightList.length > index) {
                              _sectionHeightList[index] = height;
                            } else {
                              _sectionHeightList.add(height);
                            }
                          });
                        },
                        child: widget.sectionItemBuilder.call(context, index),
                      );

                      if(widget.sticky) {
                        return StickySliverToBoxAdapter(
                          child: sectionItem,
                        );
                      } else {
                        return SliverToBoxAdapter(
                          child: sectionItem,
                        );
                      }
                    } else {
                      //children
                      ...
                    }
                  })
                ),

                ...
              ]
            ),
          )
        )
      ],
    );
  }

}

在这里插入图片描述

搞定,模拟器录屏掉帧了,改用真机录屏😄。

七.完整代码
typedef IndexedWidgetListBuilder = List<Widget> Function(BuildContext context, int index);

class AnchorCategoryController extends ChangeNotifier {
  int selectedIndex = 0;

  void selectTo(int value) {
    selectedIndex = value;
    notifyListeners();
  }

  
  void dispose() {
    selectedIndex = 0;
    super.dispose();
  }
}

class AnchorCategoryList extends StatefulWidget {

  final double categoryWidth;
  final int itemCount;
  final IndexedWidgetBuilder categoryItemBuilder;
  final IndexedWidgetBuilder sectionItemBuilder;
  final IndexedWidgetListBuilder sectionOfChildrenBuilder;
  final bool sticky;
  final AnchorCategoryController? controller;

  const AnchorCategoryList({
    super.key,
    required this.categoryItemBuilder,
    required this.sectionItemBuilder,
    required this.sectionOfChildrenBuilder,
    this.controller,
    double? categoryWidth,
    int? itemCount,
    bool? sticky
  }): categoryWidth = categoryWidth ?? 112,
        itemCount = itemCount ?? 0,
        sticky = sticky ?? true;

  
  State<StatefulWidget> createState() => _AnchorCategoryListState();

}

class _AnchorCategoryListState extends State<AnchorCategoryList> {

  final List<double> _sectionHeightList = [];
  final List<double> _childrenHeightList = [];
  final List<double> _scrollOffsetList = [];
  double _extraHeight = 0;

  final ScrollController _scrollController = ScrollController();
  int _selectedIndex = 0;
  bool _scrollLocked = false;

  
  void initState() {
    super.initState();
    if(widget.controller != null) {
      widget.controller!.addListener(_onIndexChange);
    }

    _scrollController.addListener(_onScrollChange);
  }

  void _onIndexChange() {
    if(_selectedIndex == widget.controller!.selectedIndex) {
      return;
    }

    _scrollLocked = true;

    _selectedIndex = widget.controller!.selectedIndex;
    widget.controller!.selectTo(_selectedIndex);
    _scrollController.animateTo(
        _scrollOffsetList[widget.controller!.selectedIndex],
        duration: const Duration(milliseconds: 300),
        curve: Curves.linear
    ).then((value) {
      _scrollLocked = false;
    });
  }

  void _onScrollChange() {
    if(_scrollLocked) {
      return;
    }

    double scrollOffset = _scrollController.offset;
    int selectedIndex = 0;
    for(int index = _scrollOffsetList.length - 1; index >= 0; index --) {
      selectedIndex = index;
      if(scrollOffset.roundToDouble() >= _scrollOffsetList[index]) {
        break;
      }
    }

    if(_selectedIndex != selectedIndex) {
      _selectedIndex = selectedIndex;
      widget.controller!.selectTo(selectedIndex);
    }
  }

  
  void dispose() {
    _scrollController.removeListener(_onScrollChange);
    _scrollController.dispose();

    if(widget.controller != null) {
      widget.controller!.removeListener(_onIndexChange);
    }
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.max,
      mainAxisAlignment: MainAxisAlignment.start,
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        SizedBox(
          width: widget.categoryWidth,
          child: LayoutBuilder(
            builder: (context, viewportConstraints) {
              return SingleChildScrollView(
                child: ConstrainedBox(
                  constraints: BoxConstraints(
                    minHeight: viewportConstraints.maxHeight != double.infinity ? viewportConstraints.maxHeight:0
                  ),
                  child: Column(
                    mainAxisSize: MainAxisSize.max,
                    mainAxisAlignment: MainAxisAlignment.start,
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: List.generate(widget.itemCount, (index) {
                      return widget.categoryItemBuilder.call(context, index);
                    }),
                  ),
                ),
              );
            },
          )
        ),
        Expanded(
          child: AfterLayout(
            callback: (renderBox) {
              setState(() {
                for(int i = 0; i < widget.itemCount; i ++) {
                  double scrollOffset = 0;
                  for(int j=0; j<i; j++) {
                    scrollOffset += _sectionHeightList[j] + _childrenHeightList[j];
                  }
                  if(_scrollOffsetList.length > i) {
                    _scrollOffsetList[i] = scrollOffset;
                  } else {
                    _scrollOffsetList.add(scrollOffset);
                  }
                }

                if(widget.itemCount > 0) {
                  _extraHeight = max(renderBox.size.height - _childrenHeightList[widget.itemCount - 1], 0);
                } else {
                  _extraHeight = 0;
                }
              });
            },
            child: CustomScrollView(
              physics: const ClampingScrollPhysics(),
              controller: _scrollController,
              slivers: [
                ...(
                  List<Widget>.generate(widget.itemCount * 2, (allIndex) {
                    int index = allIndex ~/ 2;
                    if(allIndex.isEven) {
                      //section
                      Widget sectionItem = AfterLayout(
                        callback: (renderBox) {
                          double height = renderBox.size.height;
                          setState(() {
                            if(_sectionHeightList.length > index) {
                              _sectionHeightList[index] = height;
                            } else {
                              _sectionHeightList.add(height);
                            }
                          });
                        },
                        child: widget.sectionItemBuilder.call(context, index),
                      );

                      if(widget.sticky) {
                        return StickySliverToBoxAdapter(
                          child: sectionItem,
                        );
                      } else {
                        return SliverToBoxAdapter(
                          child: sectionItem,
                        );
                      }
                    } else {
                      //children
                      return SliverToBoxAdapter(
                        child: AfterLayout(
                          callback: (renderBox) {
                            double height = renderBox.size.height;
                            setState(() {
                              if(_childrenHeightList.length > index) {
                                _childrenHeightList[index] = height;
                              } else {
                                _childrenHeightList.add(height);
                              }
                            });
                          },
                          child: Column(
                            children: widget.sectionOfChildrenBuilder.call(context, index),
                          ),
                        ),
                      );
                    }
                  })
                ),

                SliverToBoxAdapter(
                  child: SizedBox(
                    height: _extraHeight,
                  ),
                )
              ]
            ),
          )
        )
      ],
    );
  }

}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/585946.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Centos 系列 安装ArchR

Analyzing single-cell regulatory chromatin in R. • ArchRhttps://www.archrproject.com/ ArchR is a scalable software package for integrative single-cell chromatin accessibility analysis 一、Installation of ArchR &#xff08;Ideal&#xff09; ArchR is desig…

四川古力未来科技抖音小店安全:守护您的网购体验

在互联网购物日益普及的今天&#xff0c;四川古力未来科技抖音小店以其独特的魅力和安全保障措施&#xff0c;成为越来越多消费者信赖的购物平台。本文将为您详细解读四川古力未来科技抖音小店的安全保障措施&#xff0c;让您在享受便捷购物的同时&#xff0c;也能安心、放心。…

【简单讲解下FastStone Capture】

&#x1f3a5;博主&#xff1a;程序员不想YY啊 &#x1f4ab;CSDN优质创作者&#xff0c;CSDN实力新星&#xff0c;CSDN博客专家 &#x1f917;点赞&#x1f388;收藏⭐再看&#x1f4ab;养成习惯 ✨希望本文对您有所裨益&#xff0c;如有不足之处&#xff0c;欢迎在评论区提出…

首页最新 多IP浏览器防关联:如何配置多个独立且稳定的IP地址?

在互联网时代&#xff0c;IP地址的重要性不言而喻。然而&#xff0c;IP关联问题却成为一项令人担忧的隐私和安全挑战。针对这个问题&#xff0c;多IP浏览器是一种解决方案&#xff0c;可以帮助用户单独配置多个独立且稳定的IP地址&#xff0c;有效地防止IP关联。 一、IP关联是…

如何使用 ArcGIS Pro 查找小区最近的地铁站

学习 GIS 除了可以用在工作上之外&#xff0c;还可以将其运用到生活之中&#xff0c;比如查找距离小区最近的地铁站&#xff0c;这里为大家介绍一下查找的方法&#xff0c;希望能对你有所帮助。 数据来源 教程所使用的数据是从水经微图中下载的POI数据&#xff0c;除了POI数据…

【PyTorch与深度学习】4、PyTorch的Dataset与DataLoader详细使用教程

课程地址 最近做实验发现自己还是基础框架上掌握得不好&#xff0c;于是开始重学一遍PyTorch框架&#xff0c;这个是课程笔记&#xff0c;这个课还是讲的简略&#xff0c;我半小时的课听了一个半小时。 1. Dataset与DataLoader Dataset类是处理单个训练样本的&#xff0c;也就…

Jetson Orin NX L4T35.5.0平台LT6911芯片 调试记录(2)vi discarding frame问题调试

基于上篇调试记录 Jetson Orin NX L4T35.5.0平台LT6911芯片 调试记录(1)MIPI问题调试-CSDN博客 1.前言 当通过gstreamer持续捕获视频设备时,帧数会下降,并且I输入越高,丢失的帧数越多。 当达到4k30hz时,它完全无法使用,系统会在几秒钟的收集后崩溃并重新启动 4k30hz …

Stm32CubeMX 为 stm32mp135d 添加 adc

Stm32CubeMX 为 stm32mp135d 添加 adc 一、启用设备1. adc 设备添加2. adc 引脚配置2. adc 时钟配置 二、 生成代码1. optee 配置 adc 时钟和安全验证2. linux adc 设备 dts 配置 bringup 可参考&#xff1a; Stm32CubeMX 生成设备树 一、启用设备 1. adc 设备添加 启用adc设…

六氟化硫红外吸收峰

特此记录 anlog 2024年4月30日

标准汽车试验铁地板的技术要求

在现代科技化发展的工作中&#xff0c;试验平台被广泛使用。铸铁试验平台&#xff08;试验铁地板&#xff09;又叫试验工作平台&#xff0c;听名字大家也不难想象出来这是一款带有箱式体的台面&#xff0c;这是一种有长方形或者圆形又或者正方形的试验工作台。 铸铁试验平台&a…

Unity类银河恶魔城学习记录15-5,6 p157 Audio time limiter p158 Area sound

Alex教程每一P的教程原代码加上我自己的理解初步理解写的注释&#xff0c;可供学习Alex教程的人参考 此代码仅为较上一P有所改变的代码 【Unity教程】从0编程制作类银河恶魔城游戏_哔哩哔哩_bilibili​​ AreaSound.cs using System.Collections; using System.Collections.G…

一站式AI创作平台:融合GPT会话、GPTs应用、Midjourney视觉艺术与Suno AI音乐合成模块

一、系统简介 星河易创AI系统基于ChatGPT的核心技术打造&#xff0c;集成了自然语言问答和艺术创作功能。该系统兼容Midjourney绘画技术&#xff0c;并支持官方GPT模型。它提供了多样化的应用&#xff0c;包括GPTs的多场景应用、实时GPT语音对话能力、GPT-4模型的先进特性&…

测试腾讯云的高防CC抵御了攻击

网站需要安装防止CC攻击&#xff0c;因为CC攻击是一种常见的网络攻击&#xff0c;它会对网站造成严重的影响。 CC攻击&#xff08;Cybercrime Control&#xff09;是指向网站发起大量虚假请求的攻击&#xff0c;目的是使网站的资源耗尽&#xff0c;无法正常运行。CC攻击与DDoS…

浅谈 HTTPS

文章目录 HTTPS 简介HTTPS 特点与 HTTP 的区别HTTPS 工作流程1. 服务端生成密钥对2. 服务端申请数字证书3. 服务端发送数字证书4. 客户端验证数字证书5. 客户端解析证书内容6. 客户端传送加密信息7. 服务端解密信息8. 双方协商生成会话密钥并交换9. 使用会话密钥进行通信 总结 …

UNI-APP_拨打电话权限如何去掉,访问文件权限关闭

uniapp上架过程中一直提示&#xff1a;允许“app名”拨打电话和管理通话吗&#xff1f; uniapp配置文件&#xff1a;manifest.json “permissionPhoneState” : {“request” : “none”//拨打电话权限关闭 }, “permissionExternalStorage” : {“request” : “none”//访…

vue 前端读取Excel文件并解析

前端读取Excel文件并解析 前端如何解释Excel呢 平时项目中对于Excel的导入解析是很常见的功能&#xff0c;一般都是放在后端执行&#xff1b;但是也有特殊的情况&#xff0c;偶尔也有要求说前端执行解析&#xff0c;判空&#xff0c;校验等&#xff0c;最后组装成后端接口想要的…

JAVA前端快速入门基础_javascript入门(03)

写在前面:本文用于快速学会简易的JS&#xff0c;仅做扫盲和参考作用 本章节主要介绍JS的事件监听 1.什么是事件监听 事件:是指发生在HTML端的事件&#xff0c;主要指以下几种。 1.按钮被点击 2.鼠标移动到元素上 3.按到了键盘 事件监听:当触发了事件时&#xff0c;JS会执行相…

ECharts在网页中添加可视化图标-在网页中添加交互图表+option模块案列详解

一、引言 ECharts 是一个使用 JavaScript 编写的开源可视化库&#xff0c;它可以在浏览器中生成交互式的图表。无论是折线图、柱状图、散点图还是饼图&#xff0c;ECharts 都能轻松应对。本文将带领大家了解如何在网页中添加 ECharts 可视化图标。 本章可以直接跳到第五点完整…

Flask简介

Flask简介 安装概述使用PyCharm创建一个Flask程序 Flask程序的基本结构初始化路由和视图函数启动服务器请求-响应循环 安装 概述 Flask算是小型框架&#xff0c;小到可以称为“微框架”。Flask 非常小&#xff0c;因此你一旦能够熟练使用它&#xff0c;很可能就能读懂它所有的…

Copilot Workspace是GitHub对人工智能驱动的软件工程的诠释

软件开发的未来是人工智能驱动的集成开发环境吗&#xff1f;至少GitHub 是这样想的。 在今年初秋于旧金山举行的 GitHub Universe 年度大会之前&#xff0c;GitHub 发布了 Copilot Workspace&#xff0c;这是一种开发环境&#xff0c;利用 GitHub 所称的 “Copilot 驱动的代理…