06_Flutter自定义锚点分类列表

06_Flutter自定义锚点分类列表

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

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

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

  @override
  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();

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

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

  @override
  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]),
              );
            });
          },
        )
      )
    );
  }
}
二.组件基本布局
dart 复制代码
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;

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

}

class _AnchorCategoryListState extends State<AnchorCategoryList> {

  @override
  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完成了

dart 复制代码
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);

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

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

class RenderAfterLayout extends RenderProxyBox {

  AfterLayoutCallback callback;

  RenderAfterLayout(this.callback);

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

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

dart 复制代码
@override
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),
                      ),
                    ),
                  );
                }
              })
            ),
          ]
        )
      )
    ],
  );
}

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

dart 复制代码
@override
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的高度,保证最后能够滑动到最后一项。只需要在右侧列表添加一个空白区域即可。

dart 复制代码
@override
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})方法即可。

dart 复制代码
class _AnchorCategoryListState extends State<AnchorCategoryList> {

  ...

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

  @override
  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;
    });
  }

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

  @override
  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)方法更新选中状态即可。

dart 复制代码
class _AnchorCategoryListState extends State<AnchorCategoryList> {

  ...

  @override
  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);
    }
  }

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

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

}
六.控制标题项吸顶

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

dart 复制代码
class _AnchorCategoryListState extends State<AnchorCategoryList> {

  ...

  @override
  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
                      ...
                    }
                  })
                ),

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

}

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

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

class AnchorCategoryController extends ChangeNotifier {
  int selectedIndex = 0;

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

  @override
  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;

  @override
  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;

  @override
  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);
    }
  }

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

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

  @override
  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,
                  ),
                )
              ]
            ),
          )
        )
      ],
    );
  }

}
相关推荐
quanyechacsdn1 小时前
Android Studio创建库文件用jitpack构建后使用implementation方式引用
android·ide·kotlin·android studio·implementation·android 库文件·使用jitpack
程序员陆业聪2 小时前
聊聊2026年Android开发会是什么样
android
编程大师哥2 小时前
Android分层
android
极客小云3 小时前
【深入理解 Android 中的 build.gradle 文件】
android·安卓·安全架构·安全性测试
Juskey iii4 小时前
Android Studio Electric Eel | 2022.1.1 Patch 2 版本下载
android·ide·android studio
Android技术之家4 小时前
2025年度Android行业总结:AI驱动生态重构,跨端融合开启新篇
android·人工智能·重构
洞见前行4 小时前
Android第二代加固技术原理详解(附源码)
android
风清云淡_A4 小时前
【JetCompose】入门教程实战基础案例01之显隐动画
android
2501_916007474 小时前
iPhone APP 性能测试怎么做,除了Instruments还有什么工具?
android·ios·小程序·https·uni-app·iphone·webview
2501_915106324 小时前
Windows 环境下有哪些可用的 iOS 上架工具, iOS 上架工具的使用方式
android·ios·小程序·https·uni-app·iphone·webview