218 lines
6.6 KiB
Dart
218 lines
6.6 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 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;
|
|
final newOffset = (trackHeight * scrollRatio).clamp(0.0, trackHeight);
|
|
|
|
if (_thumbOffset != newOffset) {
|
|
setState(() {
|
|
_thumbOffset = newOffset;
|
|
});
|
|
if (_fadeTimer != null && _fadeTimer!.isActive) {
|
|
_fadeTimer?.cancel();
|
|
}
|
|
_fadeController.forward();
|
|
_fadeTimer = Timer(widget.fadeDelay, () {
|
|
if (mounted) {
|
|
_fadeController.reverse();
|
|
} else {
|
|
_fadeTimer?.cancel();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.controller.removeListener(_updateThumbPosition);
|
|
_fadeController.dispose();
|
|
_fadeTimer?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return LayoutBuilder(
|
|
builder: (_, constraints) {
|
|
final position =
|
|
widget.controller.hasClients ? widget.controller.position : null;
|
|
|
|
if (position == null || !position.hasContentDimensions) {
|
|
return widget.child;
|
|
}
|
|
|
|
final maxScrollExtent = position.maxScrollExtent;
|
|
final canShowScrollbar = widget.isShowScrollbar && maxScrollExtent > 0;
|
|
|
|
return Stack(
|
|
children: [
|
|
NotificationListener<ScrollNotification>(
|
|
onNotification: _onScrollNotification,
|
|
child: widget.child,
|
|
),
|
|
if (canShowScrollbar)
|
|
AnimatedBuilder(
|
|
animation: _fadeController,
|
|
builder: (context, child) {
|
|
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),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|