赞
踩
这样的效果,大家在一些商超应用里,应该也看到过。接下来咱们就用Flutter一步一步的来实现。
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]), ); }); }, ) ) ); } }
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完成了
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获取并保存标题项、标题项对应子列表的高度
@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), ), ), ); } }) ), ] ) ) ], ); }
计算并保存右侧面板每一项选中时的初始滑动偏移量
@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的高度,保证最后能够滑动到最后一项。只需要在右侧列表添加一个空白区域即可。
@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})方法即可。
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)方法更新选中状态即可。
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组件实现分组列表吸顶效果。
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 ... } }) ), ... ] ), ) ) ], ); } }
搞定,模拟器录屏掉帧了,改用真机录屏
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。