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