Flutter - 8 :一个附带手势刷新与自动加载的列表
友情提示 : 这个仅仅只是做出来看的,其中有些东西也是直接定死的,用到的东西可能会对其他人有些许提示效果,然而并不能保证这个东西一定不会出现错误。
列表这种东西在移动设备上还是很必要的,毕竟屏幕就那么大,数据多了,肯定要进行手势滑动,随之而来的就是手势刷新与加载,其中,数据的刷新是必须要用户触发的,加载则不是,毕竟每次拉到底要用户再下拉一次,行为上来说,并不合适,毕竟要把使用人当成傻子。
所以下面定义的列表,下拉刷新需要用户触发,至于加载则是自动加载,只有在出现错误时才能再进行上拉触发加载行为,如果没有更多的数据了,那么也同样不会触发上拉加载行为。
以下为简单展示图:
第一步:
首先需要定义几个状态值,对滑动过程中的几种不同的状态进行区分,以及动画时间等等基础变量
// 滑动状态值
const int Init = 1;
const int Ready = 2;
// 刷新状态值
const int CouldRefresh = 3;
const int ReadyToRefresh = 4;
const int Refreshing = 5;
// 加载状态值
const int CouldLoad = 6;
const int ReadyToLoad = 7;
const int Loading = 8;
// 结果状态值
const int Success = -1;
const int End = -2;
const int Error = -3;
// 动画相关
const double animateStartPosition = 0.01;
const int animateTime = 1250;
// 刷新完成后,顶部Item停留的时间
const int headerDelayTime = 1250;
第二步:
手势操作中,核心就是对用户的操作进行判定,进而触发不一样的效果,对于列表而言,NotificationListener
就是用来反馈用户行为的回调控件,在ListView
的外面加一层就能够收到用户的滑动回调信息,用于判定滑动的起点,终点,距离等等。剩下的就是对这些行为进行区分判定了,除了繁复一点,其实也并不困难。下面的代码当中有对判定的注释。
class CustomRefresher extends StatefulWidget {
int itemCount;
final IndexedWidgetBuilder itemBuilder;
final ActiveCallBack headActives;
final ActiveCallBack footActives;
final Axis scrollDirection;
final bool reverse;
final ScrollController controller;
final bool primary;
final ScrollPhysics physics;
final bool shrinkWrap;
final EdgeInsetsGeometry padding;
ResaultState loadState; // 控件加载状态
CustomRefresher(
{@required this.itemCount,
@required this.itemBuilder,
@required this.headActives,
@required this.footActives,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.controller,
this.primary,
this.physics,
this.shrinkWrap = false,
this.padding,
this.loadState});
@override
State<StatefulWidget> createState() {
return _CustomRefresherState();
}
}
class _CustomRefresherState extends State<CustomRefresher>
with TickerProviderStateMixin {
final double trigerLength = 20.0;
final double activeLength = 100.0;
StateSetter _headStateSetter;
StateSetter _footStateSetter;
double _startMetricsPosition = 0.0;
double _startDragPosition = 0.0;
double _currentDragPosition = 0.0;
ResaultState _refreshState = ResaultState.init; // 控件刷新状态
ResaultState _loadState = ResaultState.init; // 控件加载状态
int _scrollState = Init;
final Tween<double> sizeTween = Tween(begin: animateStartPosition, end: 1.0);
AnimationController _controller;
Animation<double> _animation;
int _newDataCount = 0;
@override
void initState() {
super.initState();
_controller =
AnimationController(duration: Duration(milliseconds: 250), vsync: this);
_animation = sizeTween
.animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
if(widget.loadState != null){
_loadState = widget.loadState;
widget.loadState = null;
}
}
@override
void didUpdateWidget(CustomRefresher oldWidget) {
super.didUpdateWidget(oldWidget);
if(widget.loadState != null){
_loadState = widget.loadState;
widget.loadState = null;
}
}
@override
void dispose() {
super.dispose();
_controller.dispose();
}
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
child: ListView.builder(
itemCount: (widget.itemCount + 2),
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
controller: widget.controller,
primary: widget.primary,
physics: (widget.physics != null
? widget.physics
: CustomBuncingScrollPhysicis(
parent: AlwaysScrollableScrollPhysics())),
shrinkWrap: widget.shrinkWrap,
padding: widget.padding,
itemBuilder: itemBuilder,
),
onNotification: onScrollNotificated,
);
}
// 列表item
Widget itemBuilder(context, index) {
// 0号位刷新header
if (index == 0) {
return getRefreshHeader();
}
// max号位加载footer
if (index == (widget.itemCount + 1)) {
return getRefreshFooter();
}
// 正常的item
return widget.itemBuilder(context, (index - 1));
}
// 获取刷新header
Widget getRefreshHeader() {
return SizeTransition(
sizeFactor: _animation,
child: Material(
color: Colors.black12,
child: SizedBox(
height: 48.0,
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
_headStateSetter = setState;
int headerState;
if (_scrollState == Init) {
switch (_refreshState) {
case ResaultState.init:
headerState = Init;
break;
case ResaultState.success:
headerState = Success;
break;
case ResaultState.end:
headerState = End;
break;
case ResaultState.error:
headerState = Error;
break;
}
} else {
headerState = _scrollState;
}
return getRefreshWidget(headerState);
}),
),
),
);
}
// 获取加载footer
Widget getRefreshFooter() {
return StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
_footStateSetter = setState;
int footerState;
if (_scrollState == Init) {
switch (_loadState) {
case ResaultState.end:
footerState = End;
break;
case ResaultState.error:
footerState = Error;
break;
default:
footerState = Loading;
_scrollState = Loading;
break;
}
} else {
footerState = _scrollState;
}
return Material(
color: Colors.transparent,
child: SizedBox(
height: 48.0,
child: getLoadingWidget(footerState),
),
);
});
}
// 刷新结束
void onRefreshEnd(ActiveResault resaule) {
_scrollState = Init;
_refreshState = resaule.state;
switch (resaule.state) {
case ResaultState.init:
_headStateSetter(() {
_controller.value = animateStartPosition;
});
break;
case ResaultState.success:
setState(() {
_newDataCount = resaule.dataCount - widget.itemCount;
widget.itemCount = resaule.dataCount;
Future.delayed(Duration(milliseconds: headerDelayTime), () {
_headStateSetter(() {
_refreshState = ResaultState.init;
_controller.reverse();
});
});
});
break;
case ResaultState.end:
_headStateSetter(() {
Future.delayed(Duration(milliseconds: headerDelayTime), () {
_headStateSetter(() {
_refreshState = ResaultState.init;
_controller.reverse();
});
});
});
break;
case ResaultState.error:
_headStateSetter(() {
Future.delayed(Duration(milliseconds: headerDelayTime), () {
_headStateSetter(() {
_refreshState = ResaultState.init;
_controller.reverse();
});
});
});
break;
}
}
// 加载结束
void onLoadEnd(ActiveResault resaule) {
_scrollState = Init;
if (ResaultState.success == resaule.state) {
setState(() {
_newDataCount = resaule.dataCount - widget.itemCount;
widget.itemCount = resaule.dataCount;
_loadState = ResaultState.success;
});
} else {
_footStateSetter(() {
_loadState = resaule.state;
});
}
}
// 滑动事件监听
bool onScrollNotificated(ScrollNotification notification) {
switch (notification.runtimeType) {
case ScrollStartNotification:
startDragCheck(notification);
break;
case ScrollUpdateNotification:
updateDragCheck(notification);
break;
}
return false;
}
// 检测起始点 --- 确定状态
void startDragCheck(ScrollStartNotification notification) {
if (_scrollState == Init) {
_startMetricsPosition = notification.metrics.extentBefore;
_startDragPosition = notification.dragDetails.globalPosition.dy;
_scrollState = Ready;
}
}
// 确定当前滑动时的状态
void updateDragCheck(ScrollUpdateNotification notification) {
switch (_scrollState) {
case Ready:
// 起始滑动距顶部小于触发距离 && 滑动方向向下,判定为可以触发刷新状态
if (_startMetricsPosition < trigerLength &&
notification.dragDetails != null) {
if (_startDragPosition < notification.dragDetails.globalPosition.dy) {
Logs.p("couldRefresh");
// 刷新header出现
_headStateSetter(() {
_scrollState = CouldRefresh;
_controller.forward();
});
return;
}
}
// 起始滑动距底部小于触发距离 && 滑动方向向上,判定为可以触发加载状态
if ((_startMetricsPosition + trigerLength) >
notification.metrics.maxScrollExtent &&
notification.dragDetails != null &&
_loadState != ResaultState.end) {
if (_startDragPosition > notification.dragDetails.globalPosition.dy) {
Logs.p("couldLoad");
_footStateSetter(() {
_scrollState = CouldLoad;
});
return;
}
}
// 两次判定均为false --- 取消ready状态
_scrollState = Init;
Logs.p(_controller.value);
if (_controller.value != animateStartPosition) {
_headStateSetter(() {
_controller.reverse();
});
}
break;
case CouldRefresh:
if (notification.dragDetails != null) {
// 判定拖动距离 --- 与起始点距离大于激活距离时触发刷新状态
_currentDragPosition = notification.dragDetails.globalPosition.dy;
double dragLength = _currentDragPosition - _startDragPosition;
if (dragLength > activeLength) {
Logs.p("readyRefresh");
_headStateSetter(() {
_scrollState = ReadyToRefresh;
});
} else if (dragLength < 0.0) {
// 刷新header显示
_headStateSetter(() {
_scrollState = Init;
_controller.reverse();
});
}
} else {
// 刷新header显示,并且处于松开状态
if (_controller.value != 0.0) {
_headStateSetter(() {
_scrollState = Init;
_controller.reverse();
});
}
}
break;
case ReadyToRefresh:
if (notification.dragDetails != null) {
// 判定拖动距离 --- 与起始距离小于激活距离时取消加载状态
_currentDragPosition = notification.dragDetails.globalPosition.dy;
double dragLength = _currentDragPosition - _startDragPosition;
if (dragLength <= activeLength) {
Logs.p("couldRefresh");
_headStateSetter(() {
_scrollState = CouldRefresh;
});
}
} else {
// 松开时的距离大于激活距离,执行加载
Logs.p("Refreshing");
_headStateSetter(() {
_scrollState = Refreshing;
});
}
break;
case CouldLoad:
if (notification.dragDetails != null) {
_currentDragPosition = notification.dragDetails.globalPosition.dy;
// 判定拖动距离 --- 与起始点距离大于激活距离时触发加载状态
if (_startDragPosition - _currentDragPosition > activeLength) {
Logs.p("readyLoad");
_footStateSetter(() {
_scrollState = ReadyToLoad;
});
}
} else {
_footStateSetter(() {
_scrollState = Init;
});
}
break;
case ReadyToLoad:
if (notification.dragDetails != null) {
_currentDragPosition = notification.dragDetails.globalPosition.dy;
// 判定拖动距离 --- 与起始距离小于激活距离时取消加载状态
if (_startDragPosition - _currentDragPosition <= activeLength) {
Logs.p("couldLoad");
_footStateSetter(() {
_scrollState = CouldLoad;
});
}
} else {
// 松开时的距离大于激活距离,执行加载
if (_startDragPosition - _currentDragPosition > activeLength) {
Logs.p("loading");
_footStateSetter(() {
_scrollState = Loading;
});
}
}
break;
default:
break;
}
}
// 获取刷新header控件组
Widget getRefreshWidget(int refreshState) {
switch (refreshState) {
case Refreshing:
// 执行传入的刷新方法
Future.delayed(Duration(milliseconds: animateTime), () {
widget.headActives().then(onRefreshEnd);
});
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
SizedBox(
width: 28.0,
height: 28.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
)),
Padding(
padding: EdgeInsets.only(left: 8.0),
child: Text("加载中...",
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.normal,
color: Colors.blue)),
)
],
);
case CouldRefresh:
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(
Icons.arrow_downward,
color: Colors.grey,
size: 24.0,
),
Text("下拉刷新数据!",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.normal,
color: Colors.blue,
)),
],
);
case ReadyToRefresh:
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(
Icons.arrow_upward,
color: Colors.grey,
size: 24.0,
),
Text("放开加载!",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.normal,
color: Colors.blue,
)),
],
);
case Success:
return Center(
child: Text("成功刷新${_newDataCount}条数据!",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.normal,
color: Colors.blue,
)),
);
case End:
return Center(
child: Text("数据已是最新!",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.normal,
color: Colors.blue,
)),
);
case Error:
return Center(
child: Text("数据刷新失败!",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.normal,
color: Colors.red,
)),
);
default:
return Material(
color: Colors.transparent,
);
}
}
// 获取加载footer控件组
Widget getLoadingWidget(int loadState) {
switch (loadState) {
case Loading:
// 执行传入的加载方法
Future.delayed(Duration(milliseconds: animateTime), () {
widget.footActives().then(onLoadEnd);
});
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
SizedBox(
width: 28.0,
height: 28.0,
child: CircularProgressIndicator(
strokeWidth: 2.0,
)),
Padding(
padding: EdgeInsets.only(left: 8.0),
child: Text("加载中...",
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.normal,
color: Colors.blue)),
)
],
);
case CouldLoad:
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(
Icons.arrow_upward,
color: Colors.grey,
size: 24.0,
),
Padding(
padding: EdgeInsets.only(left: 8.0),
child: Text("上拉加载更多!",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.normal,
color: Colors.blue,
))),
],
);
case ReadyToLoad:
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(
Icons.arrow_downward,
color: Colors.grey,
size: 24.0,
),
Padding(
padding: EdgeInsets.only(left: 8.0),
child: Text("放开加载!",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.normal,
color: Colors.blue,
))),
],
);
case End:
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
SizedBox(
width: 56.0,
child: Divider(
height: 2.0,
color: Colors.grey,
),
),
Padding(
padding: EdgeInsets.only(left: 6.0, right: 6.0),
child: Text(
"没有更多数据了!",
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.normal,
color: Colors.grey),
),
),
SizedBox(
width: 56.0,
child: Divider(
height: 2.0,
color: Colors.grey,
),
)
],
);
case Error:
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text("加载出错!",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.normal,
color: Colors.red,
)),
Text("上拉加载更多!",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 18.0,
fontWeight: FontWeight.bold,
fontStyle: FontStyle.normal,
color: Colors.red,
))
],
);
default:
return Material(
color: Colors.transparent,
);
}
}
}
第三步:
定义刷新与加载方法,以及加载状态的枚举,用来在刷新控件时通知底部item加载需要的布局以及手势操作。
typedef ActiveCallBack = Future<ActiveResault> Function();
enum ResaultState { init, success, end, error }
class ActiveResault {
final ResaultState state;
final int dataCount;
ActiveResault(this.state, {this.dataCount});
}
第四步:
当前控件中,用的是BouncingScrollPhysics
,然而,这个滑动物理效果本身是不能满足需求的,毕竟一旦进行刷新操作,顶部会出现一大块空白,所以需要自定义修改一下,直接继承,然后修改一下在滑动到顶端时的返回值就可以了。
class CustomBuncingScrollPhysicis extends BouncingScrollPhysics {
const CustomBuncingScrollPhysicis({ScrollPhysics parent})
: super(parent: parent);
@override
BouncingScrollPhysics applyTo(ScrollPhysics ancestor) {
return CustomBuncingScrollPhysicis(parent: buildParent(ancestor));
}
@override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
assert(offset != 0.0);
assert(position.minScrollExtent <= position.maxScrollExtent);
if (!position.outOfRange) return offset;
// 缩小比例 --- 处于顶部时,滑动距离为正常状态的1/10
double percent = 1.0;
if (position.extentBefore <= 20.0) {
percent = 0.1;
}
final double overscrollPastStart =
math.max(position.minScrollExtent - position.pixels, 0.0);
final double overscrollPastEnd =
math.max(position.pixels - position.maxScrollExtent, 0.0);
final double overscrollPast =
math.max(overscrollPastStart, overscrollPastEnd);
final bool easing = (overscrollPastStart > 0.0 && offset < 0.0) ||
(overscrollPastEnd > 0.0 && offset > 0.0);
final double friction = easing
// Apply less resistance when easing the overscroll vs tensioning.
? frictionFactor(
(overscrollPast - offset.abs()) / position.viewportDimension)
: frictionFactor(overscrollPast / position.viewportDimension);
final double direction = offset.sign;
return direction *
_applyFriction(overscrollPast, offset.abs(), friction) *
percent;
}
static double _applyFriction(
double extentOutside, double absDelta, double gamma) {
assert(absDelta > 0);
double total = 0.0;
if (extentOutside > 0) {
final double deltaToLimit = extentOutside / gamma;
if (absDelta < deltaToLimit) return absDelta * gamma;
total += extentOutside;
absDelta -= deltaToLimit;
}
return total + absDelta;
}
}
第五步:
最后附加开始的gif中展示的测试代码:
void main() {
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp])
.then((Null) {
runApp(ForFun());
});
}
class ForFun extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ForFun',
theme: ThemeData(
fontFamily: "hwxw",
primaryColorDark: Colors.blueAccent,
primaryColor: Colors.blue,
primaryColorLight: Colors.lightBlue,
primarySwatch: Colors.blue,
),
home: TestListPage(),
);
}
}
class TestListPage extends StatelessWidget {
final List<String> list = [
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14",
"15",
"16",
"17",
"18",
"19",
"20",
"21",
];
int count = 3;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.red,
child: CustomRefresher(
itemCount: count,
itemBuilder: itemBuilder,
headActives: () async {
int newElement_1 = int.parse(list[0]) - 1;
list.insert(0, newElement_1.toString());
int newElement_2 = int.parse(list[0]) - 1;
list.insert(0, newElement_2.toString());
int newElement_3 = int.parse(list[0]) - 1;
list.insert(0, newElement_3.toString());
count = count + 3;
return ActiveResault(ResaultState.success,dataCount: count);
},
footActives: () async {
if (count < 6) {
count += 2;
return ActiveResault(ResaultState.success, dataCount: count);
} else {
return ActiveResault(ResaultState.end);
}
},
));
}
Widget itemBuilder(BuildContext context, int index) {
return Padding(
padding: EdgeInsets.only(left: 12.0, top: 4.0, right: 12.0, bottom: 4.0),
child: Material(
elevation: 2.0,
borderRadius: BorderRadius.all(Radius.circular(2.0)),
child: SizedBox(
height: 64.0,
child: Padding(
padding: EdgeInsets.all(8.0),
child: Center(
child: Text(list[index]),
),
),
),
),
);
}
}
本集完!