228 lines
6.9 KiB
Dart
228 lines
6.9 KiB
Dart
import 'dart:async';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/scheduler.dart';
|
||
import 'package:flutter/rendering.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
|
||
class HiFixedScrollbar extends StatefulWidget {
|
||
final Widget child;
|
||
final ScrollController controller;
|
||
|
||
final bool isShowScrollbar;
|
||
final double right;
|
||
final double thickness;
|
||
final Color thumbColor;
|
||
final Color trackColor;
|
||
final double thumbHeight;
|
||
final Duration fadeDuration;
|
||
final Duration fadeDelay;
|
||
|
||
const HiFixedScrollbar({
|
||
super.key,
|
||
required this.child,
|
||
required this.controller,
|
||
this.right = 18,
|
||
this.isShowScrollbar = true,
|
||
this.thickness = 5,
|
||
this.thumbHeight = 50,
|
||
this.thumbColor = const Color.fromRGBO(255, 255, 255, 0.3),
|
||
this.trackColor = const Color.fromRGBO(255, 255, 255, 0.15),
|
||
this.fadeDuration = const Duration(milliseconds: 300),
|
||
this.fadeDelay = const Duration(milliseconds: 500),
|
||
});
|
||
|
||
@override
|
||
State<HiFixedScrollbar> createState() => _HiFixedScrollbarState();
|
||
}
|
||
|
||
class _HiFixedScrollbarState extends State<HiFixedScrollbar>
|
||
with SingleTickerProviderStateMixin {
|
||
double _thumbOffset = 0.0;
|
||
late AnimationController _fadeController;
|
||
Timer? _fadeTimer;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_fadeController = AnimationController(
|
||
vsync: this,
|
||
duration: widget.fadeDuration,
|
||
);
|
||
// 1. 监听位置变化 (仅更新 _thumbOffset)
|
||
widget.controller.addListener(_updateThumbPosition);
|
||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
if (widget.controller.hasClients) {
|
||
_updateThumbPosition();
|
||
}
|
||
});
|
||
}
|
||
|
||
@override
|
||
void didUpdateWidget(covariant HiFixedScrollbar oldWidget) {
|
||
super.didUpdateWidget(oldWidget);
|
||
|
||
if (widget.controller != oldWidget.controller) {
|
||
// 移除旧控制器的位置监听
|
||
oldWidget.controller.removeListener(_updateThumbPosition);
|
||
// 添加新控制器的位置监听
|
||
widget.controller.addListener(_updateThumbPosition);
|
||
_fadeTimer?.cancel();
|
||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
if (widget.controller.hasClients) {
|
||
_updateThumbPosition();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
bool _onScrollNotification(ScrollNotification notification) {
|
||
if (!mounted) return false;
|
||
|
||
if (_fadeTimer != null && _fadeTimer!.isActive) {
|
||
_fadeTimer?.cancel();
|
||
}
|
||
|
||
if (notification is ScrollStartNotification ||
|
||
notification is ScrollUpdateNotification) {
|
||
_fadeController.forward();
|
||
_updateThumbPosition();
|
||
} else if (notification is ScrollMetricsNotification) {
|
||
// 仅在最大滚动范围变化时更新位置且保持可见
|
||
// 避免不必要的闪烁
|
||
_updateThumbPosition();
|
||
} else if (notification is ScrollEndNotification ||
|
||
(notification is UserScrollNotification &&
|
||
notification.direction == ScrollDirection.idle)) {
|
||
_fadeTimer = Timer(widget.fadeDelay, () {
|
||
if (mounted) {
|
||
_fadeController.reverse();
|
||
} else {
|
||
_fadeTimer?.cancel();
|
||
}
|
||
});
|
||
}
|
||
return false;
|
||
}
|
||
|
||
void _updateThumbPosition() {
|
||
if (!mounted || !widget.controller.hasClients) return;
|
||
final position = widget.controller.position;
|
||
if (!position.hasPixels || !position.hasContentDimensions) return;
|
||
|
||
final maxScrollExtent = position.maxScrollExtent;
|
||
final offset = position.pixels;
|
||
final viewport = position.viewportDimension;
|
||
|
||
if (maxScrollExtent <= 0) {
|
||
if (_thumbOffset != 0.0) {
|
||
setState(() => _thumbOffset = 0.0);
|
||
}
|
||
if (_fadeController.status != AnimationStatus.dismissed) {
|
||
_fadeController.reverse();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 轨道总高度 = 视口高度 - 拇指高度
|
||
final trackHeight = viewport - widget.thumbHeight.h;
|
||
// 滚动比例
|
||
final scrollRatio = (offset / maxScrollExtent).clamp(0.0, 1.0);
|
||
final newOffset = trackHeight * scrollRatio;
|
||
|
||
if (_thumbOffset != newOffset) {
|
||
setState(() {
|
||
_thumbOffset = newOffset;
|
||
});
|
||
|
||
// 当位置发生变化时,保持显示一段时间
|
||
if (_fadeTimer != null && _fadeTimer!.isActive) {
|
||
_fadeTimer?.cancel();
|
||
}
|
||
_fadeController.forward();
|
||
_fadeTimer = Timer(widget.fadeDelay, () {
|
||
if (mounted) {
|
||
_fadeController.reverse();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
widget.controller.removeListener(_updateThumbPosition);
|
||
_fadeController.dispose();
|
||
_fadeTimer?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return NotificationListener<ScrollNotification>(
|
||
onNotification: _onScrollNotification,
|
||
child: Stack(
|
||
children: [
|
||
widget.child,
|
||
// 滚动条渲染逻辑移至内部,确保外部组件树(widget.child 的路径)稳定
|
||
AnimatedBuilder(
|
||
animation: _fadeController,
|
||
builder: (context, child) {
|
||
final position = widget.controller.hasClients
|
||
? widget.controller.position
|
||
: null;
|
||
|
||
if (position == null || !position.hasContentDimensions) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
final maxScrollExtent = position.maxScrollExtent;
|
||
final canShowScrollbar =
|
||
widget.isShowScrollbar && maxScrollExtent > 0;
|
||
|
||
if (!canShowScrollbar) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
return Opacity(
|
||
opacity: _fadeController.value,
|
||
child: Stack(
|
||
children: [
|
||
// 滚动条轨道 (Track)
|
||
Positioned(
|
||
right: widget.right.w.toDouble(),
|
||
top: 0,
|
||
bottom: 0,
|
||
child: Container(
|
||
width: widget.thickness.w.toDouble(),
|
||
decoration: BoxDecoration(
|
||
color: widget.trackColor,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
),
|
||
),
|
||
|
||
// 滚动条拇指 (Thumb)
|
||
Positioned(
|
||
right: widget.right.w.toDouble(),
|
||
top: _thumbOffset,
|
||
child: Container(
|
||
width: widget.thickness.w.toDouble(),
|
||
height: widget.thumbHeight.h.toDouble(),
|
||
decoration: BoxDecoration(
|
||
color: widget.thumbColor,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|