初始化
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/tools_base/refresh/pull_refresh.dart'; // 复用 CustomRefreshView 的手感调优
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
/// 横向列表"拉到底查看更多"组件(手感同上拉加载更多,只是横向)。
|
||||
///
|
||||
/// 正常不占位不显示,滑到末尾继续拉才从右侧冒出 footer,
|
||||
/// 拉过 [triggerDistance] 松手即触发 [onTrigger](跳页/加载等,由调用方决定)。
|
||||
///
|
||||
/// 触发以"松手瞬间是否仍过阈值"为准:拉出"释放查看"后又拖回阈值内再松手,不触发。
|
||||
///
|
||||
/// [child] 传横向滚动体(如 `scrollDirection: Axis.horizontal` 的 ListView)。
|
||||
/// 内部自建/释放 RefreshController,并用独立 RefreshConfiguration 兜住过拉距离,
|
||||
/// 嵌在竖向 SmartRefresher 里也不会被外层配置限制。
|
||||
class HorizontalLoadMore extends StatefulWidget {
|
||||
final Widget child;
|
||||
final VoidCallback onTrigger; // 拉过阈值松手触发
|
||||
final String idleText; // 未拉够文案
|
||||
final String releaseText; // 拉够文案
|
||||
final double triggerDistance; // 触发拉动距离(px)
|
||||
final double footerWidth; // footer 宽度
|
||||
|
||||
const HorizontalLoadMore({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.onTrigger,
|
||||
this.idleText = '查看\n更多',
|
||||
this.releaseText = '释放\n查看',
|
||||
this.triggerDistance = 50,
|
||||
this.footerWidth = 50,
|
||||
});
|
||||
|
||||
@override
|
||||
State<HorizontalLoadMore> createState() => _HorizontalLoadMoreState();
|
||||
}
|
||||
|
||||
class _HorizontalLoadMoreState extends State<HorizontalLoadMore> {
|
||||
final RefreshController _refreshCtr = RefreshController();
|
||||
final ValueNotifier<bool> _armed =
|
||||
ValueNotifier(false); // 当前过拉是否已过阈值(驱动 footer 文案 + 触发判定)
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_refreshCtr.dispose();
|
||||
_armed.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 拖动中持续更新是否已过阈值(公式同 SmartRefresher footer 的 offset 计算)
|
||||
bool _onScroll(ScrollNotification n) {
|
||||
if (n.metrics.axis != Axis.horizontal) return false;
|
||||
final double overscroll =
|
||||
n.metrics.pixels - n.metrics.maxScrollExtent; // 拉过末尾的距离
|
||||
_armed.value = overscroll >= widget.triggerDistance;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 手指抬起瞬间仍过阈值才触发(拖回阈值内松手不触发)。用原始指针事件最可靠,
|
||||
// 不依赖 SmartRefresher 的 canLoading/onLoading(受滚动物理影响、拖回会锁死)
|
||||
void _onPointerUp() {
|
||||
if (_armed.value) {
|
||||
_armed.value = false;
|
||||
Future.microtask(widget.onTrigger); // 避免在事件派发中直接 push 路由
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshConfiguration(
|
||||
// 手感对齐 CustomRefreshView:同一套回弹弹簧 + 拖拽速率 + 滚动行为
|
||||
springDescription: kRefreshSpring,
|
||||
dragSpeedRatio: kRefreshDragSpeedRatio,
|
||||
maxOverScrollExtent: 60,
|
||||
maxUnderScrollExtent: 150, // 竖向那套是0,横向靠拖动触发需给足距离
|
||||
enableBallisticLoad: false, // 不靠惯性,只松手触发
|
||||
footerTriggerDistance: widget.triggerDistance,
|
||||
child: ScrollConfiguration(
|
||||
behavior: NoStretchScrollBehavior(), // BouncingPhysics/去拉伸,同竖向
|
||||
child: Listener(
|
||||
onPointerUp: (_) => _onPointerUp(),
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScroll,
|
||||
child: SmartRefresher(
|
||||
controller: _refreshCtr,
|
||||
scrollDirection: Axis.horizontal,
|
||||
enablePullDown: false,
|
||||
enablePullUp: true,
|
||||
footer: CustomFooter(
|
||||
// 正常不占位不显示,只有滑到末尾继续拉才冒出来
|
||||
loadStyle: LoadStyle.HideAlways,
|
||||
builder: (_, __) => ValueListenableBuilder<bool>(
|
||||
valueListenable: _armed,
|
||||
builder: (_, armed, ___) => _footer(armed),
|
||||
),
|
||||
),
|
||||
onLoading: () =>
|
||||
_refreshCtr.loadComplete(), // 触发已由指针事件把关,这里只复位 footer
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 过阈值(armed)时变"释放"文案、箭头翻转
|
||||
Widget _footer(bool armed) {
|
||||
return Container(
|
||||
width: widget.footerWidth,
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
AnimatedRotation(
|
||||
turns: armed ? 0.5 : 0,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child:
|
||||
Image.asset("arrow_right_grey.webp".commonImgPath, width: 18),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
armed ? widget.releaseText : widget.idleText,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Color(0x59FFFFFF), fontSize: 10, height: 1.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
// 回弹弹簧:临界阻尼(≈2√(stiffness·mass)=34.6) → 松手快速收回不过冲
|
||||
// 注:实测此调优手感优于 3.16.8 包默认(mass2.2/stiffness150/damping16,欠阻尼、回弹更夸张),
|
||||
// 故采用这套调优值,不复刻 3.16.8 原值
|
||||
// 提成常量供横向的 HorizontalLoadMore 复用,两处手感必须一致,改这里即全改
|
||||
const SpringDescription kRefreshSpring =
|
||||
SpringDescription(mass: 1.5, stiffness: 200, damping: 35);
|
||||
const double kRefreshDragSpeedRatio = 0.91;
|
||||
|
||||
Widget pullYsRefresh({
|
||||
required RefreshViewOnInit onInit,
|
||||
required Widget child,
|
||||
Key? key,
|
||||
final Function(RefreshController? ctr)? onRefresh,
|
||||
final Function(RefreshController? ctr)? onLoading,
|
||||
bool enablePullUp = true,
|
||||
bool enablePullDown = true,
|
||||
String noDataText = "—— 没有更多数据了 ——",
|
||||
Widget? customHeader,
|
||||
Widget? footerHeader,
|
||||
}) {
|
||||
return CustomRefreshView(
|
||||
onInit: onInit,
|
||||
onLoading: onLoading,
|
||||
onRefresh: onRefresh,
|
||||
customHeader: customHeader,
|
||||
footerHeader: footerHeader,
|
||||
enablePullDown: enablePullDown,
|
||||
enablePullUp: enablePullUp,
|
||||
noDataText: noDataText,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
typedef RefreshViewOnInit = Function(RefreshController controller);
|
||||
|
||||
class CustomRefreshView extends StatefulWidget {
|
||||
final bool enablePullDown;
|
||||
final bool enablePullUp;
|
||||
final Function(RefreshController? ctr)? onRefresh;
|
||||
final Function(RefreshController? ctr)? onLoading;
|
||||
final Widget? customHeader;
|
||||
final Widget? footerHeader;
|
||||
final Widget child;
|
||||
final String noDataText;
|
||||
final RefreshViewOnInit onInit;
|
||||
|
||||
const CustomRefreshView({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.onInit,
|
||||
this.enablePullDown = true,
|
||||
this.enablePullUp = true,
|
||||
this.onRefresh,
|
||||
this.onLoading,
|
||||
this.customHeader,
|
||||
this.footerHeader,
|
||||
this.noDataText = "—— 没有更多数据了 ——",
|
||||
});
|
||||
|
||||
@override
|
||||
State<CustomRefreshView> createState() => _CustomIRefreshViewState();
|
||||
}
|
||||
|
||||
class _CustomIRefreshViewState extends State<CustomRefreshView> {
|
||||
final refreshCtr = RefreshController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
widget.onInit(refreshCtr);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
//组件创建的 controller 由组件释放(谁创建谁释放)
|
||||
refreshCtr.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RefreshConfiguration(
|
||||
springDescription: kRefreshSpring,
|
||||
dragSpeedRatio: kRefreshDragSpeedRatio,
|
||||
maxOverScrollExtent: 60,
|
||||
maxUnderScrollExtent: 0,
|
||||
enableBallisticRefresh: false, // 下拉不靠惯性触发刷新
|
||||
enableBallisticLoad: true, // 上拉滑到底自动加载
|
||||
//去掉 StretchingOverscrollIndicator,避免与下拉刷新冲突
|
||||
child: ScrollConfiguration(
|
||||
behavior: NoStretchScrollBehavior(),
|
||||
child: SmartRefresher(
|
||||
enablePullDown: widget.enablePullDown,
|
||||
enablePullUp: widget.enablePullUp,
|
||||
controller: refreshCtr,
|
||||
onRefresh: () => widget.onRefresh?.call(refreshCtr),
|
||||
onLoading: () => widget.onLoading?.call(refreshCtr),
|
||||
header: widget.customHeader ??
|
||||
WaterDropHeader(
|
||||
waterDropColor: AppColors.actionRed,
|
||||
// iOS 刷新菊花用主题色;Android 保持默认 loading
|
||||
refresh: Platform.isIOS
|
||||
? const CupertinoActivityIndicator(
|
||||
color: AppColors.actionRed)
|
||||
: null,
|
||||
complete: const Text(
|
||||
"刷新完成!",
|
||||
style: TextStyle(
|
||||
color: AppColors.tipTextColor99, fontSize: 12),
|
||||
),
|
||||
failed: const Text(
|
||||
"刷新失敗!",
|
||||
style: TextStyle(
|
||||
color: AppColors.tipTextColor99, fontSize: 12),
|
||||
),
|
||||
),
|
||||
footer: widget.footerHeader ??
|
||||
ClassicFooter(
|
||||
loadingText: "加载中...",
|
||||
canLoadingText: "松开加载更多...",
|
||||
noDataText: widget.noDataText,
|
||||
idleText: "上拉加载更多",
|
||||
// Android 默认转圈是蓝色,改成主题色;iOS 保持菊花
|
||||
loadingIcon: SizedBox(
|
||||
width: 25,
|
||||
height: 25,
|
||||
child: Platform.isIOS
|
||||
? const CupertinoActivityIndicator()
|
||||
: const CircularProgressIndicator(
|
||||
strokeWidth: 2.0,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation(AppColors.actionRed),
|
||||
),
|
||||
),
|
||||
textStyle: TextStyle(
|
||||
color: AppColors.tipTextColor99,
|
||||
fontSize: 12,
|
||||
decoration: TextDecoration.none,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
child: widget.child),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
//还原 Flutter 3.16 时代的滚动行为
|
||||
class NoStretchScrollBehavior extends MaterialScrollBehavior {
|
||||
//去掉 StretchingOverscrollIndicator,避免与下拉刷新冲突
|
||||
@override
|
||||
Widget buildOverscrollIndicator(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
ScrollableDetails details,
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
|
||||
//还原旧版多指拖拽策略(3.19+ 改为 latestPointer,拖拽手感有细微差异)
|
||||
@override
|
||||
MultitouchDragStrategy getMultitouchDragStrategy(BuildContext context) {
|
||||
return MultitouchDragStrategy.averageBoundaryPointers;
|
||||
}
|
||||
|
||||
//使用 BouncingScrollPhysics 还原弹性下拉手感
|
||||
@override
|
||||
ScrollPhysics getScrollPhysics(BuildContext context) {
|
||||
return const BouncingScrollPhysics();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user