初始化
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:mobkit_dashed_border/mobkit_dashed_border.dart';
|
||||
|
||||
class AddMediaSourceButton extends StatelessWidget {
|
||||
final bool isVideo;
|
||||
final Function() onTap;
|
||||
final double width;
|
||||
final double height;
|
||||
final double radius;
|
||||
final Color borderColor;
|
||||
final Color backgroundColor;
|
||||
final String? title;
|
||||
|
||||
AddMediaSourceButton({
|
||||
super.key,
|
||||
this.isVideo = false,
|
||||
required this.onTap,
|
||||
this.width = 111,
|
||||
this.height = 111,
|
||||
this.radius = 10,
|
||||
this.title = '添加视频',
|
||||
this.borderColor = const Color(0x3cffffff),
|
||||
this.backgroundColor = Colors.white,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = this.title ?? (isVideo ? '添加视频' : '添加图片');
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: height,
|
||||
width: width,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x0Dffffff),
|
||||
border: DashedBorder.fromBorderSide(
|
||||
dashLength: 2,
|
||||
side: BorderSide(color: borderColor, width: 1),
|
||||
),
|
||||
borderRadius: BorderRadius.all(Radius.circular(radius)),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
((111 - 24) / 2).sizeBoxH,
|
||||
Image.asset(
|
||||
'add_grey.png'.communityPath,
|
||||
width: 24,
|
||||
color: const Color(0xffDCDCDC),
|
||||
),
|
||||
9.sizeBoxH,
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: const Color(0xff999999),
|
||||
fontSize: 14.sp,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/// This library is for swiper
|
||||
|
||||
//修改源码,适配库与pageview滚动冲突
|
||||
library card_swiper;
|
||||
|
||||
export 'src/flutter_page_indicator/flutter_page_indicator.dart';
|
||||
export 'src/swiper.dart';
|
||||
export 'src/swiper_control.dart';
|
||||
export 'src/swiper_controller.dart';
|
||||
export 'src/swiper_pagination.dart';
|
||||
export 'src/swiper_plugin.dart';
|
||||
export 'src/transformer_page_view/index_controller.dart';
|
||||
@@ -0,0 +1,456 @@
|
||||
part of 'swiper.dart';
|
||||
|
||||
abstract class _CustomLayoutStateBase<T extends _SubSwiper> extends State<T> with SingleTickerProviderStateMixin {
|
||||
late double _swiperWidth;
|
||||
late double _swiperHeight;
|
||||
late Animation<double> _animation;
|
||||
late AnimationController _animationController;
|
||||
SwiperController get _controller => widget.controller;
|
||||
late int _startIndex;
|
||||
int? _animationCount;
|
||||
int _currentIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_currentIndex = widget.index ?? 0;
|
||||
if (widget.itemWidth == null) {
|
||||
throw Exception(
|
||||
'==============\n\nwidget.itemWidth must not be null when use stack layout.\n========\n',
|
||||
);
|
||||
}
|
||||
|
||||
_createAnimationController();
|
||||
_controller.addListener(_onController);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _createAnimationController() {
|
||||
_animationController = AnimationController(vsync: this, value: 0.5);
|
||||
final tween = Tween(begin: 0.0, end: 1.0);
|
||||
_animation = tween.animate(_animationController);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_ambiguate(WidgetsBinding.instance)!.addPostFrameCallback(_getSize);
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
void _getSize(Duration _) {
|
||||
if (!mounted) return;
|
||||
afterRender();
|
||||
}
|
||||
|
||||
@mustCallSuper
|
||||
void afterRender() {
|
||||
final renderObject = context.findRenderObject()!;
|
||||
final size = renderObject.paintBounds.size;
|
||||
_swiperWidth = size.width;
|
||||
_swiperHeight = size.height;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(T oldWidget) {
|
||||
if (widget.controller != oldWidget.controller) {
|
||||
oldWidget.controller.removeListener(_onController);
|
||||
widget.controller.addListener(_onController);
|
||||
}
|
||||
|
||||
if (widget.loop != oldWidget.loop) {
|
||||
if (!widget.loop) {
|
||||
_currentIndex = _ensureIndex(_currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.axisDirection != oldWidget.axisDirection) {
|
||||
afterRender();
|
||||
}
|
||||
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
int _ensureIndex(int index) {
|
||||
var res = index;
|
||||
res = index % widget.itemCount;
|
||||
if (res < 0) {
|
||||
res += widget.itemCount;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onController);
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildItem(int i, int realIndex, double animationValue);
|
||||
|
||||
Widget _buildContainer(List<Widget> list) {
|
||||
return Stack(
|
||||
children: list,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnimation(BuildContext context, Widget? w) {
|
||||
final list = <Widget>[];
|
||||
|
||||
final animationValue = _animation.value;
|
||||
|
||||
for (var i = 0; i < _animationCount! && widget.itemCount > 0; ++i) {
|
||||
final itemIndex = _currentIndex + i + _startIndex;
|
||||
if (!widget.loop && (itemIndex >= widget.itemCount || itemIndex < 0)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var realIndex = itemIndex % widget.itemCount;
|
||||
if (realIndex < 0) {
|
||||
realIndex += widget.itemCount;
|
||||
}
|
||||
|
||||
if (widget.axisDirection == AxisDirection.right) {
|
||||
list.insert(0, _buildItem(i, realIndex, animationValue));
|
||||
} else {
|
||||
list.add(_buildItem(i, realIndex, animationValue));
|
||||
}
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onHorizontalDragStart: _onPanStart,
|
||||
onHorizontalDragEnd: _onPanEnd,
|
||||
onHorizontalDragUpdate: _onPanUpdate,
|
||||
child: ClipRect(
|
||||
child: Center(
|
||||
child: _buildContainer(list),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_animationCount == null) {
|
||||
return Container();
|
||||
}
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: _buildAnimation,
|
||||
);
|
||||
}
|
||||
|
||||
late double _currentValue;
|
||||
late double _currentPos;
|
||||
|
||||
bool _lockScroll = false;
|
||||
|
||||
Future<void> _move(double position, {int? nextIndex}) async {
|
||||
if (_lockScroll) return;
|
||||
try {
|
||||
_lockScroll = true;
|
||||
await _animationController.animateTo(
|
||||
position,
|
||||
duration: Duration(milliseconds: widget.duration!),
|
||||
curve: widget.curve,
|
||||
);
|
||||
if (nextIndex != null) {
|
||||
widget.onIndexChanged!(widget.getCorrectIndex(nextIndex));
|
||||
}
|
||||
} catch (e, st) {
|
||||
log('error animating _animationController', error: e, stackTrace: st);
|
||||
} finally {
|
||||
if (nextIndex != null) {
|
||||
try {
|
||||
_animationController.value = 0.5;
|
||||
} catch (e, st) {
|
||||
log(
|
||||
'error setting _animationController.value',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
_currentIndex = nextIndex;
|
||||
}
|
||||
_lockScroll = false;
|
||||
}
|
||||
}
|
||||
|
||||
int _getProperNewIndex(int newIndex) {
|
||||
var res = newIndex;
|
||||
if (!widget.loop && newIndex >= widget.itemCount - 1) {
|
||||
res = widget.itemCount - 1;
|
||||
} else if (!widget.loop && newIndex < 0) {
|
||||
res = 0;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
Future<void> _onController() async {
|
||||
final controller = widget.controller;
|
||||
final event = controller.event;
|
||||
if (event is StepBasedIndexControllerEvent) {
|
||||
final newIndex = event.calcNextIndex(
|
||||
currentIndex: _currentIndex,
|
||||
itemCount: widget.itemCount,
|
||||
loop: widget.loop,
|
||||
reverse: false,
|
||||
);
|
||||
if (_currentIndex == newIndex) return;
|
||||
return _move(event.targetPosition, nextIndex: newIndex);
|
||||
} else if (event is MoveIndexControllerEvent) {
|
||||
final newIndex = _getProperNewIndex(event.newIndex);
|
||||
if (_currentIndex == newIndex) return;
|
||||
return _move(event.targetPosition, nextIndex: newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onPanEnd(DragEndDetails details) async {
|
||||
if (_lockScroll) return;
|
||||
|
||||
final velocity = widget.scrollDirection == Axis.horizontal ? details.velocity.pixelsPerSecond.dx : details.velocity.pixelsPerSecond.dy;
|
||||
|
||||
if (_animationController.value >= 0.75 || velocity > 500.0) {
|
||||
if (_currentIndex <= 0 && !widget.loop) {
|
||||
return _move(0.5);
|
||||
}
|
||||
return _move(1.0, nextIndex: _currentIndex - 1);
|
||||
} else if (_animationController.value < 0.25 || velocity < -500.0) {
|
||||
if (_currentIndex >= widget.itemCount - 1 && !widget.loop) {
|
||||
return _move(0.5);
|
||||
}
|
||||
return _move(0.0, nextIndex: _currentIndex + 1);
|
||||
} else {
|
||||
return _move(0.5);
|
||||
}
|
||||
}
|
||||
|
||||
void _onPanStart(DragStartDetails details) {
|
||||
if (_lockScroll) return;
|
||||
_currentValue = _animationController.value;
|
||||
_currentPos = widget.scrollDirection == Axis.horizontal ? details.globalPosition.dx : details.globalPosition.dy;
|
||||
}
|
||||
|
||||
void _onPanUpdate(DragUpdateDetails details) {
|
||||
if (_lockScroll) return;
|
||||
var value = _currentValue +
|
||||
((widget.scrollDirection == Axis.horizontal ? details.globalPosition.dx : details.globalPosition.dy) - _currentPos) /
|
||||
_swiperWidth /
|
||||
2;
|
||||
// no loop ?
|
||||
if (!widget.loop) {
|
||||
if (widget.itemCount == 1) {
|
||||
value = 0.5;
|
||||
}
|
||||
if (_currentIndex >= widget.itemCount - 1) {
|
||||
if (value < 0.5) {
|
||||
value = 0.5;
|
||||
}
|
||||
} else if (_currentIndex <= 0) {
|
||||
if (value > 0.5) {
|
||||
value = 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_animationController.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
double _getValue(List<double> values, double animationValue, int index) {
|
||||
var s = values[index];
|
||||
if (animationValue >= 0.5) {
|
||||
if (index < values.length - 1) {
|
||||
s = s + (values[index + 1] - s) * (animationValue - 0.5) * 2.0;
|
||||
}
|
||||
} else {
|
||||
if (index != 0) {
|
||||
s = s - (s - values[index - 1]) * (0.5 - animationValue) * 2.0;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
Offset _getOffsetValue(List<Offset> values, double animationValue, int index) {
|
||||
final s = values[index];
|
||||
var dx = s.dx;
|
||||
var dy = s.dy;
|
||||
if (animationValue >= 0.5) {
|
||||
if (index < values.length - 1) {
|
||||
dx = dx + (values[index + 1].dx - dx) * (animationValue - 0.5) * 2.0;
|
||||
dy = dy + (values[index + 1].dy - dy) * (animationValue - 0.5) * 2.0;
|
||||
}
|
||||
} else {
|
||||
if (index != 0) {
|
||||
dx = dx - (dx - values[index - 1].dx) * (0.5 - animationValue) * 2.0;
|
||||
dy = dy - (dy - values[index - 1].dy) * (0.5 - animationValue) * 2.0;
|
||||
}
|
||||
}
|
||||
return Offset(dx, dy);
|
||||
}
|
||||
|
||||
abstract class TransformBuilder<T> {
|
||||
TransformBuilder({required this.values});
|
||||
|
||||
final List<T> values;
|
||||
|
||||
Widget build(int i, double animationValue, Widget widget);
|
||||
}
|
||||
|
||||
class ScaleTransformBuilder extends TransformBuilder<double> {
|
||||
ScaleTransformBuilder({
|
||||
required List<double> values,
|
||||
this.alignment = Alignment.center,
|
||||
}) : super(values: values);
|
||||
|
||||
final Alignment alignment;
|
||||
|
||||
@override
|
||||
Widget build(int i, double animationValue, Widget widget) {
|
||||
final s = _getValue(values, animationValue, i);
|
||||
return Transform.scale(scale: s, child: widget);
|
||||
}
|
||||
}
|
||||
|
||||
class OpacityTransformBuilder extends TransformBuilder<double> {
|
||||
OpacityTransformBuilder({required List<double> values}) : super(values: values);
|
||||
|
||||
@override
|
||||
Widget build(int i, double animationValue, Widget widget) {
|
||||
final v = _getValue(values, animationValue, i);
|
||||
return Opacity(
|
||||
opacity: v,
|
||||
child: widget,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class RotateTransformBuilder extends TransformBuilder<double> {
|
||||
RotateTransformBuilder({required List<double> values}) : super(values: values);
|
||||
|
||||
@override
|
||||
Widget build(int i, double animationValue, Widget widget) {
|
||||
final v = _getValue(values, animationValue, i);
|
||||
return Transform.rotate(
|
||||
angle: v,
|
||||
child: widget,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TranslateTransformBuilder extends TransformBuilder<Offset> {
|
||||
TranslateTransformBuilder({required List<Offset> values}) : super(values: values);
|
||||
|
||||
@override
|
||||
Widget build(int i, double animationValue, Widget widget) {
|
||||
final s = _getOffsetValue(values, animationValue, i);
|
||||
return Transform.translate(
|
||||
offset: s,
|
||||
child: widget,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CustomLayoutOption {
|
||||
CustomLayoutOption({this.stateCount, required this.startIndex});
|
||||
|
||||
final List<TransformBuilder<dynamic>> builders = [];
|
||||
final int startIndex;
|
||||
final int? stateCount;
|
||||
|
||||
void addOpacity(List<double> values) {
|
||||
builders.add(OpacityTransformBuilder(values: values));
|
||||
}
|
||||
|
||||
void addTranslate(List<Offset> values) {
|
||||
builders.add(TranslateTransformBuilder(values: values));
|
||||
}
|
||||
|
||||
void addScale(List<double> values, Alignment alignment) {
|
||||
builders.add(ScaleTransformBuilder(values: values, alignment: alignment));
|
||||
}
|
||||
|
||||
void addRotate(List<double> values) {
|
||||
builders.add(RotateTransformBuilder(values: values));
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomLayoutSwiper extends _SubSwiper {
|
||||
const _CustomLayoutSwiper({
|
||||
required this.option,
|
||||
double? itemWidth,
|
||||
required bool loop,
|
||||
double? itemHeight,
|
||||
ValueChanged<int>? onIndexChanged,
|
||||
Key? key,
|
||||
IndexedWidgetBuilder? itemBuilder,
|
||||
required Curve curve,
|
||||
int? duration,
|
||||
int? index,
|
||||
required int itemCount,
|
||||
Axis? scrollDirection,
|
||||
required SwiperController controller,
|
||||
}) : super(
|
||||
loop: loop,
|
||||
onIndexChanged: onIndexChanged,
|
||||
itemWidth: itemWidth,
|
||||
itemHeight: itemHeight,
|
||||
key: key,
|
||||
itemBuilder: itemBuilder,
|
||||
curve: curve,
|
||||
duration: duration,
|
||||
index: index,
|
||||
itemCount: itemCount,
|
||||
controller: controller,
|
||||
scrollDirection: scrollDirection);
|
||||
|
||||
final CustomLayoutOption option;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _CustomLayoutState();
|
||||
}
|
||||
}
|
||||
|
||||
class _CustomLayoutState extends _CustomLayoutStateBase<_CustomLayoutSwiper> {
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_startIndex = widget.option.startIndex;
|
||||
_animationCount = widget.option.stateCount;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_CustomLayoutSwiper oldWidget) {
|
||||
_startIndex = widget.option.startIndex;
|
||||
_animationCount = widget.option.stateCount;
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget _buildItem(int index, int realIndex, double animationValue) {
|
||||
final builders = widget.option.builders;
|
||||
|
||||
Widget child = SizedBox(
|
||||
width: widget.itemWidth ?? double.infinity,
|
||||
height: widget.itemHeight ?? double.infinity,
|
||||
child: widget.itemBuilder!(context, realIndex));
|
||||
|
||||
for (var i = builders.length - 1; i >= 0; --i) {
|
||||
final builder = builders[i];
|
||||
child = builder.build(index, animationValue, child);
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
/// Ref: https://docs.flutter.dev/development/tools_base/sdk/release-notes/release-notes-3.0.0#your-code
|
||||
/// This allows a value of type T or T?
|
||||
/// to be treated as a value of type T?.
|
||||
///
|
||||
/// We use this so that APIs that have become
|
||||
/// non-nullable can still be used with `!` and `?`
|
||||
/// to support older versions of the API as well.
|
||||
T? _ambiguate<T>(T? value) => value;
|
||||
+356
@@ -0,0 +1,356 @@
|
||||
// ignore_for_file: constant_identifier_names
|
||||
/// page indicator library
|
||||
library flutter_page_indicator;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../transformer_page_view/transformer_page_view.dart';
|
||||
|
||||
class WarmPainter extends BasePainter {
|
||||
WarmPainter(PageIndicator widget, double page, int index, Paint paint)
|
||||
: super(widget, page, index, paint);
|
||||
|
||||
@override
|
||||
void draw(Canvas canvas, double space, double size, double radius) {
|
||||
final progress = page - index;
|
||||
final distance = size + space;
|
||||
final start = index * (size + space);
|
||||
|
||||
if (progress > 0.5) {
|
||||
final right = start + size + distance;
|
||||
//progress=>0.5-1.0
|
||||
//left:0.0=>distance
|
||||
|
||||
final left = index * distance + distance * (progress - 0.5) * 2;
|
||||
canvas.drawRRect(
|
||||
RRect.fromLTRBR(left, 0.0, right, size, Radius.circular(radius)),
|
||||
_paint);
|
||||
} else {
|
||||
final right = start + size + distance * progress * 2;
|
||||
|
||||
canvas.drawRRect(
|
||||
RRect.fromLTRBR(start, 0.0, right, size, Radius.circular(radius)),
|
||||
_paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DropPainter extends BasePainter {
|
||||
DropPainter(PageIndicator widget, double page, int index, Paint paint)
|
||||
: super(widget, page, index, paint);
|
||||
|
||||
@override
|
||||
void draw(Canvas canvas, double space, double size, double radius) {
|
||||
final progress = page - index;
|
||||
final dropHeight = widget.dropHeight;
|
||||
final rate = (0.5 - progress).abs() * 2;
|
||||
final scale = widget.scale;
|
||||
|
||||
//lerp(begin, end, progress)
|
||||
|
||||
canvas.drawCircle(
|
||||
Offset(radius + ((page) * (size + space)),
|
||||
radius - dropHeight * (1 - rate)),
|
||||
radius * (scale + rate * (1.0 - scale)),
|
||||
_paint);
|
||||
}
|
||||
}
|
||||
|
||||
class NonePainter extends BasePainter {
|
||||
NonePainter(PageIndicator widget, double page, int index, Paint paint)
|
||||
: super(widget, page, index, paint);
|
||||
|
||||
@override
|
||||
void draw(Canvas canvas, double space, double size, double radius) {
|
||||
final progress = page - index;
|
||||
final secondOffset = index == widget.count - 1
|
||||
? radius
|
||||
: radius + ((index + 1) * (size + space));
|
||||
|
||||
if (progress > 0.5) {
|
||||
canvas.drawCircle(Offset(secondOffset, radius), radius, _paint);
|
||||
} else {
|
||||
canvas.drawCircle(
|
||||
Offset(radius + (index * (size + space)), radius), radius, _paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SlidePainter extends BasePainter {
|
||||
SlidePainter(PageIndicator widget, double page, int index, Paint paint)
|
||||
: super(widget, page, index, paint);
|
||||
|
||||
@override
|
||||
void draw(Canvas canvas, double space, double size, double radius) {
|
||||
canvas.drawCircle(
|
||||
Offset(radius + (page * (size + space)), radius), radius, _paint);
|
||||
}
|
||||
}
|
||||
|
||||
class ScalePainter extends BasePainter {
|
||||
ScalePainter(PageIndicator widget, double page, int index, Paint paint)
|
||||
: super(widget, page, index, paint);
|
||||
|
||||
@override
|
||||
bool _shouldSkip(int index) {
|
||||
if (this.index == widget.count - 1) {
|
||||
return index == 0 || index == this.index;
|
||||
}
|
||||
return (index == this.index || index == this.index + 1);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
_paint.color = widget.color;
|
||||
final space = widget.space;
|
||||
final size = widget.size;
|
||||
final radius = size / 2;
|
||||
final c = widget.count;
|
||||
for (var i = 0; i < c; ++i) {
|
||||
if (_shouldSkip(i)) {
|
||||
continue;
|
||||
}
|
||||
canvas.drawCircle(Offset(i * (size + space) + radius, radius),
|
||||
radius * widget.scale, _paint);
|
||||
}
|
||||
|
||||
_paint.color = widget.activeColor;
|
||||
draw(canvas, space, size, radius);
|
||||
}
|
||||
|
||||
@override
|
||||
void draw(Canvas canvas, double space, double size, double radius) {
|
||||
final secondOffset = index == widget.count - 1
|
||||
? radius
|
||||
: radius + ((index + 1) * (size + space));
|
||||
|
||||
final progress = page - index;
|
||||
_paint.color = Color.lerp(widget.activeColor, widget.color, progress)!;
|
||||
//last
|
||||
canvas.drawCircle(Offset(radius + (index * (size + space)), radius),
|
||||
lerp(radius, radius * widget.scale, progress), _paint);
|
||||
//first
|
||||
_paint.color = Color.lerp(widget.color, widget.activeColor, progress)!;
|
||||
canvas.drawCircle(Offset(secondOffset, radius),
|
||||
lerp(radius * widget.scale, radius, progress), _paint);
|
||||
}
|
||||
}
|
||||
|
||||
class ColorPainter extends BasePainter {
|
||||
ColorPainter(PageIndicator widget, double page, int index, Paint paint)
|
||||
: super(widget, page, index, paint);
|
||||
|
||||
@override
|
||||
bool _shouldSkip(int index) {
|
||||
if (this.index == widget.count - 1) {
|
||||
return index == 0 || index == this.index;
|
||||
}
|
||||
return (index == this.index || index == this.index + 1);
|
||||
}
|
||||
|
||||
@override
|
||||
void draw(Canvas canvas, double space, double size, double radius) {
|
||||
final progress = page - index;
|
||||
final secondOffset = index == widget.count - 1
|
||||
? radius
|
||||
: radius + ((index + 1) * (size + space));
|
||||
|
||||
_paint.color = Color.lerp(widget.activeColor, widget.color, progress)!;
|
||||
//left
|
||||
canvas.drawCircle(
|
||||
Offset(radius + (index * (size + space)), radius), radius, _paint);
|
||||
//right
|
||||
_paint.color = Color.lerp(widget.color, widget.activeColor, progress)!;
|
||||
canvas.drawCircle(Offset(secondOffset, radius), radius, _paint);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class BasePainter extends CustomPainter {
|
||||
BasePainter(this.widget, this.page, this.index, this._paint);
|
||||
|
||||
final PageIndicator widget;
|
||||
final double page;
|
||||
final int index;
|
||||
final Paint _paint;
|
||||
|
||||
double lerp(double begin, double end, double progress) {
|
||||
return begin + (end - begin) * progress;
|
||||
}
|
||||
|
||||
void draw(Canvas canvas, double space, double size, double radius);
|
||||
|
||||
bool _shouldSkip(int index) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//double secondOffset = index == widget.count-1 ? radius : radius + ((index + 1) * (size + space));
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
_paint.color = widget.color;
|
||||
final space = widget.space;
|
||||
final size = widget.size;
|
||||
final radius = size / 2;
|
||||
final c = widget.count;
|
||||
for (var i = 0; i < c; ++i) {
|
||||
if (_shouldSkip(i)) {
|
||||
continue;
|
||||
}
|
||||
canvas.drawCircle(
|
||||
Offset(i * (size + space) + radius, radius), radius, _paint);
|
||||
}
|
||||
|
||||
var page = this.page;
|
||||
if (page < index) {
|
||||
page = 0.0;
|
||||
}
|
||||
_paint.color = widget.activeColor;
|
||||
draw(canvas, space, size, radius);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(BasePainter oldDelegate) {
|
||||
return oldDelegate.page != page;
|
||||
}
|
||||
}
|
||||
|
||||
class _PageIndicatorState extends State<PageIndicator> {
|
||||
int index = 0;
|
||||
double page = 0;
|
||||
final _paint = Paint();
|
||||
|
||||
BasePainter _createPainter() {
|
||||
switch (widget.layout) {
|
||||
case PageIndicatorLayout.NONE:
|
||||
return NonePainter(widget, page, index, _paint);
|
||||
case PageIndicatorLayout.SLIDE:
|
||||
return SlidePainter(widget, page, index, _paint);
|
||||
case PageIndicatorLayout.WARM:
|
||||
return WarmPainter(widget, page, index, _paint);
|
||||
case PageIndicatorLayout.COLOR:
|
||||
return ColorPainter(widget, page, index, _paint);
|
||||
case PageIndicatorLayout.SCALE:
|
||||
return ScalePainter(widget, page, index, _paint);
|
||||
case PageIndicatorLayout.DROP:
|
||||
return DropPainter(widget, page, index, _paint);
|
||||
default:
|
||||
throw Exception('Not a valid layout');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = SizedBox(
|
||||
width: widget.count * widget.size + (widget.count - 1) * widget.space,
|
||||
height: widget.size,
|
||||
child: CustomPaint(
|
||||
painter: _createPainter(),
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.layout == PageIndicatorLayout.SCALE ||
|
||||
widget.layout == PageIndicatorLayout.COLOR) {
|
||||
child = ClipRect(
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return IgnorePointer(
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
void _setInitialPage() {
|
||||
// use the initial page index but cut off
|
||||
// the offset specified when looping (kMiddleValue)
|
||||
index = widget.controller.initialPage % kMiddleValue;
|
||||
page = index.toDouble();
|
||||
}
|
||||
|
||||
void _onController() {
|
||||
if (!widget.controller.hasClients) return;
|
||||
page = widget.controller.page ?? 0.0;
|
||||
index = page.floor();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(_onController);
|
||||
_setInitialPage();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PageIndicator oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.controller != oldWidget.controller) {
|
||||
oldWidget.controller.removeListener(_onController);
|
||||
widget.controller.addListener(_onController);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onController);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
enum PageIndicatorLayout {
|
||||
NONE,
|
||||
SLIDE,
|
||||
WARM,
|
||||
COLOR,
|
||||
SCALE,
|
||||
DROP,
|
||||
}
|
||||
|
||||
class PageIndicator extends StatefulWidget {
|
||||
const PageIndicator({
|
||||
Key? key,
|
||||
this.size = 20.0,
|
||||
this.space = 5.0,
|
||||
required this.count,
|
||||
this.activeSize = 20.0,
|
||||
required this.controller,
|
||||
this.color = Colors.white30,
|
||||
this.layout = PageIndicatorLayout.SLIDE,
|
||||
this.activeColor = Colors.white,
|
||||
this.scale = 0.6,
|
||||
this.dropHeight = 20.0,
|
||||
}) : super(key: key);
|
||||
|
||||
/// size of the dots
|
||||
final double size;
|
||||
|
||||
/// space between dots.
|
||||
final double space;
|
||||
|
||||
/// count of dots
|
||||
final int count;
|
||||
|
||||
/// active color
|
||||
final Color activeColor;
|
||||
|
||||
/// normal color
|
||||
final Color color;
|
||||
|
||||
/// layout of the dots,default is [PageIndicatorLayout.SLIDE]
|
||||
final PageIndicatorLayout? layout;
|
||||
|
||||
// Only valid when layout==PageIndicatorLayout.scale
|
||||
final double scale;
|
||||
|
||||
// Only valid when layout==PageIndicatorLayout.drop
|
||||
final double dropHeight;
|
||||
|
||||
final PageController controller;
|
||||
|
||||
final double activeSize;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _PageIndicatorState();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,996 @@
|
||||
// ignore_for_file: constant_identifier_names
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../card_swiper.dart';
|
||||
import 'transformer_page_view/transformer_page_view.dart';
|
||||
|
||||
part 'custom_layout.dart';
|
||||
|
||||
typedef SwiperOnTap = void Function(int index);
|
||||
|
||||
typedef SwiperDataBuilder<T> = Widget Function(
|
||||
BuildContext context,
|
||||
T data,
|
||||
int index,
|
||||
);
|
||||
|
||||
/// default auto play delay
|
||||
const int kDefaultAutoplayDelayMs = 3000;
|
||||
|
||||
/// Default auto play transition duration (in millisecond)
|
||||
const int kDefaultAutoplayTransactionDuration = 300;
|
||||
|
||||
const int kMaxValue = 2000000000;
|
||||
const int kMiddleValue = 1000000000;
|
||||
|
||||
enum SwiperLayout {
|
||||
DEFAULT,
|
||||
STACK,
|
||||
TINDER,
|
||||
CUSTOM,
|
||||
}
|
||||
|
||||
class Swiper extends StatefulWidget {
|
||||
const Swiper({
|
||||
this.itemBuilder,
|
||||
this.indicatorLayout = PageIndicatorLayout.NONE,
|
||||
|
||||
///
|
||||
this.transformer,
|
||||
required this.itemCount,
|
||||
this.autoplay = false,
|
||||
this.layout = SwiperLayout.DEFAULT,
|
||||
this.autoplayDelay = kDefaultAutoplayDelayMs,
|
||||
this.autoplayDisableOnInteraction = true,
|
||||
this.duration = kDefaultAutoplayTransactionDuration,
|
||||
this.onIndexChanged,
|
||||
this.index,
|
||||
this.onTap,
|
||||
this.control,
|
||||
this.loop = true,
|
||||
this.curve = Curves.ease,
|
||||
this.scrollDirection = Axis.horizontal,
|
||||
this.axisDirection = AxisDirection.left,
|
||||
this.pagination,
|
||||
this.plugins,
|
||||
this.physics,
|
||||
Key? key,
|
||||
this.controller,
|
||||
this.customLayoutOption,
|
||||
|
||||
/// since v1.0.0
|
||||
this.containerHeight,
|
||||
this.containerWidth,
|
||||
this.viewportFraction = 1.0,
|
||||
this.itemHeight,
|
||||
this.itemWidth,
|
||||
this.outer = false,
|
||||
this.scale,
|
||||
this.fade,
|
||||
this.allowImplicitScrolling = false,
|
||||
}) : assert(
|
||||
itemBuilder != null || transformer != null,
|
||||
'itemBuilder and transformItemBuilder must not be both null',
|
||||
),
|
||||
assert(
|
||||
!loop ||
|
||||
((loop &&
|
||||
layout == SwiperLayout.DEFAULT &&
|
||||
(indicatorLayout == PageIndicatorLayout.SCALE ||
|
||||
indicatorLayout == PageIndicatorLayout.COLOR ||
|
||||
indicatorLayout == PageIndicatorLayout.NONE)) ||
|
||||
(loop && layout != SwiperLayout.DEFAULT)),
|
||||
'Only support `PageIndicatorLayout.SCALE` and `PageIndicatorLayout.COLOR`when layout==SwiperLayout.DEFAULT in loop mode'),
|
||||
super(key: key);
|
||||
|
||||
factory Swiper.children({
|
||||
required List<Widget> children,
|
||||
bool autoplay = false,
|
||||
PageTransformer? transformer,
|
||||
int autoplayDelay = kDefaultAutoplayDelayMs,
|
||||
bool autoplayDisableOnInteraction = true,
|
||||
int duration = kDefaultAutoplayTransactionDuration,
|
||||
ValueChanged<int>? onIndexChanged,
|
||||
int? index,
|
||||
SwiperOnTap? onTap,
|
||||
bool loop = true,
|
||||
Curve curve = Curves.ease,
|
||||
Axis scrollDirection = Axis.horizontal,
|
||||
AxisDirection axisDirection = AxisDirection.left,
|
||||
SwiperPlugin? pagination,
|
||||
SwiperPlugin? control,
|
||||
List<SwiperPlugin>? plugins,
|
||||
SwiperController? controller,
|
||||
Key? key,
|
||||
CustomLayoutOption? customLayoutOption,
|
||||
ScrollPhysics? physics,
|
||||
double? containerHeight,
|
||||
double? containerWidth,
|
||||
double viewportFraction = 1.0,
|
||||
double? itemHeight,
|
||||
double? itemWidth,
|
||||
bool outer = false,
|
||||
double scale = 1.0,
|
||||
double? fade,
|
||||
PageIndicatorLayout indicatorLayout = PageIndicatorLayout.NONE,
|
||||
SwiperLayout layout = SwiperLayout.DEFAULT,
|
||||
}) =>
|
||||
Swiper(
|
||||
fade: fade,
|
||||
indicatorLayout: indicatorLayout,
|
||||
layout: layout,
|
||||
transformer: transformer,
|
||||
customLayoutOption: customLayoutOption,
|
||||
containerHeight: containerHeight,
|
||||
containerWidth: containerWidth,
|
||||
viewportFraction: viewportFraction,
|
||||
itemHeight: itemHeight,
|
||||
itemWidth: itemWidth,
|
||||
outer: outer,
|
||||
scale: scale,
|
||||
autoplay: autoplay,
|
||||
autoplayDelay: autoplayDelay,
|
||||
autoplayDisableOnInteraction: autoplayDisableOnInteraction,
|
||||
duration: duration,
|
||||
onIndexChanged: onIndexChanged,
|
||||
index: index,
|
||||
onTap: onTap,
|
||||
curve: curve,
|
||||
scrollDirection: scrollDirection,
|
||||
axisDirection: axisDirection,
|
||||
pagination: pagination,
|
||||
control: control,
|
||||
controller: controller,
|
||||
loop: loop,
|
||||
plugins: plugins,
|
||||
physics: physics,
|
||||
key: key,
|
||||
itemBuilder: (context, index) {
|
||||
return children[index];
|
||||
},
|
||||
itemCount: children.length,
|
||||
);
|
||||
|
||||
/// If set true , the pagination will display 'outer' of the 'content' container.
|
||||
final bool outer;
|
||||
|
||||
/// Inner item height, this property is valid if layout=STACK or layout=TINDER or LAYOUT=CUSTOM,
|
||||
final double? itemHeight;
|
||||
|
||||
/// Inner item width, this property is valid if layout=STACK or layout=TINDER or LAYOUT=CUSTOM,
|
||||
final double? itemWidth;
|
||||
|
||||
// height of the inside container,this property is valid when outer=true,otherwise the inside container size is controlled by parent widget
|
||||
final double? containerHeight;
|
||||
|
||||
// width of the inside container,this property is valid when outer=true,otherwise the inside container size is controlled by parent widget
|
||||
final double? containerWidth;
|
||||
|
||||
/// Build item on index
|
||||
final IndexedWidgetBuilder? itemBuilder;
|
||||
|
||||
/// Support transform like Android PageView did
|
||||
/// `itemBuilder` and `transformItemBuilder` must have one not null
|
||||
final PageTransformer? transformer;
|
||||
|
||||
/// count of the display items
|
||||
final int itemCount;
|
||||
|
||||
final ValueChanged<int>? onIndexChanged;
|
||||
|
||||
///auto play config
|
||||
final bool autoplay;
|
||||
|
||||
///Duration of the animation between transactions (in millisecond).
|
||||
final int autoplayDelay;
|
||||
|
||||
///disable auto play when interaction
|
||||
final bool autoplayDisableOnInteraction;
|
||||
|
||||
///auto play transition duration (in millisecond)
|
||||
final int duration;
|
||||
|
||||
///horizontal/vertical
|
||||
final Axis scrollDirection;
|
||||
|
||||
///left/right for Stack Layout
|
||||
final AxisDirection axisDirection;
|
||||
|
||||
///transition curve
|
||||
final Curve curve;
|
||||
|
||||
/// Set to false to disable continuous loop mode.
|
||||
final bool loop;
|
||||
|
||||
///Index number of initial slide.
|
||||
///If not set , the `Swiper` is 'uncontrolled', which means manage index by itself
|
||||
///If set , the `Swiper` is 'controlled', which means the index is fully managed by parent widget.
|
||||
final int? index;
|
||||
|
||||
///Called when tap
|
||||
final SwiperOnTap? onTap;
|
||||
|
||||
///The swiper pagination plugin
|
||||
final SwiperPlugin? pagination;
|
||||
|
||||
///the swiper control button plugin
|
||||
final SwiperPlugin? control;
|
||||
|
||||
///other plugins, you can custom your own plugin
|
||||
final List<SwiperPlugin>? plugins;
|
||||
|
||||
///
|
||||
final SwiperController? controller;
|
||||
|
||||
final ScrollPhysics? physics;
|
||||
|
||||
///
|
||||
final double viewportFraction;
|
||||
|
||||
/// Build in layouts
|
||||
final SwiperLayout layout;
|
||||
|
||||
/// this value is valid when layout == SwiperLayout.CUSTOM
|
||||
final CustomLayoutOption? customLayoutOption;
|
||||
|
||||
// This value is valid when viewportFraction is set and < 1.0
|
||||
final double? scale;
|
||||
|
||||
// This value is valid when viewportFraction is set and < 1.0
|
||||
final double? fade;
|
||||
|
||||
final PageIndicatorLayout indicatorLayout;
|
||||
|
||||
final bool allowImplicitScrolling;
|
||||
|
||||
static Swiper list<T>({
|
||||
PageTransformer? transformer,
|
||||
required List<T> list,
|
||||
CustomLayoutOption? customLayoutOption,
|
||||
required SwiperDataBuilder<T> builder,
|
||||
bool autoplay = false,
|
||||
int autoplayDelay = kDefaultAutoplayDelayMs,
|
||||
bool reverse = false,
|
||||
bool autoplayDisableOnInteraction = true,
|
||||
int duration = kDefaultAutoplayTransactionDuration,
|
||||
ValueChanged<int>? onIndexChanged,
|
||||
int? index,
|
||||
SwiperOnTap? onTap,
|
||||
bool loop = true,
|
||||
Curve curve = Curves.ease,
|
||||
Axis scrollDirection = Axis.horizontal,
|
||||
AxisDirection axisDirection = AxisDirection.left,
|
||||
SwiperPlugin? pagination,
|
||||
SwiperPlugin? control,
|
||||
List<SwiperPlugin>? plugins,
|
||||
SwiperController? controller,
|
||||
Key? key,
|
||||
ScrollPhysics? physics,
|
||||
double? containerHeight,
|
||||
double? containerWidth,
|
||||
double viewportFraction = 1.0,
|
||||
double? itemHeight,
|
||||
double? itemWidth,
|
||||
bool outer = false,
|
||||
double scale = 1.0,
|
||||
double? fade,
|
||||
PageIndicatorLayout indicatorLayout = PageIndicatorLayout.NONE,
|
||||
SwiperLayout layout = SwiperLayout.DEFAULT,
|
||||
}) =>
|
||||
Swiper(
|
||||
fade: fade,
|
||||
indicatorLayout: indicatorLayout,
|
||||
layout: layout,
|
||||
transformer: transformer,
|
||||
customLayoutOption: customLayoutOption,
|
||||
containerHeight: containerHeight,
|
||||
containerWidth: containerWidth,
|
||||
viewportFraction: viewportFraction,
|
||||
itemHeight: itemHeight,
|
||||
itemWidth: itemWidth,
|
||||
outer: outer,
|
||||
scale: scale,
|
||||
autoplay: autoplay,
|
||||
autoplayDelay: autoplayDelay,
|
||||
autoplayDisableOnInteraction: autoplayDisableOnInteraction,
|
||||
duration: duration,
|
||||
onIndexChanged: onIndexChanged,
|
||||
index: index,
|
||||
onTap: onTap,
|
||||
curve: curve,
|
||||
key: key,
|
||||
scrollDirection: scrollDirection,
|
||||
axisDirection: axisDirection,
|
||||
pagination: pagination,
|
||||
control: control,
|
||||
controller: controller,
|
||||
loop: loop,
|
||||
plugins: plugins,
|
||||
physics: physics,
|
||||
itemBuilder: (context, index) {
|
||||
return builder(context, list[index], index);
|
||||
},
|
||||
itemCount: list.length,
|
||||
);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _SwiperState();
|
||||
}
|
||||
|
||||
abstract class _SwiperTimerMixin extends State<Swiper> {
|
||||
Timer? _timer;
|
||||
late SwiperController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = widget.controller ?? SwiperController();
|
||||
_controller.addListener(_onController);
|
||||
if (widget.autoplay) {
|
||||
_controller.startAutoplay();
|
||||
} else {
|
||||
_controller.stopAutoplay();
|
||||
}
|
||||
}
|
||||
|
||||
void _onController() {
|
||||
final event = _controller.event;
|
||||
if (event is AutoPlaySwiperControllerEvent) {
|
||||
if (event.autoplay) {
|
||||
if (_timer == null) {
|
||||
_startAutoplay();
|
||||
}
|
||||
} else {
|
||||
_stopAutoplay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(Swiper oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (_controller != oldWidget.controller) {
|
||||
final oldController = oldWidget.controller;
|
||||
if (oldController != null) {
|
||||
oldController.removeListener(_onController);
|
||||
_controller = oldController;
|
||||
_controller.addListener(_onController);
|
||||
}
|
||||
}
|
||||
if (widget.autoplay != oldWidget.autoplay) {
|
||||
if (widget.autoplay) {
|
||||
_controller.startAutoplay();
|
||||
} else {
|
||||
_controller.stopAutoplay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_onController);
|
||||
_stopAutoplay();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startAutoplay() {
|
||||
_stopAutoplay();
|
||||
_timer = Timer.periodic(
|
||||
Duration(
|
||||
milliseconds: widget.autoplayDelay,
|
||||
),
|
||||
_onTimer,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onTimer(Timer timer) async {
|
||||
return _controller.next(animation: true);
|
||||
}
|
||||
|
||||
void _stopAutoplay() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
class _SwiperState extends _SwiperTimerMixin {
|
||||
late int _activeIndex;
|
||||
|
||||
TransformerPageController? _pageController;
|
||||
|
||||
Widget _wrapTap(BuildContext context, int index) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => widget.onTap!(index),
|
||||
child: widget.itemBuilder!(context, index),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_activeIndex = widget.index ?? widget.controller?.index ?? 0;
|
||||
if (_isPageViewLayout()) {
|
||||
_pageController = TransformerPageController(
|
||||
initialPage: widget.index ?? widget.controller?.index ?? 0,
|
||||
loop: widget.loop,
|
||||
itemCount: widget.itemCount,
|
||||
reverse: widget.transformer?.reverse ?? false,
|
||||
viewportFraction: widget.viewportFraction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isPageViewLayout() {
|
||||
return widget.layout == SwiperLayout.DEFAULT;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
bool _getReverse(Swiper widget) => widget.transformer?.reverse ?? false;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(Swiper oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (_isPageViewLayout()) {
|
||||
if (_pageController == null ||
|
||||
(widget.index != oldWidget.index ||
|
||||
widget.loop != oldWidget.loop ||
|
||||
widget.itemCount != oldWidget.itemCount ||
|
||||
widget.viewportFraction != oldWidget.viewportFraction ||
|
||||
_getReverse(widget) != _getReverse(oldWidget))) {
|
||||
_pageController = TransformerPageController(
|
||||
initialPage: widget.index ?? widget.controller?.index ?? 0,
|
||||
loop: widget.loop,
|
||||
itemCount: widget.itemCount,
|
||||
reverse: _getReverse(widget),
|
||||
viewportFraction: widget.viewportFraction,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
scheduleMicrotask(() {
|
||||
// So that we have a chance to do `removeListener` in child widgets.
|
||||
if (_pageController != null) {
|
||||
_pageController!.dispose();
|
||||
_pageController = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (widget.index != null && widget.index != _activeIndex) {
|
||||
_activeIndex = widget.index!;
|
||||
}
|
||||
}
|
||||
|
||||
void _onIndexChanged(int index) {
|
||||
setState(() {
|
||||
_activeIndex = index;
|
||||
});
|
||||
|
||||
final event = _controller.event;
|
||||
if ((event is MoveIndexControllerEvent) && (event.newIndex != index)) {
|
||||
return;
|
||||
}
|
||||
widget.onIndexChanged?.call(index);
|
||||
}
|
||||
|
||||
Widget _buildSwiper() {
|
||||
IndexedWidgetBuilder? itemBuilder;
|
||||
if (widget.onTap != null) {
|
||||
itemBuilder = _wrapTap;
|
||||
} else {
|
||||
itemBuilder = widget.itemBuilder;
|
||||
}
|
||||
|
||||
if (widget.layout == SwiperLayout.STACK) {
|
||||
return _StackSwiper(
|
||||
loop: widget.loop,
|
||||
itemWidth: widget.itemWidth,
|
||||
itemHeight: widget.itemHeight,
|
||||
itemCount: widget.itemCount,
|
||||
itemBuilder: itemBuilder,
|
||||
index: _activeIndex,
|
||||
curve: widget.curve,
|
||||
duration: widget.duration,
|
||||
onIndexChanged: _onIndexChanged,
|
||||
controller: _controller,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
axisDirection: widget.axisDirection,
|
||||
);
|
||||
} else if (_isPageViewLayout()) {
|
||||
//default
|
||||
var transformer = widget.transformer;
|
||||
if (widget.scale != null || widget.fade != null) {
|
||||
transformer = ScaleAndFadeTransformer(scale: widget.scale, fade: widget.fade);
|
||||
}
|
||||
|
||||
final child = TransformerPageView(
|
||||
pageController: _pageController,
|
||||
loop: widget.loop,
|
||||
itemCount: widget.itemCount,
|
||||
itemBuilder: itemBuilder,
|
||||
transformer: transformer,
|
||||
viewportFraction: widget.viewportFraction,
|
||||
index: _activeIndex,
|
||||
duration: Duration(milliseconds: widget.duration),
|
||||
scrollDirection: widget.scrollDirection,
|
||||
onPageChanged: _onIndexChanged,
|
||||
curve: widget.curve,
|
||||
physics: widget.physics,
|
||||
controller: _controller,
|
||||
allowImplicitScrolling: widget.allowImplicitScrolling,
|
||||
);
|
||||
if (widget.autoplayDisableOnInteraction && widget.autoplay) {
|
||||
return NotificationListener(
|
||||
onNotification: (notification) {
|
||||
if (notification is ScrollStartNotification) {
|
||||
if (notification.dragDetails != null) {
|
||||
//by human
|
||||
if (_timer != null) _stopAutoplay();
|
||||
}
|
||||
} else if (notification is ScrollEndNotification) {
|
||||
if (_timer == null) _startAutoplay();
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
} else if (widget.layout == SwiperLayout.TINDER) {
|
||||
return _TinderSwiper(
|
||||
loop: widget.loop,
|
||||
itemWidth: widget.itemWidth,
|
||||
itemHeight: widget.itemHeight,
|
||||
itemCount: widget.itemCount,
|
||||
itemBuilder: itemBuilder,
|
||||
index: _activeIndex,
|
||||
curve: widget.curve,
|
||||
duration: widget.duration,
|
||||
onIndexChanged: _onIndexChanged,
|
||||
controller: _controller,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
);
|
||||
} else if (widget.layout == SwiperLayout.CUSTOM) {
|
||||
return _CustomLayoutSwiper(
|
||||
loop: widget.loop,
|
||||
option: widget.customLayoutOption!,
|
||||
itemWidth: widget.itemWidth,
|
||||
itemHeight: widget.itemHeight,
|
||||
itemCount: widget.itemCount,
|
||||
itemBuilder: itemBuilder,
|
||||
index: _activeIndex,
|
||||
curve: widget.curve,
|
||||
duration: widget.duration,
|
||||
onIndexChanged: _onIndexChanged,
|
||||
controller: _controller,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
SwiperPluginConfig _ensureConfig(SwiperPluginConfig? config) {
|
||||
final con = config ??
|
||||
SwiperPluginConfig(
|
||||
outer: widget.outer,
|
||||
itemCount: widget.itemCount,
|
||||
layout: widget.layout,
|
||||
indicatorLayout: widget.indicatorLayout,
|
||||
pageController: _pageController,
|
||||
activeIndex: _activeIndex,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
axisDirection: widget.axisDirection,
|
||||
controller: _controller,
|
||||
loop: widget.loop,
|
||||
);
|
||||
|
||||
return con;
|
||||
}
|
||||
|
||||
List<Widget>? _ensureListForStack({
|
||||
required Widget swiper,
|
||||
required List<Widget>? listForStack,
|
||||
required Widget widget,
|
||||
}) {
|
||||
final resList = <Widget>[];
|
||||
if (listForStack == null) {
|
||||
resList.addAll([swiper, widget]);
|
||||
} else {
|
||||
resList.addAll([...listForStack, widget]);
|
||||
}
|
||||
return resList;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final swiper = _buildSwiper();
|
||||
List<Widget>? listForStack;
|
||||
SwiperPluginConfig? config;
|
||||
if (widget.control != null) {
|
||||
//Stack
|
||||
config = _ensureConfig(config);
|
||||
listForStack = _ensureListForStack(
|
||||
swiper: swiper,
|
||||
listForStack: listForStack,
|
||||
widget: widget.control!.build(context, config),
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.plugins != null) {
|
||||
config = _ensureConfig(config);
|
||||
for (final plugin in widget.plugins!) {
|
||||
listForStack = _ensureListForStack(
|
||||
swiper: swiper,
|
||||
listForStack: listForStack,
|
||||
widget: plugin.build(context, config),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (widget.pagination != null) {
|
||||
config = _ensureConfig(config);
|
||||
if (widget.outer) {
|
||||
return _buildOuterPagination(
|
||||
widget.pagination! as SwiperPagination, listForStack == null ? swiper : Stack(children: listForStack), config);
|
||||
} else {
|
||||
listForStack = _ensureListForStack(
|
||||
swiper: swiper,
|
||||
listForStack: listForStack,
|
||||
widget: widget.pagination!.build(context, config),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (listForStack != null) {
|
||||
return Stack(
|
||||
children: listForStack,
|
||||
);
|
||||
}
|
||||
|
||||
return swiper;
|
||||
}
|
||||
|
||||
Widget _buildOuterPagination(
|
||||
SwiperPagination pagination,
|
||||
Widget swiper,
|
||||
SwiperPluginConfig config,
|
||||
) {
|
||||
final list = <Widget>[];
|
||||
//Only support bottom yet!
|
||||
if (widget.containerHeight != null || widget.containerWidth != null) {
|
||||
list.add(swiper);
|
||||
} else {
|
||||
list.add(Expanded(child: swiper));
|
||||
}
|
||||
|
||||
list.add(Align(
|
||||
alignment: Alignment.center,
|
||||
child: pagination.build(context, config),
|
||||
));
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: list,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _SubSwiper extends StatefulWidget {
|
||||
const _SubSwiper({
|
||||
Key? key,
|
||||
required this.loop,
|
||||
this.itemHeight,
|
||||
this.itemWidth,
|
||||
this.duration,
|
||||
required this.curve,
|
||||
this.itemBuilder,
|
||||
required this.controller,
|
||||
this.index,
|
||||
required this.itemCount,
|
||||
this.scrollDirection = Axis.horizontal,
|
||||
this.axisDirection = AxisDirection.left,
|
||||
this.onIndexChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
final IndexedWidgetBuilder? itemBuilder;
|
||||
final int itemCount;
|
||||
final int? index;
|
||||
final ValueChanged<int>? onIndexChanged;
|
||||
final SwiperController controller;
|
||||
final int? duration;
|
||||
final Curve curve;
|
||||
final double? itemWidth;
|
||||
final double? itemHeight;
|
||||
final bool loop;
|
||||
final Axis? scrollDirection;
|
||||
final AxisDirection? axisDirection;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState();
|
||||
|
||||
int getCorrectIndex(int indexNeedsFix) {
|
||||
if (itemCount == 0) return 0;
|
||||
var value = indexNeedsFix % itemCount;
|
||||
if (value < 0) {
|
||||
value += itemCount;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
class _TinderSwiper extends _SubSwiper {
|
||||
const _TinderSwiper({
|
||||
Key? key,
|
||||
required Curve curve,
|
||||
int? duration,
|
||||
required SwiperController controller,
|
||||
ValueChanged<int>? onIndexChanged,
|
||||
double? itemHeight,
|
||||
double? itemWidth,
|
||||
IndexedWidgetBuilder? itemBuilder,
|
||||
int? index,
|
||||
required bool loop,
|
||||
required int itemCount,
|
||||
Axis? scrollDirection,
|
||||
}) : assert(itemWidth != null && itemHeight != null),
|
||||
super(
|
||||
loop: loop,
|
||||
key: key,
|
||||
itemWidth: itemWidth,
|
||||
itemHeight: itemHeight,
|
||||
itemBuilder: itemBuilder,
|
||||
curve: curve,
|
||||
duration: duration,
|
||||
controller: controller,
|
||||
index: index,
|
||||
onIndexChanged: onIndexChanged,
|
||||
itemCount: itemCount,
|
||||
scrollDirection: scrollDirection);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _TinderState();
|
||||
}
|
||||
}
|
||||
|
||||
class _StackSwiper extends _SubSwiper {
|
||||
const _StackSwiper({
|
||||
Key? key,
|
||||
required Curve curve,
|
||||
int? duration,
|
||||
required SwiperController controller,
|
||||
ValueChanged<int>? onIndexChanged,
|
||||
double? itemHeight,
|
||||
double? itemWidth,
|
||||
IndexedWidgetBuilder? itemBuilder,
|
||||
int? index,
|
||||
required bool loop,
|
||||
required int itemCount,
|
||||
Axis? scrollDirection,
|
||||
AxisDirection? axisDirection,
|
||||
}) : super(
|
||||
loop: loop,
|
||||
key: key,
|
||||
itemWidth: itemWidth,
|
||||
itemHeight: itemHeight,
|
||||
itemBuilder: itemBuilder,
|
||||
curve: curve,
|
||||
duration: duration,
|
||||
controller: controller,
|
||||
index: index,
|
||||
onIndexChanged: onIndexChanged,
|
||||
itemCount: itemCount,
|
||||
scrollDirection: scrollDirection,
|
||||
axisDirection: axisDirection,
|
||||
);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _StackViewState();
|
||||
}
|
||||
|
||||
class _TinderState extends _CustomLayoutStateBase<_TinderSwiper> {
|
||||
late List<double> scales;
|
||||
late List<double> offsetsX;
|
||||
late List<double> offsetsY;
|
||||
late List<double> opacity;
|
||||
late List<double> rotates;
|
||||
|
||||
double getOffsetY(double scale) {
|
||||
return widget.itemHeight! - widget.itemHeight! * scale;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_TinderSwiper oldWidget) {
|
||||
_updateValues();
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
void afterRender() {
|
||||
super.afterRender();
|
||||
|
||||
_startIndex = -3;
|
||||
_animationCount = 5;
|
||||
opacity = [0.0, 0.9, 0.9, 1.0, 0.0, 0.0];
|
||||
scales = [0.80, 0.80, 0.85, 0.90, 1.0, 1.0, 1.0];
|
||||
rotates = [0.0, 0.0, 0.0, 0.0, 20.0, 25.0];
|
||||
_updateValues();
|
||||
}
|
||||
|
||||
void _updateValues() {
|
||||
if (widget.scrollDirection == Axis.horizontal) {
|
||||
offsetsX = [0.0, 0.0, 0.0, 0.0, _swiperWidth, _swiperWidth];
|
||||
offsetsY = [
|
||||
0.0,
|
||||
0.0,
|
||||
-5.0,
|
||||
-10.0,
|
||||
-15.0,
|
||||
-20.0,
|
||||
];
|
||||
} else {
|
||||
offsetsX = [
|
||||
0.0,
|
||||
0.0,
|
||||
5.0,
|
||||
10.0,
|
||||
15.0,
|
||||
20.0,
|
||||
];
|
||||
|
||||
offsetsY = [0.0, 0.0, 0.0, 0.0, _swiperHeight, _swiperHeight];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget _buildItem(int i, int realIndex, double animationValue) {
|
||||
final s = _getValue(scales, animationValue, i);
|
||||
final f = _getValue(offsetsX, animationValue, i);
|
||||
final fy = _getValue(offsetsY, animationValue, i);
|
||||
final o = _getValue(opacity, animationValue, i);
|
||||
final a = _getValue(rotates, animationValue, i);
|
||||
|
||||
final alignment = widget.scrollDirection == Axis.horizontal ? Alignment.bottomCenter : Alignment.centerLeft;
|
||||
|
||||
return Opacity(
|
||||
opacity: o,
|
||||
child: Transform.rotate(
|
||||
angle: a / 180.0,
|
||||
child: Transform.translate(
|
||||
key: ValueKey<int>(_currentIndex + i),
|
||||
offset: Offset(f, fy),
|
||||
child: Transform.scale(
|
||||
scale: s,
|
||||
alignment: alignment,
|
||||
child: SizedBox(
|
||||
width: widget.itemWidth ?? double.infinity,
|
||||
height: widget.itemHeight ?? double.infinity,
|
||||
child: widget.itemBuilder!(context, realIndex),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StackViewState extends _CustomLayoutStateBase<_StackSwiper> {
|
||||
late List<double> scales;
|
||||
late List<double> offsets;
|
||||
late List<double> opacity;
|
||||
|
||||
void _updateValues() {
|
||||
if (widget.scrollDirection == Axis.horizontal) {
|
||||
final space = (_swiperWidth - widget.itemWidth!) / 2;
|
||||
offsets = widget.axisDirection == AxisDirection.left
|
||||
? [-space, -space / 3 * 2, -space / 3, 0.0, _swiperWidth]
|
||||
: [_swiperWidth, 0.0, -space / 3, -space / 3 * 2, -space];
|
||||
} else {
|
||||
final space = (_swiperHeight - widget.itemHeight!) / 2;
|
||||
offsets = [-space, -space / 3 * 2, -space / 3, 0.0, _swiperHeight];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_StackSwiper oldWidget) {
|
||||
_updateValues();
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
void afterRender() {
|
||||
super.afterRender();
|
||||
final isRightSide = widget.axisDirection == AxisDirection.right;
|
||||
|
||||
//length of the values array below
|
||||
_animationCount = 5;
|
||||
|
||||
//Array below this line, '0' index is 1.0, which is the first item show in swiper.
|
||||
_startIndex = isRightSide ? -1 : -3;
|
||||
scales = isRightSide ? [1.0, 1.0, 0.9, 0.8, 0.7] : [0.7, 0.8, 0.9, 1.0, 1.0];
|
||||
opacity = isRightSide ? [1.0, 1.0, 1.0, 0.5, 0.0] : [0.0, 0.5, 1.0, 1.0, 1.0];
|
||||
|
||||
_updateValues();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget _buildItem(int i, int realIndex, double animationValue) {
|
||||
final s = _getValue(scales, animationValue, i);
|
||||
final f = _getValue(offsets, animationValue, i);
|
||||
final o = _getValue(opacity, animationValue, i);
|
||||
|
||||
final offset = widget.scrollDirection == Axis.horizontal
|
||||
? widget.axisDirection == AxisDirection.left
|
||||
? Offset(f, 0.0)
|
||||
: Offset(-f, 0.0)
|
||||
: Offset(0.0, f);
|
||||
|
||||
final alignment = widget.scrollDirection == Axis.horizontal
|
||||
? widget.axisDirection == AxisDirection.left
|
||||
? Alignment.centerLeft
|
||||
: Alignment.centerRight
|
||||
: Alignment.topCenter;
|
||||
|
||||
return Opacity(
|
||||
opacity: o,
|
||||
child: Transform.translate(
|
||||
key: ValueKey<int>(_currentIndex + i),
|
||||
offset: offset,
|
||||
child: Transform.scale(
|
||||
scale: s,
|
||||
alignment: alignment,
|
||||
child: SizedBox(
|
||||
width: widget.itemWidth ?? double.infinity,
|
||||
height: widget.itemHeight ?? double.infinity,
|
||||
child: widget.itemBuilder!(context, realIndex),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScaleAndFadeTransformer extends PageTransformer {
|
||||
ScaleAndFadeTransformer({double? fade = 0.3, double? scale = 0.8})
|
||||
: _fade = fade,
|
||||
_scale = scale;
|
||||
|
||||
final double? _scale;
|
||||
final double? _fade;
|
||||
|
||||
@override
|
||||
Widget transform(Widget child, TransformInfo info) {
|
||||
final position = info.position;
|
||||
var c = child;
|
||||
if (_scale != null) {
|
||||
final scaleFactor = (1 - position!.abs()) * (1 - _scale!);
|
||||
final scale = _scale! + scaleFactor;
|
||||
|
||||
c = Transform.scale(
|
||||
scale: scale,
|
||||
child: c,
|
||||
);
|
||||
}
|
||||
|
||||
if (_fade != null) {
|
||||
final fadeFactor = (1 - position!.abs()) * (1 - _fade!);
|
||||
final opacity = _fade! + fadeFactor;
|
||||
c = Opacity(
|
||||
opacity: opacity,
|
||||
child: c,
|
||||
);
|
||||
}
|
||||
|
||||
return c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/tools_base/widget/card_swiper/src/swiper_plugin.dart';
|
||||
|
||||
class SwiperControl extends SwiperPlugin {
|
||||
const SwiperControl({
|
||||
this.iconPrevious = Icons.arrow_back_ios,
|
||||
this.iconNext = Icons.arrow_forward_ios,
|
||||
this.color,
|
||||
this.disableColor,
|
||||
this.key,
|
||||
this.size = 30.0,
|
||||
this.padding = const EdgeInsets.all(5.0),
|
||||
});
|
||||
|
||||
///IconData for previous
|
||||
final IconData iconPrevious;
|
||||
|
||||
///iconData for next
|
||||
final IconData iconNext;
|
||||
|
||||
///icon size
|
||||
final double size;
|
||||
|
||||
///Icon normal color, The theme's [ThemeData.primaryColor] by default.
|
||||
final Color? color;
|
||||
|
||||
///if set loop=false on Swiper, this color will be used when swiper goto the last slide.
|
||||
///The theme's [ThemeData.disabledColor] by default.
|
||||
final Color? disableColor;
|
||||
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
final Key? key;
|
||||
|
||||
Widget buildButton({
|
||||
required SwiperPluginConfig? config,
|
||||
required Color color,
|
||||
required IconData iconData,
|
||||
required int quarterTurns,
|
||||
required bool previous,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () async {
|
||||
if (previous) {
|
||||
await config!.controller.previous(animation: true);
|
||||
} else {
|
||||
await config!.controller.next(animation: true);
|
||||
}
|
||||
},
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: RotatedBox(
|
||||
quarterTurns: quarterTurns,
|
||||
child: Icon(
|
||||
iconData,
|
||||
semanticLabel: previous ? 'Previous' : 'Next',
|
||||
size: size,
|
||||
color: color,
|
||||
))),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, SwiperPluginConfig config) {
|
||||
final themeData = Theme.of(context);
|
||||
|
||||
final color = this.color ?? themeData.primaryColor;
|
||||
final disableColor = this.disableColor ?? themeData.disabledColor;
|
||||
Color prevColor;
|
||||
Color nextColor;
|
||||
|
||||
if (config.loop) {
|
||||
prevColor = nextColor = color;
|
||||
} else {
|
||||
final next = config.activeIndex < config.itemCount - 1;
|
||||
final prev = config.activeIndex > 0;
|
||||
prevColor = prev ? color : disableColor;
|
||||
nextColor = next ? color : disableColor;
|
||||
}
|
||||
|
||||
Widget child;
|
||||
if (config.scrollDirection == Axis.horizontal) {
|
||||
child = Row(
|
||||
key: key,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
buildButton(
|
||||
config: config,
|
||||
color: prevColor,
|
||||
iconData: iconPrevious,
|
||||
quarterTurns: 0,
|
||||
previous: true,
|
||||
),
|
||||
buildButton(
|
||||
config: config,
|
||||
color: nextColor,
|
||||
iconData: iconNext,
|
||||
quarterTurns: 0,
|
||||
previous: false,
|
||||
)
|
||||
],
|
||||
);
|
||||
} else {
|
||||
child = Column(
|
||||
key: key,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
buildButton(
|
||||
config: config,
|
||||
color: prevColor,
|
||||
iconData: iconPrevious,
|
||||
quarterTurns: -3,
|
||||
previous: true,
|
||||
),
|
||||
buildButton(
|
||||
config: config,
|
||||
color: nextColor,
|
||||
iconData: iconNext,
|
||||
quarterTurns: -3,
|
||||
previous: false,
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox.expand(
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'swiper_plugin.dart';
|
||||
import 'transformer_page_view/index_controller.dart';
|
||||
|
||||
class SwipeIndexControllerEvent extends IndexControllerEventBase {
|
||||
SwipeIndexControllerEvent({
|
||||
required this.pos,
|
||||
required bool animation,
|
||||
}) : super(animation: animation);
|
||||
final double pos;
|
||||
}
|
||||
|
||||
class BuildIndexControllerEvent extends IndexControllerEventBase {
|
||||
BuildIndexControllerEvent({
|
||||
required bool animation,
|
||||
required this.config,
|
||||
}) : super(animation: animation);
|
||||
final SwiperPluginConfig config;
|
||||
}
|
||||
|
||||
class AutoPlaySwiperControllerEvent extends IndexControllerEventBase {
|
||||
AutoPlaySwiperControllerEvent({
|
||||
required bool animation,
|
||||
required this.autoplay,
|
||||
}) : super(animation: animation);
|
||||
|
||||
AutoPlaySwiperControllerEvent.start({
|
||||
required bool animation,
|
||||
}) : this(animation: animation, autoplay: true);
|
||||
AutoPlaySwiperControllerEvent.stop({
|
||||
required bool animation,
|
||||
}) : this(animation: animation, autoplay: false);
|
||||
final bool autoplay;
|
||||
}
|
||||
|
||||
class SwiperController extends IndexController {
|
||||
void startAutoplay({bool animation = true}) {
|
||||
event = AutoPlaySwiperControllerEvent.start(animation: animation);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void stopAutoplay({bool animation = true}) {
|
||||
event = AutoPlaySwiperControllerEvent.stop(animation: animation);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../card_swiper.dart';
|
||||
|
||||
class FractionPaginationBuilder extends SwiperPlugin {
|
||||
const FractionPaginationBuilder({
|
||||
this.color,
|
||||
this.fontSize = 20.0,
|
||||
this.key,
|
||||
this.activeColor,
|
||||
this.activeFontSize = 35.0,
|
||||
});
|
||||
|
||||
///color ,if set null , will be Theme.of(context).scaffoldBackgroundColor
|
||||
final Color? color;
|
||||
|
||||
///color when active,if set null , will be Theme.of(context).primaryColor
|
||||
final Color? activeColor;
|
||||
|
||||
////font size
|
||||
final double fontSize;
|
||||
|
||||
///font size when active
|
||||
final double activeFontSize;
|
||||
|
||||
final Key? key;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, SwiperPluginConfig? config) {
|
||||
final themeData = Theme.of(context);
|
||||
final activeColor = this.activeColor ?? themeData.primaryColor;
|
||||
final color = this.color ?? themeData.scaffoldBackgroundColor;
|
||||
|
||||
if (Axis.vertical == config!.scrollDirection) {
|
||||
return Column(
|
||||
key: key,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'${config.activeIndex + 1}',
|
||||
style: TextStyle(color: activeColor, fontSize: activeFontSize),
|
||||
),
|
||||
Text(
|
||||
'/',
|
||||
style: TextStyle(color: color, fontSize: fontSize),
|
||||
),
|
||||
Text(
|
||||
'${config.itemCount}',
|
||||
style: TextStyle(color: color, fontSize: fontSize),
|
||||
)
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
key: key,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'${config.activeIndex + 1}',
|
||||
style: TextStyle(color: activeColor, fontSize: activeFontSize),
|
||||
),
|
||||
Text(
|
||||
' / ${config.itemCount}',
|
||||
style: TextStyle(color: color, fontSize: fontSize),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RectSwiperPaginationBuilder extends SwiperPlugin {
|
||||
const RectSwiperPaginationBuilder({
|
||||
this.activeColor,
|
||||
this.color,
|
||||
this.key,
|
||||
this.size = const Size(10.0, 2.0),
|
||||
this.activeSize = const Size(10.0, 2.0),
|
||||
this.space = 3.0,
|
||||
});
|
||||
|
||||
///color when current index,if set null , will be Theme.of(context).primaryColor
|
||||
final Color? activeColor;
|
||||
|
||||
///,if set null , will be Theme.of(context).scaffoldBackgroundColor
|
||||
final Color? color;
|
||||
|
||||
///Size of the rect when activate
|
||||
final Size activeSize;
|
||||
|
||||
///Size of the rect
|
||||
final Size size;
|
||||
|
||||
/// Space between rects
|
||||
final double space;
|
||||
|
||||
final Key? key;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, SwiperPluginConfig config) {
|
||||
final themeData = Theme.of(context);
|
||||
final activeColor = this.activeColor ?? themeData.primaryColor;
|
||||
final color = this.color ?? themeData.scaffoldBackgroundColor;
|
||||
|
||||
final list = <Widget>[];
|
||||
|
||||
final itemCount = config.itemCount;
|
||||
final activeIndex = config.activeIndex;
|
||||
if (itemCount > 20) {
|
||||
log(
|
||||
'The itemCount is too big, we suggest use FractionPaginationBuilder '
|
||||
'instead of DotSwiperPaginationBuilder in this situation',
|
||||
);
|
||||
}
|
||||
|
||||
for (var i = 0; i < itemCount; ++i) {
|
||||
final active = i == activeIndex;
|
||||
final size = active ? activeSize : this.size;
|
||||
list.add(SizedBox(
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
child: Container(
|
||||
color: active ? activeColor : color,
|
||||
key: Key('pagination_$i'),
|
||||
margin: EdgeInsets.all(space),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
if (config.scrollDirection == Axis.vertical) {
|
||||
return Column(
|
||||
key: key,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: list,
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
key: key,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: list,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DotSwiperPaginationBuilder extends SwiperPlugin {
|
||||
const DotSwiperPaginationBuilder({
|
||||
this.activeColor,
|
||||
this.color,
|
||||
this.key,
|
||||
this.size = 10.0,
|
||||
this.activeSize = 10.0,
|
||||
this.space = 3.0,
|
||||
});
|
||||
|
||||
///color when current index,if set null , will be Theme.of(context).primaryColor
|
||||
final Color? activeColor;
|
||||
|
||||
///,if set null , will be Theme.of(context).scaffoldBackgroundColor
|
||||
final Color? color;
|
||||
|
||||
///Size of the dot when activate
|
||||
final double activeSize;
|
||||
|
||||
///Size of the dot
|
||||
final double size;
|
||||
|
||||
/// Space between dots
|
||||
final double space;
|
||||
|
||||
final Key? key;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, SwiperPluginConfig config) {
|
||||
if (config.itemCount > 20) {
|
||||
log(
|
||||
'The itemCount is too big, we suggest use FractionPaginationBuilder '
|
||||
'instead of DotSwiperPaginationBuilder in this situation',
|
||||
);
|
||||
}
|
||||
var activeColor = this.activeColor;
|
||||
var color = this.color;
|
||||
|
||||
if (activeColor == null || color == null) {
|
||||
final themeData = Theme.of(context);
|
||||
activeColor = this.activeColor ?? themeData.primaryColor;
|
||||
color = this.color ?? themeData.scaffoldBackgroundColor;
|
||||
}
|
||||
|
||||
if (config.indicatorLayout != PageIndicatorLayout.NONE &&
|
||||
config.layout == SwiperLayout.DEFAULT) {
|
||||
return PageIndicator(
|
||||
count: config.itemCount,
|
||||
controller: config.pageController!,
|
||||
layout: config.indicatorLayout,
|
||||
size: size,
|
||||
activeColor: activeColor,
|
||||
color: color,
|
||||
space: space,
|
||||
);
|
||||
}
|
||||
|
||||
final list = <Widget>[];
|
||||
|
||||
final itemCount = config.itemCount;
|
||||
final activeIndex = config.activeIndex;
|
||||
|
||||
for (var i = 0; i < itemCount; ++i) {
|
||||
final active = i == activeIndex;
|
||||
list.add(Container(
|
||||
key: Key('pagination_$i'),
|
||||
margin: EdgeInsets.all(space),
|
||||
child: ClipOval(
|
||||
child: Container(
|
||||
color: active ? activeColor : color,
|
||||
width: active ? activeSize : size,
|
||||
height: active ? activeSize : size,
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
if (config.scrollDirection == Axis.vertical) {
|
||||
return Column(
|
||||
key: key,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: list,
|
||||
);
|
||||
} else {
|
||||
return Row(
|
||||
key: key,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: list,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typedef SwiperPaginationBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
SwiperPluginConfig config,
|
||||
);
|
||||
|
||||
class SwiperCustomPagination extends SwiperPlugin {
|
||||
const SwiperCustomPagination({required this.builder});
|
||||
|
||||
final SwiperPaginationBuilder builder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, SwiperPluginConfig config) {
|
||||
return builder(context, config);
|
||||
}
|
||||
}
|
||||
|
||||
class SwiperPagination extends SwiperPlugin {
|
||||
const SwiperPagination({
|
||||
this.alignment,
|
||||
this.key,
|
||||
this.margin = const EdgeInsets.all(10.0),
|
||||
this.builder = SwiperPagination.dots,
|
||||
});
|
||||
|
||||
/// dot style pagination
|
||||
static const SwiperPlugin dots = DotSwiperPaginationBuilder();
|
||||
|
||||
/// fraction style pagination
|
||||
static const SwiperPlugin fraction = FractionPaginationBuilder();
|
||||
|
||||
static const SwiperPlugin rect = RectSwiperPaginationBuilder();
|
||||
|
||||
/// Alignment.bottomCenter by default when scrollDirection== Axis.horizontal
|
||||
/// Alignment.centerRight by default when scrollDirection== Axis.vertical
|
||||
final Alignment? alignment;
|
||||
|
||||
/// Distance between pagination and the container
|
||||
final EdgeInsetsGeometry margin;
|
||||
|
||||
/// Build the widget
|
||||
final SwiperPlugin builder;
|
||||
|
||||
final Key? key;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, SwiperPluginConfig config) {
|
||||
final defaultAlignment = config.scrollDirection == Axis.horizontal
|
||||
? Alignment.bottomCenter
|
||||
: Alignment.centerRight;
|
||||
Widget child = Container(
|
||||
margin: margin,
|
||||
child: builder.build(context, config),
|
||||
);
|
||||
if (!config.outer!) {
|
||||
child = Align(
|
||||
key: key,
|
||||
alignment: alignment ?? defaultAlignment,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
return child;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../card_swiper.dart';
|
||||
|
||||
/// plugin to display swiper components
|
||||
///
|
||||
abstract class SwiperPlugin {
|
||||
const SwiperPlugin();
|
||||
|
||||
Widget build(BuildContext context, SwiperPluginConfig config);
|
||||
}
|
||||
|
||||
class SwiperPluginConfig {
|
||||
const SwiperPluginConfig({
|
||||
required this.scrollDirection,
|
||||
required this.controller,
|
||||
required this.activeIndex,
|
||||
required this.itemCount,
|
||||
this.axisDirection,
|
||||
this.indicatorLayout,
|
||||
this.outer,
|
||||
this.pageController,
|
||||
this.layout,
|
||||
this.loop = false,
|
||||
});
|
||||
|
||||
final Axis scrollDirection;
|
||||
final AxisDirection? axisDirection;
|
||||
final SwiperController controller;
|
||||
final int activeIndex;
|
||||
final int itemCount;
|
||||
final PageIndicatorLayout? indicatorLayout;
|
||||
final bool loop;
|
||||
final bool? outer;
|
||||
final PageController? pageController;
|
||||
final SwiperLayout? layout;
|
||||
}
|
||||
|
||||
class SwiperPluginView extends StatelessWidget {
|
||||
const SwiperPluginView({
|
||||
Key? key,
|
||||
required this.plugin,
|
||||
required this.config,
|
||||
}) : super(key: key);
|
||||
|
||||
final SwiperPlugin plugin;
|
||||
final SwiperPluginConfig config;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return plugin.build(context, config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
abstract class IndexControllerEventBase {
|
||||
IndexControllerEventBase({
|
||||
required this.animation,
|
||||
});
|
||||
|
||||
final bool animation;
|
||||
|
||||
final completer = Completer<void>();
|
||||
Future<void> get future => completer.future;
|
||||
void complete() {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mixin TargetedPositionControllerEvent on IndexControllerEventBase {
|
||||
double get targetPosition;
|
||||
}
|
||||
mixin StepBasedIndexControllerEvent on TargetedPositionControllerEvent {
|
||||
int get step;
|
||||
int calcNextIndex({
|
||||
required int currentIndex,
|
||||
required int itemCount,
|
||||
required bool loop,
|
||||
required bool reverse,
|
||||
}) {
|
||||
var cIndex = currentIndex;
|
||||
if (reverse) {
|
||||
cIndex -= step;
|
||||
} else {
|
||||
cIndex += step;
|
||||
}
|
||||
|
||||
if (!loop) {
|
||||
if (cIndex >= itemCount) {
|
||||
cIndex = itemCount - 1;
|
||||
} else if (cIndex < 0) {
|
||||
cIndex = 0;
|
||||
}
|
||||
}
|
||||
return cIndex;
|
||||
}
|
||||
}
|
||||
|
||||
class NextIndexControllerEvent extends IndexControllerEventBase
|
||||
with TargetedPositionControllerEvent, StepBasedIndexControllerEvent {
|
||||
NextIndexControllerEvent({
|
||||
required bool animation,
|
||||
}) : super(
|
||||
animation: animation,
|
||||
);
|
||||
|
||||
@override
|
||||
int get step => 1;
|
||||
|
||||
@override
|
||||
double get targetPosition => 0;
|
||||
}
|
||||
|
||||
class PrevIndexControllerEvent extends IndexControllerEventBase
|
||||
with TargetedPositionControllerEvent, StepBasedIndexControllerEvent {
|
||||
PrevIndexControllerEvent({
|
||||
required bool animation,
|
||||
}) : super(
|
||||
animation: animation,
|
||||
);
|
||||
@override
|
||||
int get step => -1;
|
||||
|
||||
@override
|
||||
double get targetPosition => 1;
|
||||
}
|
||||
|
||||
class MoveIndexControllerEvent extends IndexControllerEventBase
|
||||
with TargetedPositionControllerEvent {
|
||||
MoveIndexControllerEvent({
|
||||
required this.newIndex,
|
||||
required this.oldIndex,
|
||||
required bool animation,
|
||||
}) : super(
|
||||
animation: animation,
|
||||
);
|
||||
final int newIndex;
|
||||
final int oldIndex;
|
||||
@override
|
||||
double get targetPosition => newIndex > oldIndex ? 1 : 0;
|
||||
}
|
||||
|
||||
class IndexController extends ChangeNotifier {
|
||||
IndexControllerEventBase? event;
|
||||
int index = 0;
|
||||
Future<void> move(int index, {bool animation = true}) {
|
||||
final e = event = MoveIndexControllerEvent(
|
||||
animation: animation,
|
||||
newIndex: index,
|
||||
oldIndex: this.index,
|
||||
);
|
||||
notifyListeners();
|
||||
return e.future;
|
||||
}
|
||||
|
||||
Future<void> next({bool animation = true}) {
|
||||
final e = event = NextIndexControllerEvent(animation: animation);
|
||||
notifyListeners();
|
||||
return e.future;
|
||||
}
|
||||
|
||||
Future<void> previous({bool animation = true}) {
|
||||
final e = event = PrevIndexControllerEvent(animation: animation);
|
||||
notifyListeners();
|
||||
return e.future;
|
||||
}
|
||||
}
|
||||
+611
@@ -0,0 +1,611 @@
|
||||
/// transformer page view library
|
||||
library transformer_page_view;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'index_controller.dart';
|
||||
|
||||
///
|
||||
/// NOTICE::
|
||||
///
|
||||
/// In order to make package smaller,currently we're not supporting any build-in page transformers
|
||||
/// You can find build in transforms here:
|
||||
///
|
||||
///
|
||||
///
|
||||
|
||||
const int kMaxValue = 2000000000;
|
||||
const int kMiddleValue = 1000000000;
|
||||
|
||||
/// Default auto play transition duration (in millisecond)
|
||||
const int kDefaultTransactionDuration = 300;
|
||||
|
||||
class TransformInfo {
|
||||
TransformInfo({
|
||||
this.index,
|
||||
this.position,
|
||||
this.width,
|
||||
this.height,
|
||||
this.activeIndex,
|
||||
required this.fromIndex,
|
||||
this.forward,
|
||||
this.done,
|
||||
this.viewportFraction,
|
||||
this.scrollDirection,
|
||||
});
|
||||
|
||||
/// The `width` of the `TransformerPageView`
|
||||
final double? width;
|
||||
|
||||
/// The `height` of the `TransformerPageView`
|
||||
final double? height;
|
||||
|
||||
/// The `position` of the widget pass to [PageTransformer.transform]
|
||||
/// A `position` describes how visible the widget is.
|
||||
/// The widget in the center of the screen' which is full visible, position is 0.0.
|
||||
/// The widget in the left ,may be hidden, of the screen's position is less than 0.0, -1.0 when out of the screen.
|
||||
/// The widget in the right ,may be hidden, of the screen's position is greater than 0.0, 1.0 when out of the screen
|
||||
///
|
||||
///
|
||||
final double? position;
|
||||
|
||||
/// The `index` of the widget pass to [PageTransformer.transform]
|
||||
final int? index;
|
||||
|
||||
/// The `activeIndex` of the PageView
|
||||
final int? activeIndex;
|
||||
|
||||
/// The `activeIndex` of the PageView, from user start to swipe
|
||||
/// It will change when user end drag
|
||||
final int fromIndex;
|
||||
|
||||
/// Next `index` is greater than this `index`
|
||||
final bool? forward;
|
||||
|
||||
/// User drag is done.
|
||||
final bool? done;
|
||||
|
||||
/// Same as [TransformerPageView.viewportFraction]
|
||||
final double? viewportFraction;
|
||||
|
||||
/// Copy from [TransformerPageView.scrollDirection]
|
||||
final Axis? scrollDirection;
|
||||
}
|
||||
|
||||
abstract class PageTransformer {
|
||||
PageTransformer({this.reverse = false});
|
||||
|
||||
///
|
||||
final bool reverse;
|
||||
|
||||
/// Return a transformed widget, based on child and TransformInfo
|
||||
Widget transform(Widget child, TransformInfo info);
|
||||
}
|
||||
|
||||
typedef PageTransformerBuilderCallback = Widget Function(
|
||||
Widget child,
|
||||
TransformInfo info,
|
||||
);
|
||||
|
||||
class PageTransformerBuilder extends PageTransformer {
|
||||
PageTransformerBuilder({bool reverse = false, required this.builder})
|
||||
: super(reverse: reverse);
|
||||
|
||||
final PageTransformerBuilderCallback builder;
|
||||
|
||||
@override
|
||||
Widget transform(Widget child, TransformInfo info) {
|
||||
return builder(child, info);
|
||||
}
|
||||
}
|
||||
|
||||
class TransformerPageController extends PageController {
|
||||
TransformerPageController({
|
||||
int initialPage = 0,
|
||||
bool keepPage = true,
|
||||
double viewportFraction = 1.0,
|
||||
this.loop = false,
|
||||
this.itemCount = 0,
|
||||
this.reverse = false,
|
||||
}) : super(
|
||||
initialPage: TransformerPageController._getRealIndexFromRenderIndex(
|
||||
initialPage, loop, itemCount, reverse),
|
||||
keepPage: keepPage,
|
||||
viewportFraction: viewportFraction);
|
||||
|
||||
final bool loop;
|
||||
final int itemCount;
|
||||
final bool reverse;
|
||||
|
||||
int getRenderIndexFromRealIndex(num index) {
|
||||
return _getRenderIndexFromRealIndex(index, loop, itemCount, reverse);
|
||||
}
|
||||
|
||||
int? getRealItemCount() {
|
||||
if (itemCount == 0) return 0;
|
||||
return loop ? itemCount + kMaxValue : itemCount;
|
||||
}
|
||||
|
||||
static int _getRenderIndexFromRealIndex(
|
||||
num index,
|
||||
bool loop,
|
||||
int itemCount,
|
||||
bool reverse,
|
||||
) {
|
||||
if (itemCount == 0) return 0;
|
||||
int renderIndex;
|
||||
if (loop) {
|
||||
renderIndex = (index - kMiddleValue).toInt();
|
||||
renderIndex = renderIndex % itemCount;
|
||||
if (renderIndex < 0) {
|
||||
renderIndex += itemCount;
|
||||
}
|
||||
} else {
|
||||
renderIndex = index.toInt();
|
||||
}
|
||||
if (reverse) {
|
||||
renderIndex = itemCount - renderIndex - 1;
|
||||
}
|
||||
|
||||
return renderIndex;
|
||||
}
|
||||
|
||||
double get realPage => super.page ?? 0.0;
|
||||
|
||||
static double? _getRenderPageFromRealPage(
|
||||
double page,
|
||||
bool loop,
|
||||
int itemCount,
|
||||
bool reverse,
|
||||
) {
|
||||
double? renderPage;
|
||||
if (loop) {
|
||||
renderPage = page - kMiddleValue;
|
||||
renderPage = renderPage % itemCount;
|
||||
if (renderPage < 0) {
|
||||
renderPage += itemCount;
|
||||
}
|
||||
} else {
|
||||
renderPage = page;
|
||||
}
|
||||
if (reverse) {
|
||||
renderPage = itemCount - renderPage - 1;
|
||||
}
|
||||
|
||||
return renderPage;
|
||||
}
|
||||
|
||||
@override
|
||||
double? get page {
|
||||
return loop
|
||||
? _getRenderPageFromRealPage(realPage, loop, itemCount, reverse)
|
||||
: realPage;
|
||||
}
|
||||
|
||||
int getRealIndexFromRenderIndex(num index) {
|
||||
return _getRealIndexFromRenderIndex(index, loop, itemCount, reverse);
|
||||
}
|
||||
|
||||
static int _getRealIndexFromRenderIndex(
|
||||
num index, bool loop, int itemCount, bool reverse) {
|
||||
var result = reverse ? itemCount - index - 1 as int : index as int;
|
||||
if (loop) {
|
||||
result += kMiddleValue;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class TransformerPageView extends StatefulWidget {
|
||||
/// Creates a scrollable list that works page by page using widgets that are
|
||||
/// created on demand.
|
||||
///
|
||||
/// This constructor is appropriate for page views with a large (or infinite)
|
||||
/// number of children because the builder is called only for those children
|
||||
/// that are actually visible.
|
||||
///
|
||||
/// Providing a non-null [itemCount] lets the [PageView] compute the maximum
|
||||
/// scroll extent.
|
||||
///
|
||||
/// [itemBuilder] will be called only with indices greater than or equal to
|
||||
/// zero and less than [itemCount].
|
||||
const TransformerPageView({
|
||||
Key? key,
|
||||
this.index,
|
||||
Duration? duration,
|
||||
this.curve = Curves.ease,
|
||||
this.viewportFraction = 1.0,
|
||||
required this.loop,
|
||||
this.scrollDirection = Axis.horizontal,
|
||||
this.physics,
|
||||
this.pageSnapping = true,
|
||||
this.onPageChanged,
|
||||
this.controller,
|
||||
this.transformer,
|
||||
this.allowImplicitScrolling = false,
|
||||
this.itemBuilder,
|
||||
this.pageController,
|
||||
required this.itemCount,
|
||||
}) : assert(itemCount == 0 || itemBuilder != null || transformer != null),
|
||||
duration = duration ??
|
||||
const Duration(milliseconds: kDefaultTransactionDuration),
|
||||
super(key: key);
|
||||
|
||||
factory TransformerPageView.children({
|
||||
Key? key,
|
||||
int? index,
|
||||
Duration? duration,
|
||||
Curve curve = Curves.ease,
|
||||
double viewportFraction = 1.0,
|
||||
bool loop = false,
|
||||
Axis scrollDirection = Axis.horizontal,
|
||||
ScrollPhysics? physics,
|
||||
bool pageSnapping = true,
|
||||
ValueChanged<int?>? onPageChanged,
|
||||
IndexController? controller,
|
||||
PageTransformer? transformer,
|
||||
bool allowImplicitScrolling = false,
|
||||
required List<Widget> children,
|
||||
TransformerPageController? pageController,
|
||||
}) {
|
||||
return TransformerPageView(
|
||||
itemCount: children.length,
|
||||
itemBuilder: (context, index) {
|
||||
return children[index];
|
||||
},
|
||||
pageController: pageController,
|
||||
transformer: transformer,
|
||||
pageSnapping: pageSnapping,
|
||||
key: key,
|
||||
index: index,
|
||||
loop: loop,
|
||||
duration: duration,
|
||||
curve: curve,
|
||||
viewportFraction: viewportFraction,
|
||||
scrollDirection: scrollDirection,
|
||||
physics: physics,
|
||||
allowImplicitScrolling: allowImplicitScrolling,
|
||||
onPageChanged: onPageChanged,
|
||||
controller: controller,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a `transformed` widget base on the widget that has been passed to the [PageTransformer.transform].
|
||||
/// See [TransformInfo]
|
||||
///
|
||||
final PageTransformer? transformer;
|
||||
|
||||
/// Same as [PageView.scrollDirection]
|
||||
///
|
||||
/// Defaults to [Axis.horizontal].
|
||||
final Axis scrollDirection;
|
||||
|
||||
/// Same as [PageView.physics]
|
||||
final ScrollPhysics? physics;
|
||||
|
||||
/// Set to false to disable page snapping, useful for custom scroll behavior.
|
||||
/// Same as [PageView.pageSnapping]
|
||||
final bool pageSnapping;
|
||||
|
||||
/// Called whenever the page in the center of the viewport changes.
|
||||
/// Same as [PageView.onPageChanged]
|
||||
final ValueChanged<int>? onPageChanged;
|
||||
|
||||
final IndexedWidgetBuilder? itemBuilder;
|
||||
|
||||
// See [IndexController.mode],[IndexController.next],[IndexController.previous]
|
||||
final IndexController? controller;
|
||||
|
||||
/// Animation duration
|
||||
final Duration duration;
|
||||
|
||||
/// Animation curve
|
||||
final Curve curve;
|
||||
|
||||
final TransformerPageController? pageController;
|
||||
|
||||
/// Set true to open infinity loop mode.
|
||||
final bool loop;
|
||||
|
||||
/// This value is only valid when `pageController` is not set,
|
||||
final int itemCount;
|
||||
|
||||
/// This value is only valid when `pageController` is not set,
|
||||
final double viewportFraction;
|
||||
|
||||
/// If not set, it is controlled by this widget.
|
||||
final int? index;
|
||||
|
||||
final bool allowImplicitScrolling;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _TransformerPageViewState();
|
||||
|
||||
static int getRealIndexFromRenderIndex({
|
||||
required bool reverse,
|
||||
int index = 0,
|
||||
int itemCount = 0,
|
||||
required bool loop,
|
||||
}) {
|
||||
var initPage = reverse ? (itemCount - index - 1) : index;
|
||||
if (loop) {
|
||||
initPage += kMiddleValue;
|
||||
}
|
||||
return initPage;
|
||||
}
|
||||
|
||||
static PageController createPageController({
|
||||
required bool reverse,
|
||||
int index = 0,
|
||||
int itemCount = 0,
|
||||
required bool loop,
|
||||
required double viewportFraction,
|
||||
}) {
|
||||
return PageController(
|
||||
initialPage: getRealIndexFromRenderIndex(
|
||||
reverse: reverse,
|
||||
index: index,
|
||||
itemCount: itemCount,
|
||||
loop: loop,
|
||||
),
|
||||
viewportFraction: viewportFraction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TransformerPageViewState extends State<TransformerPageView> {
|
||||
Size? _size;
|
||||
int _activeIndex = 0;
|
||||
late double _currentPixels;
|
||||
bool _done = false;
|
||||
|
||||
///This value will not change until user end drag.
|
||||
late int _fromIndex;
|
||||
|
||||
PageTransformer? _transformer;
|
||||
|
||||
late TransformerPageController _pageController;
|
||||
|
||||
Widget _buildItemNormal(BuildContext context, int index) {
|
||||
final renderIndex = _pageController.getRenderIndexFromRealIndex(index);
|
||||
return widget.itemBuilder!(context, renderIndex);
|
||||
}
|
||||
|
||||
Widget _buildItem(BuildContext context, int index) {
|
||||
return AnimatedBuilder(
|
||||
animation: _pageController,
|
||||
builder: (c, w) {
|
||||
final renderIndex =
|
||||
_pageController.getRenderIndexFromRealIndex(index);
|
||||
final child = widget.itemBuilder?.call(context, renderIndex) ??
|
||||
const SizedBox.shrink();
|
||||
if (_size == null) {
|
||||
return child;
|
||||
}
|
||||
|
||||
double position;
|
||||
|
||||
final page = _pageController.realPage;
|
||||
if (_transformer!.reverse) {
|
||||
position = page - index;
|
||||
} else {
|
||||
position = index - page;
|
||||
}
|
||||
position *= widget.viewportFraction;
|
||||
|
||||
final info = TransformInfo(
|
||||
index: renderIndex,
|
||||
width: _size!.width,
|
||||
height: _size!.height,
|
||||
position: position.clamp(-1.0, 1.0),
|
||||
activeIndex:
|
||||
_pageController.getRenderIndexFromRealIndex(_activeIndex),
|
||||
fromIndex: _fromIndex,
|
||||
forward: _pageController.position.pixels - _currentPixels >= 0,
|
||||
done: _done,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
viewportFraction: widget.viewportFraction,
|
||||
);
|
||||
|
||||
return _transformer!.transform(child, info);
|
||||
});
|
||||
}
|
||||
|
||||
double? _calcCurrentPixels() {
|
||||
_currentPixels = _pageController.getRenderIndexFromRealIndex(_activeIndex) *
|
||||
_pageController.position.viewportDimension *
|
||||
widget.viewportFraction;
|
||||
|
||||
// print("activeIndex:$_activeIndex , pix:$_currentPixels");
|
||||
|
||||
return _currentPixels;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final builder = _transformer == null ? _buildItemNormal : _buildItem;
|
||||
final child = PageView.builder(
|
||||
allowImplicitScrolling: widget.allowImplicitScrolling,
|
||||
itemBuilder: builder,
|
||||
itemCount: _pageController.getRealItemCount(),
|
||||
onPageChanged: _onIndexChanged,
|
||||
controller: _pageController,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
physics: widget.physics,
|
||||
pageSnapping: widget.pageSnapping,
|
||||
reverse: _pageController.reverse,
|
||||
);
|
||||
if (_transformer == null) {
|
||||
return child;
|
||||
}
|
||||
return NotificationListener(
|
||||
onNotification: (notification) {
|
||||
if (notification is ScrollStartNotification) {
|
||||
_calcCurrentPixels();
|
||||
_done = false;
|
||||
_fromIndex = _activeIndex;
|
||||
} else if (notification is ScrollEndNotification) {
|
||||
_calcCurrentPixels();
|
||||
_fromIndex = _activeIndex;
|
||||
_done = true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
void _onIndexChanged(int index) {
|
||||
_activeIndex = index;
|
||||
widget.onPageChanged
|
||||
?.call(_pageController.getRenderIndexFromRealIndex(index));
|
||||
}
|
||||
|
||||
void _onGetSize(Duration _) {
|
||||
if (!mounted) return;
|
||||
Size? size;
|
||||
|
||||
final renderObject = context.findRenderObject();
|
||||
if (renderObject != null) {
|
||||
final bounds = renderObject.paintBounds;
|
||||
size = bounds.size;
|
||||
}
|
||||
_calcCurrentPixels();
|
||||
onGetSize(size);
|
||||
}
|
||||
|
||||
void onGetSize(Size? size) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_size = size;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
IndexController? _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_transformer = widget.transformer;
|
||||
// int index = widget.index ?? 0;
|
||||
_pageController = widget.pageController ??
|
||||
TransformerPageController(
|
||||
initialPage: widget.index ?? 0,
|
||||
itemCount: widget.itemCount,
|
||||
loop: widget.loop,
|
||||
reverse: widget.transformer?.reverse ?? false,
|
||||
);
|
||||
// int initPage = _getRealIndexFromRenderIndex(index);
|
||||
// _pageController = PageController(initialPage: initPage,viewportFraction: widget.viewportFraction);
|
||||
_fromIndex = _activeIndex = _pageController.initialPage;
|
||||
|
||||
_controller = widget.controller;
|
||||
_controller?.addListener(onChangeNotifier);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(TransformerPageView oldWidget) {
|
||||
_transformer = widget.transformer;
|
||||
final index = widget.index ?? 0;
|
||||
var created = false;
|
||||
if (_pageController != widget.pageController) {
|
||||
if (widget.pageController != null) {
|
||||
_pageController = widget.pageController!;
|
||||
} else {
|
||||
created = true;
|
||||
_pageController = TransformerPageController(
|
||||
initialPage: widget.index ?? 0,
|
||||
itemCount: widget.itemCount,
|
||||
loop: widget.loop,
|
||||
reverse: widget.transformer?.reverse ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (_pageController.getRenderIndexFromRealIndex(_activeIndex) != index) {
|
||||
_fromIndex = _activeIndex = _pageController.initialPage;
|
||||
if (!created) {
|
||||
final initPage = _pageController.getRealIndexFromRenderIndex(index);
|
||||
if (_pageController.hasClients) {
|
||||
unawaited(_pageController.animateToPage(
|
||||
initPage,
|
||||
duration: widget.duration,
|
||||
curve: widget.curve,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (_transformer != null) {
|
||||
_ambiguate(WidgetsBinding.instance)!.addPostFrameCallback(_onGetSize);
|
||||
}
|
||||
|
||||
if (_controller != widget.controller) {
|
||||
_controller?.removeListener(onChangeNotifier);
|
||||
_controller = widget.controller;
|
||||
_controller?.addListener(onChangeNotifier);
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
if (_transformer != null) {
|
||||
_ambiguate(WidgetsBinding.instance)!.addPostFrameCallback(_onGetSize);
|
||||
}
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
Future<void> onChangeNotifier() async {
|
||||
final controller = widget.controller!;
|
||||
final event = controller.event;
|
||||
int index;
|
||||
if (event == null) return;
|
||||
if (event is MoveIndexControllerEvent) {
|
||||
index = _pageController.getRealIndexFromRenderIndex(event.newIndex);
|
||||
} else if (event is StepBasedIndexControllerEvent) {
|
||||
index = event.calcNextIndex(
|
||||
currentIndex: _activeIndex,
|
||||
itemCount: _pageController.itemCount,
|
||||
loop: _pageController.loop,
|
||||
reverse: _pageController.reverse,
|
||||
);
|
||||
} else {
|
||||
//ignore other events
|
||||
return;
|
||||
}
|
||||
if (_pageController.hasClients) {
|
||||
if (event.animation) {
|
||||
await _pageController
|
||||
.animateToPage(
|
||||
index,
|
||||
duration: widget.duration,
|
||||
curve: widget.curve,
|
||||
)
|
||||
.whenComplete(event.complete);
|
||||
} else {
|
||||
event.complete();
|
||||
}
|
||||
} else {
|
||||
event.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.removeListener(onChangeNotifier);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Ref: https://docs.flutter.dev/development/tools_base/sdk/release-notes/release-notes-3.0.0#your-code
|
||||
/// This allows a value of type T or T?
|
||||
/// to be treated as a value of type T?.
|
||||
///
|
||||
/// We use this so that APIs that have become
|
||||
/// non-nullable can still be used with `!` and `?`
|
||||
/// to support older versions of the API as well.
|
||||
T? _ambiguate<T>(T? value) => value;
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/common_dialog.dart';
|
||||
|
||||
/// 全站统一的提示/确认弹窗:标题 + 分割线 + 正文 + 副文案 + 灰「取消」红「确定」双按钮。
|
||||
/// 壳走 [CommonDialog],业务结果一律用返回值传(点确定=true),别在弹窗里塞跳转逻辑。
|
||||
/// 统一走 [show],别自己套 Get.dialog——barrierDismissible 要同时给到两层才生效。
|
||||
class CommonAlert extends StatelessWidget {
|
||||
final String title;
|
||||
final String? content; //正文,16/半透明白
|
||||
final String? subContent; //副文案,接在正文下方
|
||||
final bool showDivider; //标题下的分割线
|
||||
final bool showCancel; //false = 只有一个确定按钮
|
||||
final String cancelText;
|
||||
final String confirmText;
|
||||
final bool barrierDismissible; //false = 只能点按钮,见 CommonDialog 同名参数
|
||||
|
||||
const CommonAlert({
|
||||
super.key,
|
||||
this.title = '温馨提示',
|
||||
this.content,
|
||||
this.subContent,
|
||||
this.showDivider = true,
|
||||
this.showCancel = true,
|
||||
this.cancelText = '取消',
|
||||
this.confirmText = '确定',
|
||||
this.barrierDismissible = true,
|
||||
});
|
||||
|
||||
/// 弹出并等待结果:true = 点了确定,点取消/遮罩/返回键都是 false。
|
||||
/// [barrierDismissible] 传 false 则必须点按钮才能关(权限、强制重试这类场景)
|
||||
static Future<bool> show({
|
||||
String title = '温馨提示',
|
||||
String? content,
|
||||
String? subContent,
|
||||
bool showDivider = true,
|
||||
bool showCancel = true,
|
||||
String cancelText = '取消',
|
||||
String confirmText = '确定',
|
||||
bool barrierDismissible = true,
|
||||
}) async {
|
||||
final res = await Get.dialog<bool>(
|
||||
CommonAlert(
|
||||
title: title,
|
||||
content: content,
|
||||
subContent: subContent,
|
||||
showDivider: showDivider,
|
||||
showCancel: showCancel,
|
||||
cancelText: cancelText,
|
||||
confirmText: confirmText,
|
||||
barrierDismissible: barrierDismissible,
|
||||
),
|
||||
barrierDismissible: barrierDismissible,
|
||||
);
|
||||
return res ?? false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CommonDialog(
|
||||
//点正文不关:结果只认按钮,误触关掉会被当成「取消」
|
||||
canTapClose: false,
|
||||
barrierDismissible: barrierDismissible,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .9),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (showDivider) ...[
|
||||
12.sizeBoxH,
|
||||
0.5.line,
|
||||
],
|
||||
if (content?.isNotEmpty == true) ...[
|
||||
12.sizeBoxH,
|
||||
Text(
|
||||
content!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .5),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
if (subContent?.isNotEmpty == true) ...[
|
||||
20.sizeBoxH,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
subContent!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .5), fontSize: 16),
|
||||
),
|
||||
),
|
||||
],
|
||||
31.sizeBoxH,
|
||||
SizedBox(
|
||||
height: 44,
|
||||
child: Row(
|
||||
children: [
|
||||
if (showCancel) ...[
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.back(result: false),
|
||||
child: Container(
|
||||
width: 112,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: .1),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
cancelText,
|
||||
style: const TextStyle(
|
||||
color: Color(0xff989898),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
12.sizeBoxW,
|
||||
],
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.back(result: true),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.actionRed,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
confirmText,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
/// 通用居中弹窗壳:背景高斯模糊 + 金棕渐变卡片,内容由调用方传。
|
||||
/// 弹出走 Get.dialog,关闭统一 Get.back()
|
||||
class CommonDialog extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry margin;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
/// 点卡片内容区能不能关:默认能(点空白处的手势冒泡给外层)。
|
||||
/// 带输入框/需要用户明确选择的弹窗传 false,否则点一下正文就误关了
|
||||
final bool canTapClose;
|
||||
|
||||
/// 点卡片外的模糊区能不能关。
|
||||
/// ⚠️ 本组件铺满全屏并吃掉点击,[Get.dialog] 的 barrierDismissible 到不了这层,
|
||||
/// 要做「必须点按钮」的弹窗只能靠这个参数
|
||||
final bool barrierDismissible;
|
||||
|
||||
const CommonDialog({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.margin = const EdgeInsets.symmetric(horizontal: 32),
|
||||
this.padding = const EdgeInsets.fromLTRB(24, 32, 24, 24),
|
||||
this.canTapClose = true,
|
||||
this.barrierDismissible = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent, //别设成实色,渲染会有延迟
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: barrierDismissible ? Get.back : null, //点模糊背景关闭;null 则整层只是吃掉点击
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
//空回调=在手势竞技场吃掉这一下,外层收不到就关不掉;给 null 则让外层关
|
||||
onTap: canTapClose ? null : () {},
|
||||
child: Container(
|
||||
margin: margin,
|
||||
padding: padding,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(colors: [Color(0xff4d2817), Color(0xff12110f), Color(0xff302814)]),
|
||||
border: Border.fromBorderSide(BorderSide(color: Color(0xff61563a))),
|
||||
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 「加入购物车」式飞行动画:把某个组件的副本从原位置缩小飞到目标位置。
|
||||
///
|
||||
/// 挂在 Overlay 上而不是弹窗内部——先测量、再关弹窗、最后飞,这样蒙层干净消失,
|
||||
/// 飞行层浮在真实页面之上;放弹窗里做的话背景遮罩会一直压着,不像"收进去"。
|
||||
class FlyToOverlay {
|
||||
FlyToOverlay._();
|
||||
|
||||
/// [context] 调用方(弹窗)的 context,用来找根 Overlay —— 不能用 Get.overlayContext,
|
||||
/// 那拿到的是 Overlay 自身的 context,而 Overlay.of 只往祖先找,必然抛 "No Overlay widget found"
|
||||
/// [sourceKey] 起飞组件(**必须在关闭弹窗前调用**,否则拿不到位置)
|
||||
/// [target] 目标区域(屏幕坐标);[child] 飞行途中显示的内容,通常是起飞组件的副本
|
||||
static void play({
|
||||
required BuildContext context,
|
||||
required GlobalKey sourceKey,
|
||||
required Rect target,
|
||||
required Widget child,
|
||||
Duration duration = const Duration(milliseconds: 700),
|
||||
}) {
|
||||
final box = sourceKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) return; // 量不到就不飞,不能因为动画报错
|
||||
final begin = box.localToGlobal(Offset.zero) & box.size;
|
||||
|
||||
// rootOverlay:挂到最顶层,弹窗 pop 掉之后飞行层还得继续存活
|
||||
final overlay = Overlay.maybeOf(context, rootOverlay: true);
|
||||
if (overlay == null) return;
|
||||
|
||||
late OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (_) => _FlyView(
|
||||
begin: begin,
|
||||
end: target,
|
||||
duration: duration,
|
||||
// Overlay 被整体销毁时 entry 已不在树上,再 remove 会踩 assert
|
||||
onDone: () {
|
||||
if (entry.mounted) entry.remove();
|
||||
},
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
overlay.insert(entry);
|
||||
}
|
||||
}
|
||||
|
||||
class _FlyView extends StatefulWidget {
|
||||
final Rect begin;
|
||||
final Rect end;
|
||||
final Duration duration;
|
||||
final VoidCallback onDone;
|
||||
final Widget child;
|
||||
|
||||
const _FlyView({
|
||||
required this.begin,
|
||||
required this.end,
|
||||
required this.duration,
|
||||
required this.onDone,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_FlyView> createState() => _FlyViewState();
|
||||
}
|
||||
|
||||
class _FlyViewState extends State<_FlyView> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctr = AnimationController(vsync: this, duration: widget.duration);
|
||||
|
||||
// easeInOutCubic:起步慢、中段快、**末尾减速**。不用 easeIn 系是因为那样末尾越飞越快,
|
||||
// 到落点时一闪而过,用户来不及把弹窗和浮窗对应起来
|
||||
late final Animation<Rect?> _rect = RectTween(begin: widget.begin, end: widget.end)
|
||||
.animate(CurvedAnimation(parent: _ctr, curve: Curves.easeInOutCubic));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctr.addStatusListener((s) {
|
||||
if (s == AnimationStatus.completed) widget.onDone();
|
||||
});
|
||||
_ctr.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctr.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _ctr,
|
||||
builder: (_, child) {
|
||||
final r = _rect.value ?? widget.begin;
|
||||
// 全程基本保持不透明,只在最后 12% 收尾淡出:太早淡出会让人看不清落到哪儿了
|
||||
final t = _ctr.value;
|
||||
final opacity = t < 0.88 ? 1.0 : (1 - (t - 0.88) / 0.12).clamp(0.0, 1.0);
|
||||
return Positioned(
|
||||
left: r.left,
|
||||
top: r.top,
|
||||
width: r.width,
|
||||
height: r.height,
|
||||
child: IgnorePointer(
|
||||
child: Opacity(opacity: opacity, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
// 内容按起飞尺寸渲染一次,交给 FittedBox 等比缩放,避免每帧重新布局图片
|
||||
child: FittedBox(
|
||||
fit: BoxFit.fill,
|
||||
child: SizedBox(
|
||||
width: widget.begin.width,
|
||||
height: widget.begin.height,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
|
||||
import '../../assets_tool/app_colors.dart';
|
||||
import '../../hj_utils/api_service/acg_service.dart';
|
||||
import '../../hj_utils/api_service/mine_service.dart';
|
||||
import '../../hj_utils/widget_util.dart';
|
||||
|
||||
enum FollowEnum {
|
||||
collect, //收藏,加入书架
|
||||
cartoon, //动画,加入书架
|
||||
actress1, // 女优
|
||||
actress2, // 网黄
|
||||
user, // 用户
|
||||
voiceActor, // 声优
|
||||
tag, // 帖子标签
|
||||
noval, //小说收藏
|
||||
}
|
||||
|
||||
class FollowButton extends StatefulWidget {
|
||||
final String? mediaId; //id
|
||||
final FollowEnum? followType; //ui样式
|
||||
final bool? isFollow; //
|
||||
final Color? borderColor;
|
||||
final Function(bool isSuccess)? successsAction; //成功回调
|
||||
|
||||
FollowButton({
|
||||
super.key,
|
||||
this.mediaId,
|
||||
this.followType,
|
||||
this.isFollow,
|
||||
this.successsAction,
|
||||
this.borderColor,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FollowButton> createState() => _FollowButtonState();
|
||||
}
|
||||
|
||||
class _FollowButtonState extends State<FollowButton> {
|
||||
String? get mediaId => widget.mediaId;
|
||||
|
||||
FollowEnum get followType => widget.followType ?? FollowEnum.collect;
|
||||
bool get isFollow => widget.isFollow ?? false;
|
||||
bool loading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//1.收藏/加入书架样式
|
||||
if (followType == FollowEnum.cartoon) return _buildCollectionView();
|
||||
//2.用户关注样式
|
||||
if (followType == FollowEnum.actress1 ||
|
||||
followType == FollowEnum.actress2 ||
|
||||
followType == FollowEnum.user ||
|
||||
followType == FollowEnum.voiceActor ||
|
||||
followType == FollowEnum.tag) {
|
||||
return _buildUserFollowView();
|
||||
}
|
||||
//3.文字小说收藏
|
||||
if (followType == FollowEnum.noval) return _buildNovalCollect();
|
||||
return Container();
|
||||
}
|
||||
|
||||
//收藏/加入书架样式
|
||||
_buildCollectionView() {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: onCollectAction,
|
||||
child: loading
|
||||
? _loadingView()
|
||||
: isFollow
|
||||
? Image.asset(
|
||||
'collect_red.png'.commonImgPath,
|
||||
width: 24,
|
||||
)
|
||||
: Image.asset(
|
||||
'collect_path.png'.commonImgPath,
|
||||
width: 24,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
//关注样式
|
||||
_buildUserFollowView() {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: onFollowEvent,
|
||||
child: isFollow
|
||||
? Container(
|
||||
alignment: Alignment.center,
|
||||
height: 24,
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
color: Colors.white.withValues(alpha: .05),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
loading
|
||||
? _loadingView()
|
||||
: Icon(
|
||||
Icons.check,
|
||||
size: 14,
|
||||
color: Color(0xff989898),
|
||||
),
|
||||
4.sizeBoxW,
|
||||
Text(
|
||||
'关注',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xff989898)),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
height: 24,
|
||||
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
// border: Border.all(color: widget.borderColor ?? AppColors.primaryHighColor, width: 1),
|
||||
color: Color(0x1AF68804),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
loading
|
||||
? _loadingView()
|
||||
: Icon(
|
||||
Icons.add,
|
||||
size: 14,
|
||||
color: AppColors.actionRed,
|
||||
),
|
||||
4.sizeBoxW,
|
||||
Text(
|
||||
'关注',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.actionRed,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_buildNovalCollect() {
|
||||
return InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: onCollectAction,
|
||||
child: loading
|
||||
? _loadingView()
|
||||
: Container(
|
||||
width: 62,
|
||||
height: 24,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: isFollow ? Color(0xff3D3D3D) : AppColors.actionRed,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: isFollow
|
||||
? Text(
|
||||
'已收藏',
|
||||
style: textStyle(10, Colors.white, FontWeight.w400),
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
'add_red.png'.commonImgPath,
|
||||
width: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
2.sizeBoxW,
|
||||
Text(
|
||||
'收藏',
|
||||
style: textStyle(10, Colors.white, FontWeight.w400),
|
||||
)
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Widget _loadingView() {
|
||||
return CupertinoActivityIndicator(
|
||||
color: Colors.white,
|
||||
radius: 8,
|
||||
);
|
||||
}
|
||||
|
||||
//加入书架
|
||||
onCollectAction() {
|
||||
isFollow == true ? cancelFollowAction() : addFollowAction();
|
||||
}
|
||||
|
||||
//取消收藏
|
||||
cancelFollowAction() async {
|
||||
if (loading) return;
|
||||
setState(() => loading = true);
|
||||
final res = await ACGService.deleteBookshelf(mediaId ?? '');
|
||||
if (res) {
|
||||
widget.successsAction?.call(false);
|
||||
}
|
||||
loading = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
addFollowAction() async {
|
||||
if (loading) return;
|
||||
setState(() => loading = true);
|
||||
final res = await ACGService.addBookshelf(mediaId ?? '');
|
||||
if (res) {
|
||||
widget.successsAction?.call(true);
|
||||
// isFollow = true;
|
||||
}
|
||||
loading = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
//关注声优
|
||||
onFollowEvent() async {
|
||||
if (loading) return;
|
||||
setState(() => loading = true);
|
||||
if (widget.followType == FollowEnum.tag) {
|
||||
_onCollectEvent();
|
||||
} else if (widget.followType == FollowEnum.user) {
|
||||
await _onFollowUser();
|
||||
} else {
|
||||
await _onFollowOther();
|
||||
}
|
||||
loading = false;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future _onCollectEvent() async {
|
||||
String typeValue = 'tag';
|
||||
bool result = await MineService.postCollect(mediaId, typeValue, !isFollow);
|
||||
if (result) {
|
||||
showToast(isFollow ? '关注成功' : '取消关注');
|
||||
widget.successsAction?.call(isFollow);
|
||||
}
|
||||
}
|
||||
|
||||
Future _onFollowUser() async {
|
||||
bool followStatus = !isFollow;
|
||||
bool result =
|
||||
await MineService.getFollow(int.tryParse(mediaId ?? ""), followStatus);
|
||||
if (result) {
|
||||
showToast(followStatus ? '关注成功' : '取消关注');
|
||||
widget.successsAction?.call(isFollow);
|
||||
}
|
||||
}
|
||||
|
||||
Future _onFollowOther() async {
|
||||
String type = "actress";
|
||||
if (widget.followType == FollowEnum.actress1) {
|
||||
type = "actress1";
|
||||
} else if (widget.followType == FollowEnum.actress2) {
|
||||
type = "actress2";
|
||||
} else if (widget.followType == FollowEnum.voiceActor) {
|
||||
type = "actress4";
|
||||
}
|
||||
bool result = await MineService.postCollect(mediaId, type, !isFollow);
|
||||
if (result) {
|
||||
widget.successsAction?.call(isFollow);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class GroupTextFiled extends StatelessWidget {
|
||||
final TextEditingController? controller;
|
||||
final String? placeholder;
|
||||
final double? height;
|
||||
final int? maxLines;
|
||||
final int? maxLength;
|
||||
final Alignment? alignment;
|
||||
final EdgeInsets? padding;
|
||||
final double? radius;
|
||||
final Color? bgColor;
|
||||
final bool autoFocus;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final TextInputAction? textInputAction;
|
||||
final TextInputType? keyboardType;
|
||||
final FocusNode? focusNode;
|
||||
final TextStyle? textStyle;
|
||||
final TextStyle? placeholderTextStyle;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final bool? enabled;
|
||||
final Decoration? decoration;
|
||||
final TextAlign textAlign;
|
||||
final Function(String)? onChangeCallback;
|
||||
|
||||
const GroupTextFiled({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.placeholder,
|
||||
this.height,
|
||||
this.maxLines,
|
||||
this.maxLength,
|
||||
this.alignment,
|
||||
this.padding,
|
||||
this.radius,
|
||||
this.bgColor,
|
||||
this.autoFocus = false,
|
||||
this.onSubmitted,
|
||||
this.textInputAction,
|
||||
this.keyboardType,
|
||||
this.focusNode,
|
||||
this.textStyle,
|
||||
this.placeholderTextStyle,
|
||||
this.inputFormatters,
|
||||
this.enabled,
|
||||
this.decoration,
|
||||
this.onChangeCallback,
|
||||
this.textAlign = TextAlign.start,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(enableFeedback: false,
|
||||
onTap: focusNode != null ? () => focusNode!.requestFocus() : null,
|
||||
child: Container(
|
||||
height: height ?? 40,
|
||||
padding: padding ?? const EdgeInsets.fromLTRB(0, 0, 0, 0),
|
||||
alignment: alignment ?? Alignment.centerLeft,
|
||||
decoration: decoration,
|
||||
child: TextField(
|
||||
cursorColor: Colors.blue.withValues(alpha: 0.5),
|
||||
autofocus: autoFocus,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
style: textStyle ?? const TextStyle(color: Colors.white, fontSize: 14),
|
||||
controller: controller,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: maxLines,
|
||||
maxLength: maxLength,
|
||||
onSubmitted: onSubmitted,
|
||||
focusNode: focusNode,
|
||||
enabled: enabled,
|
||||
onChanged: onChangeCallback,
|
||||
textAlign: textAlign,
|
||||
decoration: InputDecoration(
|
||||
hintText: placeholder,
|
||||
border: InputBorder.none,
|
||||
labelText: "",
|
||||
counterText: "",
|
||||
isDense: true,
|
||||
isCollapsed: true,
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
hintStyle: placeholderTextStyle ?? const TextStyle(color: Color(0xff999999), fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
///用户头像(包括VIP)
|
||||
class HeaderWidget extends StatelessWidget {
|
||||
final String headPath;
|
||||
|
||||
final double headWidth;
|
||||
|
||||
final double headHeight;
|
||||
|
||||
//vip显示的高度
|
||||
final double? borderHeight;
|
||||
|
||||
final Widget? defaultHead;
|
||||
|
||||
final int level;
|
||||
final bool isCircle;
|
||||
final double? radius;
|
||||
|
||||
final VoidCallback? tabCallback; //点击头像
|
||||
|
||||
const HeaderWidget({
|
||||
super.key,
|
||||
required this.headPath,
|
||||
required this.level,
|
||||
required this.headWidth,
|
||||
required this.headHeight,
|
||||
this.borderHeight,
|
||||
this.defaultHead,
|
||||
this.tabCallback,
|
||||
this.isCircle = true,
|
||||
this.radius,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color _borderColor = _configVIPInfo();
|
||||
return GestureDetector(
|
||||
onTap: tabCallback,
|
||||
child: Container(
|
||||
width: headWidth,
|
||||
height: headHeight,
|
||||
decoration: isCircle
|
||||
? BoxDecoration(
|
||||
border:
|
||||
Border.all(width: borderHeight ?? 4, color: _borderColor),
|
||||
shape: BoxShape.circle,
|
||||
)
|
||||
: null,
|
||||
child: ClipRRect(
|
||||
borderRadius:
|
||||
BorderRadius.circular(isCircle ? headWidth : radius ?? 0),
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: headPath,
|
||||
width: headWidth,
|
||||
height: headHeight,
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
///获取对应VIP的边框颜色和图片
|
||||
Color _configVIPInfo() {
|
||||
return const Color.fromRGBO(253, 45, 85, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/image_util.dart';
|
||||
import 'package:hgdj/tools_base/cache/image_cache_manager.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_alert_widget.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
|
||||
|
||||
/// 微信风格图片浏览器
|
||||
/// 横滑切图 + 双指/双击缩放 + 上下滑拖拽退出(图片跟手、背景随拖动渐隐露出下层页面) + 长按保存
|
||||
///
|
||||
/// 用法(透明路由,拖动渐隐才能看到下层):
|
||||
/// ```dart
|
||||
/// ImageBrowserPage.open(['url1', 'url2'], index: 0);
|
||||
/// ```
|
||||
class ImageBrowserPage extends StatefulWidget {
|
||||
final List<String> images;
|
||||
final int initialIndex;
|
||||
final bool showSaveButton; // 右上角显式"保存到相册"按钮(AI 生成图等场景);默认只支持长按保存
|
||||
|
||||
const ImageBrowserPage(
|
||||
{super.key,
|
||||
required this.images,
|
||||
this.initialIndex = 0,
|
||||
this.showSaveButton = false});
|
||||
|
||||
/// 打开浏览器(透明路由 + 淡入)
|
||||
/// [showSaveButton] 显示右上角"保存到相册"按钮
|
||||
static void open(List<String> images,
|
||||
{int index = 0, bool showSaveButton = false}) {
|
||||
final valid = images.where((e) => e.isNotEmpty).toList();
|
||||
if (valid.isEmpty) return;
|
||||
// fullscreenDialog:true → GetX canTransitionTo 返回 false,下层页面不做外出转场,
|
||||
// 保持完整渲染 → 拖拽偷看时看到的是整页而非"pop 一半"。
|
||||
// 瞬时 fadeIn:打开无残影。仍是 Get.to → Get.back 能正常 pop,下滑退出正常
|
||||
Get.to(
|
||||
() => ImageBrowserPage(
|
||||
images: valid,
|
||||
initialIndex: index.clamp(0, valid.length - 1),
|
||||
showSaveButton: showSaveButton),
|
||||
opaque: false,
|
||||
transition: Transition.fadeIn,
|
||||
duration: Duration.zero,
|
||||
fullscreenDialog: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ImageBrowserPage> createState() => _ImageBrowserPageState();
|
||||
}
|
||||
|
||||
class _ImageBrowserPageState extends State<ImageBrowserPage>
|
||||
with TickerProviderStateMixin {
|
||||
// ========== 分页 ==========
|
||||
late final PageController _pageCtr;
|
||||
late int _curIndex;
|
||||
bool _isForward = true; // 页码翻滚方向:下一张上滚、上一张下滚
|
||||
|
||||
// ========== 缩放(双指/双击) ==========
|
||||
late final List<TransformationController> _transCtrs; // 每张图各自的缩放/平移矩阵
|
||||
late final AnimationController _zoomCtr; // 双击缩放过渡
|
||||
Offset? _tapPos; // 双击落点,作为放大锚点
|
||||
|
||||
// ========== 拖拽退出 ==========
|
||||
double _dragY = 0; // 只跟竖直方向
|
||||
bool _isDragging = false;
|
||||
late final AnimationController _resetCtr; // 松手未达阈值时回弹
|
||||
|
||||
// ========== 入场 / 退出 ==========
|
||||
late final AnimationController _enterCtr; // 入场:图片在实底上淡入
|
||||
late final AnimationController _exitCtr; // 退出:整体淡出
|
||||
bool _isExiting = false;
|
||||
|
||||
// ========== 保存 ==========
|
||||
bool _isSaving = false; // 动图转码耗时,挡重复触发并盖个转圈
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_curIndex = widget.initialIndex;
|
||||
_pageCtr = PageController(initialPage: _curIndex);
|
||||
_transCtrs =
|
||||
List.generate(widget.images.length, (_) => TransformationController());
|
||||
_resetCtr = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 200));
|
||||
_zoomCtr = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 200));
|
||||
_enterCtr = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 220))
|
||||
..forward();
|
||||
_exitCtr = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageCtr.dispose();
|
||||
for (final c in _transCtrs) {
|
||||
c.dispose();
|
||||
}
|
||||
_resetCtr.dispose();
|
||||
_zoomCtr.dispose();
|
||||
_enterCtr.dispose();
|
||||
_exitCtr.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// ========== 派生状态 ==========
|
||||
// 当前图是否已放大(放大时禁用下滑退出,交给 InteractiveViewer 平移)
|
||||
bool get _isZoomed => _transCtrs[_curIndex].value.getMaxScaleOnAxis() > 1.05;
|
||||
|
||||
// 背景不透明度:随竖直拖动距离渐隐(下拉偷看下层)
|
||||
double get _bgOpacity =>
|
||||
(1 - _dragY.abs() / (Get.height * 0.6)).clamp(0.0, 1.0);
|
||||
|
||||
// 拖动时图片轻微缩小
|
||||
double get _dragScale =>
|
||||
(1 - _dragY.abs() / (Get.height * 2)).clamp(0.85, 1.0);
|
||||
|
||||
// 退出淡出系数:1→0
|
||||
double get _exitFactor => 1 - _exitCtr.value;
|
||||
|
||||
// ========== 拖拽退出 ==========
|
||||
void _onDragUpdate(DragUpdateDetails d) {
|
||||
setState(() {
|
||||
_isDragging = true;
|
||||
_dragY += d.delta.dy;
|
||||
});
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails d) {
|
||||
// 拖够距离或甩动够快 → 退出,否则回弹
|
||||
if (_dragY.abs() > 120 || d.velocity.pixelsPerSecond.dy.abs() > 800) {
|
||||
_exit();
|
||||
return;
|
||||
}
|
||||
final anim = Tween<double>(begin: _dragY, end: 0)
|
||||
.animate(CurvedAnimation(parent: _resetCtr, curve: Curves.easeOut));
|
||||
void listener() => setState(() => _dragY = anim.value);
|
||||
anim.addListener(listener);
|
||||
// whenComplete 在 dispose 取消 ticker 时也会触发,一律先判 mounted
|
||||
_resetCtr.forward(from: 0).whenComplete(() {
|
||||
anim.removeListener(listener);
|
||||
if (mounted) setState(() => _isDragging = false);
|
||||
});
|
||||
}
|
||||
|
||||
// 退出:图片+背景就地整体淡出再 pop —— 还原点赞那版手感,也避免"背景先没图片还在"的残留
|
||||
void _exit() {
|
||||
setState(() => _isExiting = true); // 隐藏返回/保存/页码,只留图片淡出
|
||||
// 淡出途中被系统返回键 pop 掉时 whenComplete 照样触发,不判 mounted 会把下层页面也弹掉
|
||||
_exitCtr.forward(from: 0).whenComplete(() {
|
||||
if (mounted) Get.back();
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 双击缩放 ==========
|
||||
void _onDoubleTap() {
|
||||
final ctr = _transCtrs[_curIndex];
|
||||
final Matrix4 target;
|
||||
if (_isZoomed) {
|
||||
target = Matrix4.identity();
|
||||
} else {
|
||||
// 以双击点为锚点放大到 2.5 倍(列主序构造缩放+平移,避开已废弃的 Matrix4.translate/scale)
|
||||
final pos = _tapPos ?? Offset(Get.width / 2, Get.height / 2);
|
||||
const scale = 2.5;
|
||||
target = Matrix4(
|
||||
scale, 0, 0, 0, //
|
||||
0, scale, 0, 0, //
|
||||
0, 0, 1, 0, //
|
||||
-pos.dx * (scale - 1), -pos.dy * (scale - 1), 0, 1, //
|
||||
);
|
||||
}
|
||||
final anim = Matrix4Tween(begin: ctr.value, end: target)
|
||||
.animate(CurvedAnimation(parent: _zoomCtr, curve: Curves.easeOut));
|
||||
void listener() => ctr.value = anim.value;
|
||||
anim.addListener(listener);
|
||||
_zoomCtr.forward(from: 0).whenComplete(() {
|
||||
anim.removeListener(listener);
|
||||
if (mounted) setState(() {}); // 刷新 _isZoomed → 下滑退出开关
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 长按保存 ==========
|
||||
// 缓存存的是解密后的图(见 ImageCacheManager.CustomFileRespons),直接取字节保存
|
||||
// 动图(AI 图生视频的结果就是多帧 webp)由 saveImageToAlbum 内部转 mp4,否则相册里只有第一帧
|
||||
Future<void> _saveCurrent() async {
|
||||
if (_isSaving) return;
|
||||
final url = widget.images[_curIndex];
|
||||
if (url.isEmpty) return;
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
final file = await ImageCacheManager().getSingleFile(url);
|
||||
final ok = await ImageUtil.saveImageToAlbum(await file.readAsBytes());
|
||||
showToast(ok ? '已保存到相册' : '保存失败');
|
||||
} catch (_) {
|
||||
showToast('保存失败');
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false); //可能保存途中已被下滑退出
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
// 退出淡出逐帧重建交给 AnimatedBuilder,不用自己挂 listener + 空 setState
|
||||
child: AnimatedBuilder(
|
||||
animation: _exitCtr,
|
||||
builder: (_, __) => Stack(
|
||||
children: [
|
||||
// 黑色背景,随拖动/退出渐隐
|
||||
Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Colors.black
|
||||
.withValues(alpha: _bgOpacity * _exitFactor))),
|
||||
GestureDetector(
|
||||
onLongPress: _saveCurrent, // 长按保存
|
||||
onDoubleTapDown: (d) => _tapPos = d.localPosition,
|
||||
onDoubleTap: _onDoubleTap,
|
||||
// 未放大才下滑退出;放大时为 null,竖直拖动交给 InteractiveViewer 平移
|
||||
onVerticalDragUpdate: _isZoomed ? null : _onDragUpdate,
|
||||
onVerticalDragEnd: _isZoomed ? null : _onDragEnd,
|
||||
child: Opacity(
|
||||
opacity: _exitFactor, // 退出时整体淡出
|
||||
child: FadeTransition(
|
||||
opacity: _enterCtr,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _dragY),
|
||||
child:
|
||||
Transform.scale(scale: _dragScale, child: _pageView()),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 拖拽/退出中只留图片,顶部按钮和页码全隐藏
|
||||
if (!_isDragging && !_isExiting) ...[
|
||||
_backButton(),
|
||||
if (widget.showSaveButton) _saveButton(),
|
||||
if (widget.images.length > 1) _indicator(), // 多图才显示页码
|
||||
],
|
||||
// 保存中(动图要解码+转码,要几秒),压在最上层挡住交互
|
||||
// 用和上传图片一致的 LoadingAlertWidget,但内嵌而非 .show() 弹窗——
|
||||
// 弹窗的 cancel() 是 Get.back(),本页自身也靠 Get.back() 退出,容易互相弹错
|
||||
if (_isSaving)
|
||||
const Positioned.fill(
|
||||
child: AbsorbPointer(
|
||||
child: ColoredBox(
|
||||
color: Color(0x80000000),
|
||||
child: LoadingAlertWidget(title: '保存中...')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pageView() {
|
||||
return PageView.builder(
|
||||
controller: _pageCtr,
|
||||
itemCount: widget.images.length,
|
||||
onPageChanged: (i) {
|
||||
_transCtrs[_curIndex].value = Matrix4.identity(); // 离开的图复位缩放
|
||||
setState(() {
|
||||
_isForward = i >= _curIndex;
|
||||
_curIndex = i;
|
||||
});
|
||||
},
|
||||
itemBuilder: (_, i) => InteractiveViewer(
|
||||
transformationController: _transCtrs[i],
|
||||
minScale: 1,
|
||||
maxScale: 4,
|
||||
onInteractionEnd: (_) => setState(() {}), // 缩放结束刷新 _isZoomed
|
||||
child: SizedBox(
|
||||
width: Get.width,
|
||||
height: Get.height,
|
||||
child: NetworkImageLoader(
|
||||
imageUrl: widget.images[i],
|
||||
fit: BoxFit.contain,
|
||||
borderRadius: 0,
|
||||
isResizeImage: false, // 看大图用原图,不按组件尺寸压缩
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 页码指示(白色胶囊,当前页红色) —— 沿用 CommunityImagePage 样式
|
||||
Widget _indicator() {
|
||||
return Positioned(
|
||||
bottom: 50,
|
||||
right: 20,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 当前页码:切换时上下翻滚(下一张上滚、上一张下滚)
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
transitionBuilder: (child, anim) {
|
||||
final isIncoming = child.key == ValueKey(_curIndex);
|
||||
// 进入的从对向滑入到原位,离开的从原位滑出到反向
|
||||
final begin = _isForward
|
||||
? (isIncoming ? const Offset(0, 1) : const Offset(0, -1))
|
||||
: (isIncoming ? const Offset(0, -1) : const Offset(0, 1));
|
||||
return ClipRect(
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(begin: begin, end: Offset.zero)
|
||||
.animate(anim),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
'${_curIndex + 1}',
|
||||
key: ValueKey(_curIndex),
|
||||
style: TextStyle(color: AppColors.actionRed, fontSize: 12),
|
||||
),
|
||||
),
|
||||
Text('/${widget.images.length}',
|
||||
style: const TextStyle(color: Color(0xff3D3D3D), fontSize: 12)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 左上返回按钮 —— 沿用 CommunityImagePage
|
||||
Widget _backButton() {
|
||||
return Positioned(
|
||||
top: 0,
|
||||
left: 16,
|
||||
child: SafeArea(
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: _exit,
|
||||
child: Image.asset('back_circle.png'.commonImgPath, width: 24),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 右上"保存到相册"按钮 —— 沿用 AiNewImageView 样式
|
||||
Widget _saveButton() {
|
||||
return Positioned(
|
||||
top: 0,
|
||||
right: 16,
|
||||
child: SafeArea(
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: _saveCurrent,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: const Color(0xFFE57310)),
|
||||
child: const Text('保存到相册',
|
||||
style: TextStyle(color: Colors.white, fontSize: 14)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 保持widget 活跃
|
||||
/// 使用:KeepAliveWidget(widget);
|
||||
class KeepAliveWidget extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const KeepAliveWidget(this.child, {super.key});
|
||||
|
||||
@override
|
||||
KeepAliveState createState() => KeepAliveState();
|
||||
}
|
||||
|
||||
class KeepAliveState extends State<KeepAliveWidget> with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
extension KeepWrapper on Widget {
|
||||
Widget get keepAlive {
|
||||
return KeepAliveWidget(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 懒加载 + 保活的 IndexedStack(底部 tab 内容页标准写法)
|
||||
///
|
||||
/// 没进过的 tab 用空盒占位,首次切到才真正 build;进过一次就一直留在树里,
|
||||
/// 切走只是不 paint,state / 滚动位置 / 播放器全部保留。
|
||||
/// 相比 PageView:不需要 AutomaticKeepAlive 保活,也不会预建相邻页。
|
||||
///
|
||||
/// 非当前 tab 用 TickerMode(enabled: false) 关掉:IndexedStack 只是不 paint,
|
||||
/// 离屏页的动画/定时器照样在跑(跑马灯、Shimmer、轮播),看不见还空转。
|
||||
class LazyIndexedStack extends StatefulWidget {
|
||||
final int index;
|
||||
final List<Widget> children;
|
||||
|
||||
const LazyIndexedStack({super.key, required this.index, required this.children});
|
||||
|
||||
@override
|
||||
State<LazyIndexedStack> createState() => _LazyIndexedStackState();
|
||||
}
|
||||
|
||||
class _LazyIndexedStackState extends State<LazyIndexedStack> {
|
||||
//已建过的下标:进过一次就永久保活,不再退回占位
|
||||
final _loaded = <int>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loaded.add(widget.index);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant LazyIndexedStack oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_loaded.add(widget.index);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IndexedStack(
|
||||
// 必须 expand:默认的 loose 会让子页拿到非 tight 约束,从而不再是 relayout boundary,
|
||||
// 离屏页一脏就把整个 stack 拖着重新布局(PageView 原来给的是 tight,每页各自隔离)。
|
||||
// 顺带保证「根节点自身不撑满」的页也能满屏,不依赖各页自觉
|
||||
sizing: StackFit.expand,
|
||||
index: widget.index,
|
||||
children: List.generate(
|
||||
widget.children.length,
|
||||
(i) => _loaded.contains(i)
|
||||
? TickerMode(enabled: i == widget.index, child: widget.children[i])
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
|
||||
/// 双击点赞爱心动效的触发器。onDoubleTapDown 里 [markAt] 记落点,onDoubleTap 里 [burst] 放动画
|
||||
class LikeBurstController extends ChangeNotifier {
|
||||
Offset? at;
|
||||
|
||||
/// 记下双击落点(相对 Stack 左上角)
|
||||
void markAt(Offset offset) => at = offset;
|
||||
|
||||
/// 在落点弹一颗爱心;没记过落点就不弹
|
||||
void burst() {
|
||||
if (at == null) return;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 双击处弹出的爱心:弹起放大 → 停顿 → 淡出。必须直接挂在 Stack 里
|
||||
class LikeBurstView extends StatefulWidget {
|
||||
const LikeBurstView({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.icon = 'like_red.png',
|
||||
this.size = 90,
|
||||
});
|
||||
|
||||
final LikeBurstController controller;
|
||||
final String icon; // assets/images/common 下的图名
|
||||
final double size;
|
||||
|
||||
@override
|
||||
State<LikeBurstView> createState() => _LikeBurstViewState();
|
||||
}
|
||||
|
||||
class _LikeBurstViewState extends State<LikeBurstView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctr;
|
||||
Offset? _at;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctr = AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 600))
|
||||
..addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed && mounted)
|
||||
setState(() => _at = null);
|
||||
});
|
||||
widget.controller.addListener(_onBurst);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant LikeBurstView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.removeListener(_onBurst);
|
||||
widget.controller.addListener(_onBurst);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onBurst);
|
||||
_ctr.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onBurst() {
|
||||
setState(() => _at = widget.controller.at);
|
||||
_ctr.forward(from: 0); // 连点就从头重放
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final at = _at;
|
||||
if (at == null) return const SizedBox.shrink();
|
||||
return Positioned(
|
||||
left: at.dx - widget.size / 2,
|
||||
top: at.dy - widget.size / 2,
|
||||
child: IgnorePointer(
|
||||
child: FadeTransition(
|
||||
opacity: TweenSequence<double>([
|
||||
TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 20),
|
||||
TweenSequenceItem(tween: ConstantTween(1.0), weight: 40),
|
||||
TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 40),
|
||||
]).animate(_ctr),
|
||||
child: ScaleTransition(
|
||||
scale: Tween(begin: 0.5, end: 1.3).animate(
|
||||
CurvedAnimation(parent: _ctr, curve: Curves.easeOutBack)),
|
||||
child: Image.asset(widget.icon.commonImgPath,
|
||||
width: widget.size, height: widget.size),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
// 上下滚动的消息轮播
|
||||
class MarqueeWidget extends StatefulWidget {
|
||||
/// 子视图数量
|
||||
final int count;
|
||||
|
||||
/// 子视图构建器
|
||||
final IndexedWidgetBuilder itemBuilder;
|
||||
|
||||
/// 轮播的时间间隔(秒)
|
||||
final int loopSeconds;
|
||||
|
||||
/// 当前展示项变化回调(返回逻辑下标)
|
||||
final ValueChanged<int>? onIndexChanged;
|
||||
|
||||
const MarqueeWidget({
|
||||
super.key,
|
||||
required this.count,
|
||||
required this.itemBuilder,
|
||||
this.loopSeconds = 3,
|
||||
this.onIndexChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MarqueeWidget> createState() => _MarqueeWidgetState();
|
||||
}
|
||||
|
||||
class _MarqueeWidgetState extends State<MarqueeWidget> {
|
||||
final pageCtr = PageController();
|
||||
Timer? loopTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
loopTimer = Timer.periodic(Duration(seconds: widget.loopSeconds), (_) {
|
||||
final page = pageCtr.page;
|
||||
if (page == null) return;
|
||||
// 滚到末尾占位页(内容同第一页)时无感跳回首页,实现无限循环
|
||||
if (page.round() >= widget.count) {
|
||||
pageCtr.jumpToPage(0);
|
||||
}
|
||||
pageCtr.nextPage(duration: const Duration(seconds: 1), curve: Curves.linear);
|
||||
// 上报即将展示的逻辑下标
|
||||
widget.onIndexChanged?.call((page.round() + 1) % widget.count);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
loopTimer?.cancel();
|
||||
pageCtr.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PageView.builder(
|
||||
scrollDirection: Axis.vertical,
|
||||
controller: pageCtr,
|
||||
itemCount: widget.count + 1,
|
||||
// 末尾多一页占位、内容取第一页,配合 jumpToPage(0) 做无限循环
|
||||
itemBuilder: (ctx, index) => widget.itemBuilder(ctx, index < widget.count ? index : 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
//子/父都响应点击
|
||||
class MultiTapGestureRecognizer extends TapGestureRecognizer {
|
||||
@override
|
||||
void rejectGesture(int pointer) {
|
||||
// 让手势同时被多个识别器处理
|
||||
acceptGesture(pointer);
|
||||
}
|
||||
}
|
||||
|
||||
class MultiTap extends StatelessWidget {
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const MultiTap({super.key, required this.child, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return RawGestureDetector(
|
||||
gestures: {
|
||||
MultiTapGestureRecognizer: GestureRecognizerFactoryWithHandlers<MultiTapGestureRecognizer>(
|
||||
() => MultiTapGestureRecognizer(),
|
||||
(instance) {
|
||||
instance.onTap = () => onTap?.call();
|
||||
},
|
||||
),
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/cache/image_cache_manager.dart';
|
||||
|
||||
class NetworkImageLoader extends StatelessWidget {
|
||||
final double borderRadius;
|
||||
final BorderRadius? imgBorderRadius;
|
||||
final String? imageUrl;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final BoxFit fit;
|
||||
final double? placeHolderH;
|
||||
final double? placeHolderW;
|
||||
final bool blur; //高斯模糊
|
||||
final Alignment alignment;
|
||||
final bool encrypt; //是否需要加密
|
||||
final Widget? placeHolderWidget;
|
||||
final int? loadWidth; // 服务端根据宽度等比例压缩
|
||||
final bool? isResizeImage; //是否需要根据组件大小压缩
|
||||
|
||||
const NetworkImageLoader({
|
||||
super.key,
|
||||
required this.imageUrl,
|
||||
this.borderRadius = 8,
|
||||
this.imgBorderRadius,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.width,
|
||||
this.blur = false,
|
||||
this.placeHolderH,
|
||||
this.placeHolderW,
|
||||
this.alignment = Alignment.center,
|
||||
this.encrypt = true,
|
||||
this.placeHolderWidget,
|
||||
this.loadWidth,
|
||||
this.isResizeImage = true,
|
||||
});
|
||||
|
||||
bool get isGif => imageUrl?.contains(".gif") ?? false;
|
||||
String get realImgUrl {
|
||||
if (imageUrl == null) return "";
|
||||
return imageUrl!;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (context, cons) {
|
||||
final imageWidget = CachedNetworkImage(
|
||||
alignment: alignment,
|
||||
imageUrl: realImgUrl,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
memCacheWidth: memCacheWidth(cons),
|
||||
// 默认 FilterQuality.low 用廉价 bilinear,iOS retina(dpr=3) 上肉眼可见的模糊
|
||||
// medium 是质量/性能平衡点,high 会卡列表滚动
|
||||
filterQuality: FilterQuality.medium,
|
||||
cacheManager: encrypt ? ImageCacheManager() : null,
|
||||
placeholder: (context, url) => _buildPlaceHolder(),
|
||||
errorWidget: (context, url, err) => _buildPlaceHolder(),
|
||||
fadeInCurve: Curves.linear,
|
||||
fadeOutCurve: Curves.linear,
|
||||
);
|
||||
final clip = borderRadius == 0
|
||||
? imageWidget
|
||||
: ClipRRect(
|
||||
borderRadius:
|
||||
imgBorderRadius ?? BorderRadius.circular(borderRadius),
|
||||
child: imageWidget,
|
||||
);
|
||||
final backdrop = blur
|
||||
? ClipRRect(
|
||||
borderRadius:
|
||||
imgBorderRadius ?? BorderRadius.circular(borderRadius),
|
||||
child: ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(
|
||||
sigmaX: 5,
|
||||
sigmaY: 5,
|
||||
),
|
||||
child: clip,
|
||||
),
|
||||
)
|
||||
: clip;
|
||||
return backdrop;
|
||||
});
|
||||
}
|
||||
|
||||
//resize的宽度
|
||||
int? memCacheWidth(BoxConstraints cons) {
|
||||
final dpr = screen.devicePixelRatio;
|
||||
if (isResizeImage == true) {
|
||||
//约束无限时用屏幕宽兜底,避免退化成按原图解码(图片过载)
|
||||
final w = (cons.maxWidth == double.infinity || cons.maxWidth.isNaN)
|
||||
? screen.screenWidth
|
||||
: cons.maxWidth;
|
||||
return (w * dpr).toInt();
|
||||
} else {
|
||||
return loadWidth != null
|
||||
? loadWidth!
|
||||
: (screen.screenWidth * dpr).toInt();
|
||||
}
|
||||
}
|
||||
|
||||
_buildPlaceHolder() {
|
||||
if (placeHolderWidget != null) return placeHolderWidget;
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(color: Color(0xff343434)),
|
||||
alignment: Alignment.center,
|
||||
child: Image.asset(
|
||||
'place_holder_logo.webp'.commonImgPath,
|
||||
width: placeHolderW ?? 136,
|
||||
height: placeHolderW ?? 96,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright 2019 The Fuchsia Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// A registry to track some [Element]s in the tree.
|
||||
class RegistryWidget extends StatefulWidget {
|
||||
/// Creates a [RegistryWidget].
|
||||
const RegistryWidget({Key? key, this.elementNotifier, required this.child})
|
||||
: super(key: key);
|
||||
|
||||
/// The widget below this widget in the tree.
|
||||
final Widget child;
|
||||
|
||||
/// Contains the current set of all [Element]s created by
|
||||
/// [RegisteredElementWidget]s in the tree below this widget.
|
||||
///
|
||||
/// Note that if there is another [RegistryWidget] in this widget's subtree
|
||||
/// that registry, and not this one, will collect elements in its subtree.
|
||||
final ValueNotifier<Set<Element>?>? elementNotifier;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _RegistryWidgetState();
|
||||
}
|
||||
|
||||
/// A widget whose [Element] will be added its nearest ancestor
|
||||
/// [RegistryWidget].
|
||||
class RegisteredElementWidget extends ProxyWidget {
|
||||
/// Creates a [RegisteredElementWidget].
|
||||
const RegisteredElementWidget({Key? key, required Widget child})
|
||||
: super(key: key, child: child);
|
||||
|
||||
@override
|
||||
Element createElement() => _RegisteredElement(this);
|
||||
}
|
||||
|
||||
class _RegistryWidgetState extends State<RegistryWidget> {
|
||||
final Set<Element> registeredElements = {};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => _InheritedRegistryWidget(
|
||||
state: this,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
class _InheritedRegistryWidget extends InheritedWidget {
|
||||
final _RegistryWidgetState state;
|
||||
|
||||
const _InheritedRegistryWidget(
|
||||
{Key? key, required this.state, required Widget child})
|
||||
: super(key: key, child: child);
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(InheritedWidget oldWidget) => true;
|
||||
}
|
||||
|
||||
class _RegisteredElement extends ProxyElement {
|
||||
_RegisteredElement(ProxyWidget widget) : super(widget);
|
||||
|
||||
@override
|
||||
void notifyClients(ProxyWidget oldWidget) {}
|
||||
|
||||
late _RegistryWidgetState _registryWidgetState;
|
||||
|
||||
@override
|
||||
void mount(Element? parent, dynamic newSlot) {
|
||||
super.mount(parent, newSlot);
|
||||
final _inheritedRegistryWidget =
|
||||
dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!;
|
||||
_registryWidgetState = _inheritedRegistryWidget.state;
|
||||
_registryWidgetState.registeredElements.add(this);
|
||||
_registryWidgetState.widget.elementNotifier?.value =
|
||||
_registryWidgetState.registeredElements;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final _inheritedRegistryWidget =
|
||||
dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!;
|
||||
_registryWidgetState = _inheritedRegistryWidget.state;
|
||||
_registryWidgetState.registeredElements.add(this);
|
||||
_registryWidgetState.widget.elementNotifier?.value =
|
||||
_registryWidgetState.registeredElements;
|
||||
}
|
||||
|
||||
@override
|
||||
void unmount() {
|
||||
_registryWidgetState.registeredElements.remove(this);
|
||||
_registryWidgetState.widget.elementNotifier?.value =
|
||||
_registryWidgetState.registeredElements;
|
||||
super.unmount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright 2019 The Fuchsia Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'item_positions_notifier.dart';
|
||||
import 'scrollable_positioned_list.dart';
|
||||
|
||||
/// Provides a listenable iterable of [itemPositions] of items that are on
|
||||
/// screen and their locations.
|
||||
abstract class ItemPositionsListener {
|
||||
/// Creates an [ItemPositionsListener] that can be used by a
|
||||
/// [ScrollablePositionedList] to return the current position of items.
|
||||
factory ItemPositionsListener.create() => ItemPositionsNotifier();
|
||||
|
||||
/// The position of items that are at least partially visible in the viewport.
|
||||
ValueListenable<Iterable<ItemPosition>> get itemPositions;
|
||||
}
|
||||
|
||||
/// Position information for an item in the list.
|
||||
class ItemPosition {
|
||||
/// Create an [ItemPosition].
|
||||
const ItemPosition(
|
||||
{required this.index,
|
||||
required this.itemLeadingEdge,
|
||||
required this.itemTrailingEdge});
|
||||
|
||||
/// Index of the item.
|
||||
final int index;
|
||||
|
||||
/// Distance in proportion of the viewport's main axis length from the leading
|
||||
/// edge of the viewport to the leading edge of the item.
|
||||
///
|
||||
/// May be negative if the item is partially visible.
|
||||
final double itemLeadingEdge;
|
||||
|
||||
/// Distance in proportion of the viewport's main axis length from the leading
|
||||
/// edge of the viewport to the trailing edge of the item.
|
||||
///
|
||||
/// May be greater than one if the item is partially visible.
|
||||
final double itemTrailingEdge;
|
||||
|
||||
@override
|
||||
bool operator ==(dynamic other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
final ItemPosition otherPosition = other;
|
||||
return otherPosition.index == index &&
|
||||
otherPosition.itemLeadingEdge == itemLeadingEdge &&
|
||||
otherPosition.itemTrailingEdge == itemTrailingEdge;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
31 * (31 * (7 + index.hashCode) + itemLeadingEdge.hashCode) +
|
||||
itemTrailingEdge.hashCode;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ItemPosition(index: $index, itemLeadingEdge: $itemLeadingEdge, itemTrailingEdge: $itemTrailingEdge)';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright 2019 The Fuchsia Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'item_positions_listener.dart';
|
||||
|
||||
/// Internal implementation of [ItemPositionsListener].
|
||||
class ItemPositionsNotifier implements ItemPositionsListener {
|
||||
@override
|
||||
final ValueNotifier<Iterable<ItemPosition>> itemPositions = ValueNotifier([]);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
// Copyright 2019 The Fuchsia Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'element_registry.dart';
|
||||
import 'item_positions_listener.dart';
|
||||
import 'item_positions_notifier.dart';
|
||||
import 'scroll_view.dart';
|
||||
import 'wrapping.dart';
|
||||
|
||||
/// A list of widgets similar to [ListView], except scroll control
|
||||
/// and position reporting is based on index rather than pixel offset.
|
||||
///
|
||||
/// [PositionedList] lays out children in the same way as [ListView].
|
||||
///
|
||||
/// The list can be displayed with the item at [positionIndex] positioned at a
|
||||
/// particular [alignment]. See [ItemScrollController.jumpTo] for an
|
||||
/// explanation of alignment.
|
||||
///
|
||||
/// All other parameters are the same as specified in [ListView].
|
||||
class PositionedList extends StatefulWidget {
|
||||
/// Create a [PositionedList].
|
||||
const PositionedList({
|
||||
Key? key,
|
||||
required this.itemCount,
|
||||
required this.itemBuilder,
|
||||
this.separatorBuilder,
|
||||
this.controller,
|
||||
this.itemPositionsNotifier,
|
||||
this.positionedIndex = 0,
|
||||
this.alignment = 0,
|
||||
this.scrollDirection = Axis.vertical,
|
||||
this.reverse = false,
|
||||
this.shrinkWrap = false,
|
||||
this.physics,
|
||||
this.padding,
|
||||
this.cacheExtent,
|
||||
this.semanticChildCount,
|
||||
this.addSemanticIndexes = true,
|
||||
this.addRepaintBoundaries = true,
|
||||
this.addAutomaticKeepAlives = true,
|
||||
}) : assert(itemCount != null),
|
||||
assert(itemBuilder != null),
|
||||
assert((positionedIndex == 0) || (positionedIndex < itemCount)),
|
||||
super(key: key);
|
||||
|
||||
/// Number of items the [itemBuilder] can produce.
|
||||
final int itemCount;
|
||||
|
||||
/// Called to build children for the list with
|
||||
/// 0 <= index < itemCount.
|
||||
final IndexedWidgetBuilder itemBuilder;
|
||||
|
||||
/// If not null, called to build separators for between each item in the list.
|
||||
/// Called with 0 <= index < itemCount - 1.
|
||||
final IndexedWidgetBuilder? separatorBuilder;
|
||||
|
||||
/// An object that can be used to control the position to which this scroll
|
||||
/// view is scrolled.
|
||||
final ScrollController? controller;
|
||||
|
||||
/// Notifier that reports the items laid out in the list after each frame.
|
||||
final ItemPositionsNotifier? itemPositionsNotifier;
|
||||
|
||||
/// Index of an item to initially align to a position within the viewport
|
||||
/// defined by [alignment].
|
||||
final int positionedIndex;
|
||||
|
||||
/// Determines where the leading edge of the item at [positionedIndex]
|
||||
/// should be placed.
|
||||
///
|
||||
/// See [ItemScrollController.jumpTo] for an explanation of alignment.
|
||||
final double alignment;
|
||||
|
||||
/// The axis along which the scroll view scrolls.
|
||||
///
|
||||
/// Defaults to [Axis.vertical].
|
||||
final Axis scrollDirection;
|
||||
|
||||
/// Whether the view scrolls in the reading direction.
|
||||
///
|
||||
/// Defaults to false.
|
||||
///
|
||||
/// See [ScrollView.reverse].
|
||||
final bool reverse;
|
||||
|
||||
/// {@template flutter.widgets.scroll_view.shrinkWrap}
|
||||
/// Whether the extent of the scroll view in the [scrollDirection] should be
|
||||
/// determined by the contents being viewed.
|
||||
///
|
||||
/// Defaults to false.
|
||||
///
|
||||
/// See [ScrollView.shrinkWrap].
|
||||
final bool shrinkWrap;
|
||||
|
||||
/// How the scroll view should respond to user input.
|
||||
///
|
||||
/// For example, determines how the scroll view continues to animate after the
|
||||
/// user stops dragging the scroll view.
|
||||
///
|
||||
/// See [ScrollView.physics].
|
||||
final ScrollPhysics? physics;
|
||||
|
||||
/// {@macro flutter.widgets.scrollable.cacheExtent}
|
||||
final double? cacheExtent;
|
||||
|
||||
/// The number of children that will contribute semantic information.
|
||||
///
|
||||
/// See [ScrollView.semanticChildCount] for more information.
|
||||
final int? semanticChildCount;
|
||||
|
||||
/// Whether to wrap each child in an [IndexedSemantics].
|
||||
///
|
||||
/// See [SliverChildBuilderDelegate.addSemanticIndexes].
|
||||
final bool addSemanticIndexes;
|
||||
|
||||
/// The amount of space by which to inset the children.
|
||||
final EdgeInsets? padding;
|
||||
|
||||
/// Whether to wrap each child in a [RepaintBoundary].
|
||||
///
|
||||
/// See [SliverChildBuilderDelegate.addRepaintBoundaries].
|
||||
final bool addRepaintBoundaries;
|
||||
|
||||
/// Whether to wrap each child in an [AutomaticKeepAlive].
|
||||
///
|
||||
/// See [SliverChildBuilderDelegate.addAutomaticKeepAlives].
|
||||
final bool addAutomaticKeepAlives;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _PositionedListState();
|
||||
}
|
||||
|
||||
class _PositionedListState extends State<PositionedList> {
|
||||
final Key _centerKey = UniqueKey();
|
||||
|
||||
final registeredElements = ValueNotifier<Set<Element>?>(null);
|
||||
late final ScrollController scrollController;
|
||||
|
||||
bool updateScheduled = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
scrollController = widget.controller ?? ScrollController();
|
||||
scrollController.addListener(_schedulePositionNotificationUpdate);
|
||||
_schedulePositionNotificationUpdate();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollController.removeListener(_schedulePositionNotificationUpdate);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PositionedList oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_schedulePositionNotificationUpdate();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => RegistryWidget(
|
||||
elementNotifier: registeredElements,
|
||||
child: UnboundedCustomScrollView(
|
||||
anchor: widget.alignment,
|
||||
center: _centerKey,
|
||||
controller: scrollController,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
reverse: widget.reverse,
|
||||
cacheExtent: widget.cacheExtent,
|
||||
physics: widget.physics,
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
semanticChildCount: widget.semanticChildCount ?? widget.itemCount,
|
||||
slivers: <Widget>[
|
||||
if (widget.positionedIndex > 0)
|
||||
SliverPadding(
|
||||
padding: _leadingSliverPadding,
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => widget.separatorBuilder == null
|
||||
? _buildItem(widget.positionedIndex - (index + 1))
|
||||
: _buildSeparatedListElement(
|
||||
2 * widget.positionedIndex - (index + 1)),
|
||||
childCount: widget.separatorBuilder == null
|
||||
? widget.positionedIndex
|
||||
: 2 * widget.positionedIndex,
|
||||
addSemanticIndexes: false,
|
||||
addRepaintBoundaries: widget.addRepaintBoundaries,
|
||||
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
key: _centerKey,
|
||||
padding: _centerSliverPadding,
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => widget.separatorBuilder == null
|
||||
? _buildItem(index + widget.positionedIndex)
|
||||
: _buildSeparatedListElement(
|
||||
index + 2 * widget.positionedIndex),
|
||||
childCount: widget.itemCount != 0 ? 1 : 0,
|
||||
addSemanticIndexes: false,
|
||||
addRepaintBoundaries: widget.addRepaintBoundaries,
|
||||
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.positionedIndex >= 0 &&
|
||||
widget.positionedIndex < widget.itemCount - 1)
|
||||
SliverPadding(
|
||||
padding: _trailingSliverPadding,
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => widget.separatorBuilder == null
|
||||
? _buildItem(index + widget.positionedIndex + 1)
|
||||
: _buildSeparatedListElement(
|
||||
index + 2 * widget.positionedIndex + 1),
|
||||
childCount: widget.separatorBuilder == null
|
||||
? widget.itemCount - widget.positionedIndex - 1
|
||||
: 2 * (widget.itemCount - widget.positionedIndex - 1),
|
||||
addSemanticIndexes: false,
|
||||
addRepaintBoundaries: widget.addRepaintBoundaries,
|
||||
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildSeparatedListElement(int index) {
|
||||
if (index.isEven) {
|
||||
return _buildItem(index ~/ 2);
|
||||
} else {
|
||||
return widget.separatorBuilder!(context, index ~/ 2);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildItem(int index) {
|
||||
return RegisteredElementWidget(
|
||||
key: ValueKey(index),
|
||||
child: widget.addSemanticIndexes
|
||||
? IndexedSemantics(
|
||||
index: index, child: widget.itemBuilder(context, index))
|
||||
: widget.itemBuilder(context, index),
|
||||
);
|
||||
}
|
||||
|
||||
EdgeInsets get _leadingSliverPadding =>
|
||||
(widget.scrollDirection == Axis.vertical
|
||||
? widget.reverse
|
||||
? widget.padding?.copyWith(top: 0)
|
||||
: widget.padding?.copyWith(bottom: 0)
|
||||
: widget.reverse
|
||||
? widget.padding?.copyWith(left: 0)
|
||||
: widget.padding?.copyWith(right: 0)) ??
|
||||
EdgeInsets.all(0);
|
||||
|
||||
EdgeInsets get _centerSliverPadding => widget.scrollDirection == Axis.vertical
|
||||
? widget.reverse
|
||||
? widget.padding?.copyWith(
|
||||
top: widget.positionedIndex == widget.itemCount - 1
|
||||
? widget.padding!.top
|
||||
: 0,
|
||||
bottom: widget.positionedIndex == 0
|
||||
? widget.padding!.bottom
|
||||
: 0) ??
|
||||
EdgeInsets.all(0)
|
||||
: widget.padding?.copyWith(
|
||||
top: widget.positionedIndex == 0 ? widget.padding!.top : 0,
|
||||
bottom: widget.positionedIndex == widget.itemCount - 1
|
||||
? widget.padding!.bottom
|
||||
: 0) ??
|
||||
EdgeInsets.all(0)
|
||||
: widget.reverse
|
||||
? widget.padding?.copyWith(
|
||||
left: widget.positionedIndex == widget.itemCount - 1
|
||||
? widget.padding!.left
|
||||
: 0,
|
||||
right: widget.positionedIndex == 0
|
||||
? widget.padding!.right
|
||||
: 0) ??
|
||||
EdgeInsets.all(0)
|
||||
: widget.padding?.copyWith(
|
||||
left: widget.positionedIndex == 0 ? widget.padding!.left : 0,
|
||||
right: widget.positionedIndex == widget.itemCount - 1
|
||||
? widget.padding!.right
|
||||
: 0,
|
||||
) ??
|
||||
EdgeInsets.all(0);
|
||||
|
||||
EdgeInsets get _trailingSliverPadding =>
|
||||
widget.scrollDirection == Axis.vertical
|
||||
? widget.reverse
|
||||
? widget.padding?.copyWith(bottom: 0) ?? EdgeInsets.all(0)
|
||||
: widget.padding?.copyWith(top: 0) ?? EdgeInsets.all(0)
|
||||
: widget.reverse
|
||||
? widget.padding?.copyWith(right: 0) ?? EdgeInsets.all(0)
|
||||
: widget.padding?.copyWith(left: 0) ?? EdgeInsets.all(0);
|
||||
|
||||
void _schedulePositionNotificationUpdate() {
|
||||
if (!updateScheduled) {
|
||||
updateScheduled = true;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
final elements = registeredElements.value;
|
||||
if (elements == null) {
|
||||
updateScheduled = false;
|
||||
return;
|
||||
}
|
||||
final positions = <ItemPosition>[];
|
||||
RenderViewportBase? viewport;
|
||||
for (var element in elements) {
|
||||
final RenderBox box = element.renderObject as RenderBox;
|
||||
viewport ??= RenderAbstractViewport.of(box) as RenderViewportBase?;
|
||||
var anchor = 0.0;
|
||||
if (viewport is RenderViewport) {
|
||||
anchor = viewport.anchor;
|
||||
}
|
||||
|
||||
if (viewport is CustomRenderViewport) {
|
||||
anchor = viewport.anchor;
|
||||
}
|
||||
|
||||
final ValueKey<int> key = element.widget.key as ValueKey<int>;
|
||||
// Skip this element if `box` has never been laid out.
|
||||
if (!box.hasSize) continue;
|
||||
if (widget.scrollDirection == Axis.vertical) {
|
||||
final reveal = viewport!.getOffsetToReveal(box, 0).offset;
|
||||
if (!reveal.isFinite) continue;
|
||||
final itemOffset =
|
||||
reveal - viewport.offset.pixels + anchor * viewport.size.height;
|
||||
positions.add(ItemPosition(
|
||||
index: key.value,
|
||||
itemLeadingEdge: itemOffset.round() /
|
||||
scrollController.position.viewportDimension,
|
||||
itemTrailingEdge: (itemOffset + box.size.height).round() /
|
||||
scrollController.position.viewportDimension));
|
||||
} else {
|
||||
final itemOffset =
|
||||
box.localToGlobal(Offset.zero, ancestor: viewport).dx;
|
||||
if (!itemOffset.isFinite) continue;
|
||||
positions.add(ItemPosition(
|
||||
index: key.value,
|
||||
itemLeadingEdge: (widget.reverse
|
||||
? scrollController.position.viewportDimension -
|
||||
(itemOffset + box.size.width)
|
||||
: itemOffset)
|
||||
.round() /
|
||||
scrollController.position.viewportDimension,
|
||||
itemTrailingEdge: (widget.reverse
|
||||
? scrollController.position.viewportDimension -
|
||||
itemOffset
|
||||
: (itemOffset + box.size.width))
|
||||
.round() /
|
||||
scrollController.position.viewportDimension));
|
||||
}
|
||||
}
|
||||
widget.itemPositionsNotifier?.itemPositions.value = positions;
|
||||
updateScheduled = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright 2019 The Fuchsia Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Widget whose [Element] calls a callback when the element is mounted.
|
||||
class PostMountCallback extends StatelessWidget {
|
||||
/// Creates a [PostMountCallback] widget.
|
||||
const PostMountCallback({required this.child, this.callback, Key? key})
|
||||
: super(key: key);
|
||||
|
||||
/// The widget below this widget in the tree.
|
||||
final Widget child;
|
||||
|
||||
/// Callback to call when the element for this widget is mounted.
|
||||
final void Function()? callback;
|
||||
|
||||
@override
|
||||
StatelessElement createElement() => _PostMountCallbackElement(this);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => child;
|
||||
}
|
||||
|
||||
class _PostMountCallbackElement extends StatelessElement {
|
||||
_PostMountCallbackElement(PostMountCallback widget) : super(widget);
|
||||
|
||||
@override
|
||||
void mount(Element? parent, dynamic newSlot) {
|
||||
super.mount(parent, newSlot);
|
||||
final PostMountCallback postMountCallback = widget as PostMountCallback;
|
||||
postMountCallback.callback?.call();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'scroll_offset_notifier.dart';
|
||||
|
||||
/// Provides an affordance for listening to scroll offset changes.
|
||||
///
|
||||
/// This is an experimental API and is subject to change.
|
||||
/// Behavior may be ill-defined in some cases. Please file bugs.
|
||||
abstract class ScrollOffsetListener {
|
||||
/// Stream of scroll offset deltas.
|
||||
Stream<double> get changes;
|
||||
|
||||
/// Construct a ScrollOffsetListener.
|
||||
///
|
||||
/// Set [recordProgrammaticScrolls] to false to prevent reporting of
|
||||
/// programmatic scrolls.
|
||||
factory ScrollOffsetListener.create(
|
||||
{bool recordProgrammaticScrolls = true}) =>
|
||||
ScrollOffsetNotifier(
|
||||
recordProgrammaticScrolls: recordProgrammaticScrolls);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'scroll_offset_listener.dart';
|
||||
|
||||
class ScrollOffsetNotifier implements ScrollOffsetListener {
|
||||
final bool recordProgrammaticScrolls;
|
||||
|
||||
ScrollOffsetNotifier({this.recordProgrammaticScrolls = true});
|
||||
|
||||
final _streamController = StreamController<double>();
|
||||
|
||||
@override
|
||||
Stream<double> get changes => _streamController.stream;
|
||||
|
||||
StreamController get changeController => _streamController;
|
||||
|
||||
void dispose() {
|
||||
_streamController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright 2019 The Fuchsia Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'wrapping.dart';
|
||||
import 'viewport.dart';
|
||||
|
||||
/// A version of [CustomScrollView] that allows does not constrict the extents
|
||||
/// to be within 0 and 1. See [CustomScrollView] for more information.
|
||||
class UnboundedCustomScrollView extends CustomScrollView {
|
||||
final bool _shrinkWrap;
|
||||
|
||||
const UnboundedCustomScrollView({
|
||||
Key? key,
|
||||
Axis scrollDirection = Axis.vertical,
|
||||
bool reverse = false,
|
||||
ScrollController? controller,
|
||||
bool? primary,
|
||||
ScrollPhysics? physics,
|
||||
bool shrinkWrap = false,
|
||||
Key? center,
|
||||
double anchor = 0.0,
|
||||
double? cacheExtent,
|
||||
List<Widget> slivers = const <Widget>[],
|
||||
int? semanticChildCount,
|
||||
DragStartBehavior dragStartBehavior = DragStartBehavior.start,
|
||||
}) : _shrinkWrap = shrinkWrap,
|
||||
_anchor = anchor,
|
||||
super(
|
||||
key: key,
|
||||
scrollDirection: scrollDirection,
|
||||
reverse: reverse,
|
||||
controller: controller,
|
||||
primary: primary,
|
||||
physics: physics,
|
||||
shrinkWrap: false,
|
||||
center: center,
|
||||
cacheExtent: cacheExtent,
|
||||
semanticChildCount: semanticChildCount,
|
||||
dragStartBehavior: dragStartBehavior,
|
||||
slivers: slivers,
|
||||
);
|
||||
|
||||
// [CustomScrollView] enforces constraints on [CustomScrollView.anchor], so
|
||||
// we need our own version.
|
||||
final double _anchor;
|
||||
|
||||
@override
|
||||
double get anchor => _anchor;
|
||||
|
||||
/// Build the viewport.
|
||||
@override
|
||||
@protected
|
||||
Widget buildViewport(
|
||||
BuildContext context,
|
||||
ViewportOffset offset,
|
||||
AxisDirection axisDirection,
|
||||
List<Widget> slivers,
|
||||
) {
|
||||
if (_shrinkWrap) {
|
||||
return CustomShrinkWrappingViewport(
|
||||
axisDirection: axisDirection,
|
||||
offset: offset,
|
||||
slivers: slivers,
|
||||
cacheExtent: cacheExtent,
|
||||
center: center,
|
||||
anchor: anchor,
|
||||
);
|
||||
}
|
||||
return UnboundedViewport(
|
||||
axisDirection: axisDirection,
|
||||
offset: offset,
|
||||
slivers: slivers,
|
||||
cacheExtent: cacheExtent,
|
||||
center: center,
|
||||
anchor: anchor,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
// Copyright 2019 The Fuchsia Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/item_positions_listener.dart';
|
||||
import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/item_positions_notifier.dart';
|
||||
import 'package:hgdj/tools_base/widget/scroll_positioned_list/src/scroll_offset_listener.dart';
|
||||
|
||||
import 'positioned_list.dart';
|
||||
import 'post_mount_callback.dart';
|
||||
import 'scroll_offset_notifier.dart';
|
||||
|
||||
|
||||
/// Number of screens to scroll when scrolling a long distance.
|
||||
const int _screenScrollCount = 2;
|
||||
|
||||
/// A scrollable list of widgets similar to [ListView], except scroll control
|
||||
/// and position reporting is based on index rather than pixel offset.
|
||||
///
|
||||
/// [ScrollablePositionedList] lays out children in the same way as [ListView].
|
||||
///
|
||||
/// The list can be displayed with the item at [initialScrollIndex] positioned
|
||||
/// at a particular [initialAlignment].
|
||||
///
|
||||
/// The [itemScrollController] can be used to scroll or jump to particular items
|
||||
/// in the list. The [itemPositionsNotifier] can be used to get a list of items
|
||||
/// currently laid out by the list.
|
||||
///
|
||||
/// The [scrollOffsetListener] can be used to get updates about scroll position
|
||||
/// changes.
|
||||
///
|
||||
/// All other parameters are the same as specified in [ListView].
|
||||
class ScrollablePositionedList extends StatefulWidget {
|
||||
/// Create a [ScrollablePositionedList] whose items are provided by
|
||||
/// [itemBuilder].
|
||||
const ScrollablePositionedList.builder({
|
||||
required this.itemCount,
|
||||
required this.itemBuilder,
|
||||
Key? key,
|
||||
this.itemScrollController,
|
||||
this.shrinkWrap = false,
|
||||
ItemPositionsListener? itemPositionsListener,
|
||||
this.scrollOffsetController,
|
||||
ScrollOffsetListener? scrollOffsetListener,
|
||||
this.initialScrollIndex = 0,
|
||||
this.initialAlignment = 0,
|
||||
this.scrollDirection = Axis.vertical,
|
||||
this.reverse = false,
|
||||
this.physics,
|
||||
this.semanticChildCount,
|
||||
this.padding,
|
||||
this.addSemanticIndexes = true,
|
||||
this.addAutomaticKeepAlives = true,
|
||||
this.addRepaintBoundaries = true,
|
||||
this.minCacheExtent,
|
||||
this.scrollAction,
|
||||
}) : assert(itemCount != null),
|
||||
assert(itemBuilder != null),
|
||||
itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
|
||||
scrollOffsetNotifier = scrollOffsetListener as ScrollOffsetNotifier?,
|
||||
separatorBuilder = null,
|
||||
super(key: key);
|
||||
|
||||
/// Create a [ScrollablePositionedList] whose items are provided by
|
||||
/// [itemBuilder] and separators provided by [separatorBuilder].
|
||||
const ScrollablePositionedList.separated({
|
||||
required this.itemCount,
|
||||
required this.itemBuilder,
|
||||
required this.separatorBuilder,
|
||||
Key? key,
|
||||
this.shrinkWrap = false,
|
||||
this.itemScrollController,
|
||||
ItemPositionsListener? itemPositionsListener,
|
||||
this.scrollOffsetController,
|
||||
ScrollOffsetListener? scrollOffsetListener,
|
||||
this.initialScrollIndex = 0,
|
||||
this.initialAlignment = 0,
|
||||
this.scrollDirection = Axis.vertical,
|
||||
this.reverse = false,
|
||||
this.physics,
|
||||
this.semanticChildCount,
|
||||
this.padding,
|
||||
this.addSemanticIndexes = true,
|
||||
this.addAutomaticKeepAlives = true,
|
||||
this.addRepaintBoundaries = true,
|
||||
this.minCacheExtent,
|
||||
this.scrollAction,
|
||||
}) : assert(itemCount != null),
|
||||
assert(itemBuilder != null),
|
||||
assert(separatorBuilder != null),
|
||||
itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?,
|
||||
scrollOffsetNotifier = scrollOffsetListener as ScrollOffsetNotifier?,
|
||||
super(key: key);
|
||||
|
||||
/// Number of items the [itemBuilder] can produce.
|
||||
final int itemCount;
|
||||
|
||||
/// Called to build children for the list with
|
||||
/// 0 <= index < itemCount.
|
||||
final IndexedWidgetBuilder itemBuilder;
|
||||
|
||||
/// Called to build separators for between each item in the list.
|
||||
/// Called with 0 <= index < itemCount - 1.
|
||||
final IndexedWidgetBuilder? separatorBuilder;
|
||||
|
||||
/// Controller for jumping or scrolling to an item.
|
||||
final ItemScrollController? itemScrollController;
|
||||
|
||||
/// Notifier that reports the items laid out in the list after each frame.
|
||||
final ItemPositionsNotifier? itemPositionsNotifier;
|
||||
|
||||
final ScrollOffsetController? scrollOffsetController;
|
||||
|
||||
/// Notifier that reports the changes to the scroll offset.
|
||||
final ScrollOffsetNotifier? scrollOffsetNotifier;
|
||||
|
||||
/// Index of an item to initially align within the viewport.
|
||||
final int initialScrollIndex;
|
||||
|
||||
/// Determines where the leading edge of the item at [initialScrollIndex]
|
||||
/// should be placed.
|
||||
///
|
||||
/// See [ItemScrollController.jumpTo] for an explanation of alignment.
|
||||
final double initialAlignment;
|
||||
|
||||
/// The axis along which the scroll view scrolls.
|
||||
///
|
||||
/// Defaults to [Axis.vertical].
|
||||
final Axis scrollDirection;
|
||||
|
||||
/// Whether the view scrolls in the reading direction.
|
||||
///
|
||||
/// Defaults to false.
|
||||
///
|
||||
/// See [ScrollView.reverse].
|
||||
final bool reverse;
|
||||
|
||||
/// {@template flutter.widgets.scroll_view.shrinkWrap}
|
||||
/// Whether the extent of the scroll view in the [scrollDirection] should be
|
||||
/// determined by the contents being viewed.
|
||||
///
|
||||
/// Defaults to false.
|
||||
///
|
||||
/// See [ScrollView.shrinkWrap].
|
||||
final bool shrinkWrap;
|
||||
|
||||
/// How the scroll view should respond to user input.
|
||||
///
|
||||
/// For example, determines how the scroll view continues to animate after the
|
||||
/// user stops dragging the scroll view.
|
||||
///
|
||||
/// See [ScrollView.physics].
|
||||
final ScrollPhysics? physics;
|
||||
|
||||
/// The number of children that will contribute semantic information.
|
||||
///
|
||||
/// See [ScrollView.semanticChildCount] for more information.
|
||||
final int? semanticChildCount;
|
||||
|
||||
/// The amount of space by which to inset the children.
|
||||
final EdgeInsets? padding;
|
||||
|
||||
/// Whether to wrap each child in an [IndexedSemantics].
|
||||
///
|
||||
/// See [SliverChildBuilderDelegate.addSemanticIndexes].
|
||||
final bool addSemanticIndexes;
|
||||
|
||||
/// Whether to wrap each child in an [AutomaticKeepAlive].
|
||||
///
|
||||
/// See [SliverChildBuilderDelegate.addAutomaticKeepAlives].
|
||||
final bool addAutomaticKeepAlives;
|
||||
|
||||
/// Whether to wrap each child in a [RepaintBoundary].
|
||||
///
|
||||
/// See [SliverChildBuilderDelegate.addRepaintBoundaries].
|
||||
final bool addRepaintBoundaries;
|
||||
|
||||
/// The minimum cache extent used by the underlying scroll lists.
|
||||
/// See [ScrollView.cacheExtent].
|
||||
///
|
||||
/// Note that the [ScrollablePositionedList] uses two lists to simulate long
|
||||
/// scrolls, so using the [ScrollController.scrollTo] method may result
|
||||
/// in builds of widgets that would otherwise already be built in the
|
||||
/// cache extent.
|
||||
final double? minCacheExtent;
|
||||
|
||||
final Function(ScrollController)? scrollAction;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => _ScrollablePositionedListState();
|
||||
}
|
||||
|
||||
/// Controller to jump or scroll to a particular position in a
|
||||
/// [ScrollablePositionedList].
|
||||
class ItemScrollController {
|
||||
/// Whether any ScrollablePositionedList objects are attached this object.
|
||||
///
|
||||
/// If `false`, then [jumpTo] and [scrollTo] must not be called.
|
||||
bool get isAttached => _scrollableListState != null;
|
||||
|
||||
_ScrollablePositionedListState? _scrollableListState;
|
||||
|
||||
/// Immediately, without animation, reconfigure the list so that the item at
|
||||
/// [index]'s leading edge is at the given [alignment].
|
||||
///
|
||||
/// The [alignment] specifies the desired position for the leading edge of the
|
||||
/// item. The [alignment] is expected to be a value in the range \[0.0, 1.0\]
|
||||
/// and represents a proportion along the main axis of the viewport.
|
||||
///
|
||||
/// For a vertically scrolling view that is not reversed:
|
||||
/// * 0 aligns the top edge of the item with the top edge of the view.
|
||||
/// * 1 aligns the top edge of the item with the bottom of the view.
|
||||
/// * 0.5 aligns the top edge of the item with the center of the view.
|
||||
///
|
||||
/// For a horizontally scrolling view that is not reversed:
|
||||
/// * 0 aligns the left edge of the item with the left edge of the view
|
||||
/// * 1 aligns the left edge of the item with the right edge of the view.
|
||||
/// * 0.5 aligns the left edge of the item with the center of the view.
|
||||
void jumpTo({required int index, double alignment = 0}) {
|
||||
_scrollableListState!._jumpTo(index: index, alignment: alignment);
|
||||
}
|
||||
|
||||
/// Animate the list over [duration] using the given [curve] such that the
|
||||
/// item at [index] ends up with its leading edge at the given [alignment].
|
||||
/// See [jumpTo] for an explanation of alignment.
|
||||
///
|
||||
/// The [duration] must be greater than 0; otherwise, use [jumpTo].
|
||||
///
|
||||
/// When item position is not available, because it's too far, the scroll
|
||||
/// is composed into three phases:
|
||||
///
|
||||
/// 1. The currently displayed list view starts scrolling.
|
||||
/// 2. Another list view, which scrolls with the same speed, fades over the
|
||||
/// first one and shows items that are close to the scroll target.
|
||||
/// 3. The second list view scrolls and stops on the target.
|
||||
///
|
||||
/// The [opacityAnimationWeights] can be used to apply custom weights to these
|
||||
/// three stages of this animation. The default weights, `[40, 20, 40]`, are
|
||||
/// good with default [Curves.linear]. Different weights might be better for
|
||||
/// other cases. For example, if you use [Curves.easeOut], consider setting
|
||||
/// [opacityAnimationWeights] to `[20, 20, 60]`.
|
||||
///
|
||||
/// See [TweenSequenceItem.weight] for more info.
|
||||
Future<void> scrollTo({
|
||||
required int index,
|
||||
double alignment = 0,
|
||||
required Duration duration,
|
||||
Curve curve = Curves.linear,
|
||||
List<double> opacityAnimationWeights = const [40, 20, 40],
|
||||
}) {
|
||||
assert(_scrollableListState != null);
|
||||
assert(opacityAnimationWeights.length == 3);
|
||||
assert(duration > Duration.zero);
|
||||
return _scrollableListState!._scrollTo(
|
||||
index: index,
|
||||
alignment: alignment,
|
||||
duration: duration,
|
||||
curve: curve,
|
||||
opacityAnimationWeights: opacityAnimationWeights,
|
||||
);
|
||||
}
|
||||
|
||||
void _attach(_ScrollablePositionedListState scrollableListState) {
|
||||
assert(_scrollableListState == null);
|
||||
_scrollableListState = scrollableListState;
|
||||
}
|
||||
|
||||
void _detach() {
|
||||
_scrollableListState = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Controller to scroll a certain number of pixels relative to the current
|
||||
/// scroll offset.
|
||||
///
|
||||
/// Scrolls [offset] pixels relative to the current scroll offset. [offset] can
|
||||
/// be positive or negative.
|
||||
///
|
||||
/// This is an experimental API and is subject to change.
|
||||
/// Behavior may be ill-defined in some cases. Please file bugs.
|
||||
class ScrollOffsetController {
|
||||
Future<void> animateScroll(
|
||||
{required double offset,
|
||||
required Duration duration,
|
||||
Curve curve = Curves.linear}) async {
|
||||
final currentPosition =
|
||||
_scrollableListState!.primary.scrollController.offset;
|
||||
final newPosition = currentPosition + offset;
|
||||
await _scrollableListState!.primary.scrollController.animateTo(
|
||||
newPosition,
|
||||
duration: duration,
|
||||
curve: curve,
|
||||
);
|
||||
}
|
||||
|
||||
_ScrollablePositionedListState? _scrollableListState;
|
||||
|
||||
void _attach(_ScrollablePositionedListState scrollableListState) {
|
||||
assert(_scrollableListState == null);
|
||||
_scrollableListState = scrollableListState;
|
||||
}
|
||||
|
||||
void _detach() {
|
||||
_scrollableListState = null;
|
||||
}
|
||||
}
|
||||
|
||||
class _ScrollablePositionedListState extends State<ScrollablePositionedList>
|
||||
with TickerProviderStateMixin {
|
||||
/// Details for the primary (active) [ListView].
|
||||
var primary = _ListDisplayDetails(const ValueKey('Ping'));
|
||||
|
||||
/// Details for the secondary (transitional) [ListView] that is temporarily
|
||||
/// shown when scrolling a long distance.
|
||||
var secondary = _ListDisplayDetails(const ValueKey('Pong'));
|
||||
|
||||
final opacity = ProxyAnimation(const AlwaysStoppedAnimation<double>(0));
|
||||
|
||||
void Function() startAnimationCallback = () {};
|
||||
|
||||
bool _isTransitioning = false;
|
||||
|
||||
var _animationController;
|
||||
|
||||
double previousOffset = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ItemPosition? initialPosition = PageStorage.of(context).readState(context);
|
||||
primary.target = initialPosition?.index ?? widget.initialScrollIndex;
|
||||
primary.alignment =
|
||||
initialPosition?.itemLeadingEdge ?? widget.initialAlignment;
|
||||
if (widget.itemCount > 0 && primary.target > widget.itemCount - 1) {
|
||||
primary.target = widget.itemCount - 1;
|
||||
}
|
||||
widget.itemScrollController?._attach(this);
|
||||
widget.scrollOffsetController?._attach(this);
|
||||
primary.itemPositionsNotifier.itemPositions.addListener(_updatePositions);
|
||||
secondary.itemPositionsNotifier.itemPositions.addListener(_updatePositions);
|
||||
primary.scrollController.addListener(() {
|
||||
final currentOffset = primary.scrollController.offset;
|
||||
final offsetChange = currentOffset - previousOffset;
|
||||
previousOffset = currentOffset;
|
||||
if (!_isTransitioning |
|
||||
(widget.scrollOffsetNotifier?.recordProgrammaticScrolls ?? false)) {
|
||||
widget.scrollOffsetNotifier?.changeController.add(offsetChange);
|
||||
}
|
||||
if (widget.scrollAction != null) {
|
||||
widget.scrollAction?.call(primary.scrollController);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void activate() {
|
||||
super.activate();
|
||||
widget.itemScrollController?._attach(this);
|
||||
widget.scrollOffsetController?._attach(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void deactivate() {
|
||||
widget.itemScrollController?._detach();
|
||||
widget.scrollOffsetController?._detach();
|
||||
super.deactivate();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
primary.itemPositionsNotifier.itemPositions
|
||||
.removeListener(_updatePositions);
|
||||
secondary.itemPositionsNotifier.itemPositions
|
||||
.removeListener(_updatePositions);
|
||||
_animationController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ScrollablePositionedList oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.itemScrollController?._scrollableListState == this) {
|
||||
oldWidget.itemScrollController?._detach();
|
||||
}
|
||||
if (widget.itemScrollController?._scrollableListState != this) {
|
||||
widget.itemScrollController?._detach();
|
||||
widget.itemScrollController?._attach(this);
|
||||
}
|
||||
|
||||
if (widget.itemCount == 0) {
|
||||
setState(() {
|
||||
primary.target = 0;
|
||||
secondary.target = 0;
|
||||
});
|
||||
} else {
|
||||
if (primary.target > widget.itemCount - 1) {
|
||||
setState(() {
|
||||
primary.target = widget.itemCount - 1;
|
||||
});
|
||||
}
|
||||
if (secondary.target > widget.itemCount - 1) {
|
||||
setState(() {
|
||||
secondary.target = widget.itemCount - 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final cacheExtent = _cacheExtent(constraints);
|
||||
return Listener(
|
||||
onPointerDown: (_) => _stopScroll(canceled: true),
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
PostMountCallback(
|
||||
key: primary.key,
|
||||
callback: startAnimationCallback,
|
||||
child: FadeTransition(
|
||||
opacity: ReverseAnimation(opacity),
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (_) => _isTransitioning,
|
||||
child: PositionedList(
|
||||
itemBuilder: widget.itemBuilder,
|
||||
separatorBuilder: widget.separatorBuilder,
|
||||
itemCount: widget.itemCount,
|
||||
positionedIndex: primary.target,
|
||||
controller: primary.scrollController,
|
||||
itemPositionsNotifier: primary.itemPositionsNotifier,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
reverse: widget.reverse,
|
||||
cacheExtent: cacheExtent,
|
||||
alignment: primary.alignment,
|
||||
physics: widget.physics,
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
addSemanticIndexes: widget.addSemanticIndexes,
|
||||
semanticChildCount: widget.semanticChildCount,
|
||||
padding: widget.padding,
|
||||
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
|
||||
addRepaintBoundaries: widget.addRepaintBoundaries,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isTransitioning)
|
||||
PostMountCallback(
|
||||
key: secondary.key,
|
||||
callback: startAnimationCallback,
|
||||
child: FadeTransition(
|
||||
opacity: opacity,
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (_) => false,
|
||||
child: PositionedList(
|
||||
itemBuilder: widget.itemBuilder,
|
||||
separatorBuilder: widget.separatorBuilder,
|
||||
itemCount: widget.itemCount,
|
||||
itemPositionsNotifier: secondary.itemPositionsNotifier,
|
||||
positionedIndex: secondary.target,
|
||||
controller: secondary.scrollController,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
reverse: widget.reverse,
|
||||
cacheExtent: cacheExtent,
|
||||
alignment: secondary.alignment,
|
||||
physics: widget.physics,
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
addSemanticIndexes: widget.addSemanticIndexes,
|
||||
semanticChildCount: widget.semanticChildCount,
|
||||
padding: widget.padding,
|
||||
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
|
||||
addRepaintBoundaries: widget.addRepaintBoundaries,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
double _cacheExtent(BoxConstraints constraints) => max(
|
||||
(widget.scrollDirection == Axis.vertical
|
||||
? constraints.maxHeight
|
||||
: constraints.maxWidth) *
|
||||
_screenScrollCount,
|
||||
widget.minCacheExtent ?? 0,
|
||||
);
|
||||
|
||||
void _jumpTo({required int index, required double alignment}) {
|
||||
_stopScroll(canceled: true);
|
||||
if (index > widget.itemCount - 1) {
|
||||
index = widget.itemCount - 1;
|
||||
}
|
||||
setState(() {
|
||||
primary.scrollController.jumpTo(0);
|
||||
primary.target = index;
|
||||
primary.alignment = alignment;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _scrollTo({
|
||||
required int index,
|
||||
required double alignment,
|
||||
required Duration duration,
|
||||
Curve curve = Curves.linear,
|
||||
required List<double> opacityAnimationWeights,
|
||||
}) async {
|
||||
if (index > widget.itemCount - 1) {
|
||||
index = widget.itemCount - 1;
|
||||
}
|
||||
if (_isTransitioning) {
|
||||
final scrollCompleter = Completer<void>();
|
||||
_stopScroll(canceled: true);
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) async {
|
||||
await _startScroll(
|
||||
index: index,
|
||||
alignment: alignment,
|
||||
duration: duration,
|
||||
curve: curve,
|
||||
opacityAnimationWeights: opacityAnimationWeights,
|
||||
);
|
||||
scrollCompleter.complete();
|
||||
});
|
||||
await scrollCompleter.future;
|
||||
} else {
|
||||
await _startScroll(
|
||||
index: index,
|
||||
alignment: alignment,
|
||||
duration: duration,
|
||||
curve: curve,
|
||||
opacityAnimationWeights: opacityAnimationWeights,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startScroll({
|
||||
required int index,
|
||||
required double alignment,
|
||||
required Duration duration,
|
||||
Curve curve = Curves.linear,
|
||||
required List<double> opacityAnimationWeights,
|
||||
}) async {
|
||||
final direction = index > primary.target ? 1 : -1;
|
||||
final itemPosition = primary.itemPositionsNotifier.itemPositions.value
|
||||
.firstWhereOrNull(
|
||||
(ItemPosition itemPosition) => itemPosition.index == index);
|
||||
if (itemPosition != null) {
|
||||
// Scroll directly.
|
||||
final localScrollAmount = itemPosition.itemLeadingEdge *
|
||||
primary.scrollController.position.viewportDimension;
|
||||
await primary.scrollController.animateTo(
|
||||
primary.scrollController.offset +
|
||||
localScrollAmount -
|
||||
alignment * primary.scrollController.position.viewportDimension,
|
||||
duration: duration,
|
||||
curve: curve);
|
||||
} else {
|
||||
final scrollAmount = _screenScrollCount *
|
||||
primary.scrollController.position.viewportDimension;
|
||||
final startCompleter = Completer<void>();
|
||||
final endCompleter = Completer<void>();
|
||||
startAnimationCallback = () {
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
startAnimationCallback = () {};
|
||||
_animationController?.dispose();
|
||||
_animationController =
|
||||
AnimationController(vsync: this, duration: duration)..forward();
|
||||
opacity.parent = _opacityAnimation(opacityAnimationWeights)
|
||||
.animate(_animationController);
|
||||
secondary.scrollController.jumpTo(-direction *
|
||||
(_screenScrollCount *
|
||||
primary.scrollController.position.viewportDimension -
|
||||
alignment *
|
||||
secondary.scrollController.position.viewportDimension));
|
||||
|
||||
startCompleter.complete(primary.scrollController.animateTo(
|
||||
primary.scrollController.offset + direction * scrollAmount,
|
||||
duration: duration,
|
||||
curve: curve));
|
||||
endCompleter.complete(secondary.scrollController
|
||||
.animateTo(0, duration: duration, curve: curve));
|
||||
});
|
||||
};
|
||||
setState(() {
|
||||
// TODO: _startScroll can be re-entrant, which invalidates this assert.
|
||||
// assert(!_isTransitioning);
|
||||
secondary.target = index;
|
||||
secondary.alignment = alignment;
|
||||
_isTransitioning = true;
|
||||
});
|
||||
await Future.wait<void>([startCompleter.future, endCompleter.future]);
|
||||
_stopScroll();
|
||||
}
|
||||
}
|
||||
|
||||
void _stopScroll({bool canceled = false}) {
|
||||
if (!_isTransitioning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canceled) {
|
||||
if (primary.scrollController.hasClients) {
|
||||
primary.scrollController.jumpTo(primary.scrollController.offset);
|
||||
}
|
||||
if (secondary.scrollController.hasClients) {
|
||||
secondary.scrollController.jumpTo(secondary.scrollController.offset);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
if (opacity.value >= 0.5) {
|
||||
// Secondary [ListView] is more visible than the primary; make it the
|
||||
// new primary.
|
||||
var temp = primary;
|
||||
primary = secondary;
|
||||
secondary = temp;
|
||||
}
|
||||
_isTransitioning = false;
|
||||
opacity.parent = const AlwaysStoppedAnimation<double>(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Animatable<double> _opacityAnimation(List<double> opacityAnimationWeights) {
|
||||
final startOpacity = 0.0;
|
||||
final endOpacity = 1.0;
|
||||
return TweenSequence<double>(<TweenSequenceItem<double>>[
|
||||
TweenSequenceItem<double>(
|
||||
tween: ConstantTween<double>(startOpacity),
|
||||
weight: opacityAnimationWeights[0]),
|
||||
TweenSequenceItem<double>(
|
||||
tween: Tween<double>(begin: startOpacity, end: endOpacity),
|
||||
weight: opacityAnimationWeights[1]),
|
||||
TweenSequenceItem<double>(
|
||||
tween: ConstantTween<double>(endOpacity),
|
||||
weight: opacityAnimationWeights[2]),
|
||||
]);
|
||||
}
|
||||
|
||||
void _updatePositions() {
|
||||
final itemPositions = primary.itemPositionsNotifier.itemPositions.value
|
||||
.where((ItemPosition position) =>
|
||||
position.itemLeadingEdge < 1 && position.itemTrailingEdge > 0);
|
||||
if (itemPositions.isNotEmpty) {
|
||||
PageStorage.of(context).writeState(
|
||||
context,
|
||||
itemPositions.reduce((value, element) =>
|
||||
value.itemLeadingEdge < element.itemLeadingEdge
|
||||
? value
|
||||
: element));
|
||||
}
|
||||
widget.itemPositionsNotifier?.itemPositions.value = itemPositions;
|
||||
}
|
||||
}
|
||||
|
||||
class _ListDisplayDetails {
|
||||
_ListDisplayDetails(this.key);
|
||||
|
||||
final itemPositionsNotifier = ItemPositionsNotifier();
|
||||
final scrollController = ScrollController(keepScrollOffset: false);
|
||||
|
||||
/// The index of the item to scroll to.
|
||||
int target = 0;
|
||||
|
||||
/// The desired alignment for [target].
|
||||
///
|
||||
/// See [ItemScrollController.jumpTo] for an explanation of alignment.
|
||||
double alignment = 0;
|
||||
|
||||
final Key key;
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// Copyright 2019 The Fuchsia Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// A render object that is bigger on the inside.
|
||||
///
|
||||
/// Version of [Viewport] with some modifications to how extents are
|
||||
/// computed to allow scroll extents outside 0 to 1. See [Viewport]
|
||||
/// for more information.
|
||||
class UnboundedViewport extends Viewport {
|
||||
UnboundedViewport({
|
||||
Key? key,
|
||||
AxisDirection axisDirection = AxisDirection.down,
|
||||
AxisDirection? crossAxisDirection,
|
||||
double anchor = 0.0,
|
||||
required ViewportOffset offset,
|
||||
Key? center,
|
||||
double? cacheExtent,
|
||||
List<Widget> slivers = const <Widget>[],
|
||||
}) : _anchor = anchor,
|
||||
super(
|
||||
key: key,
|
||||
axisDirection: axisDirection,
|
||||
crossAxisDirection: crossAxisDirection,
|
||||
offset: offset,
|
||||
center: center,
|
||||
cacheExtent: cacheExtent,
|
||||
slivers: slivers);
|
||||
|
||||
// [Viewport] enforces constraints on [Viewport.anchor], so we need our own
|
||||
// version.
|
||||
final double _anchor;
|
||||
|
||||
@override
|
||||
double get anchor => _anchor;
|
||||
|
||||
@override
|
||||
RenderViewport createRenderObject(BuildContext context) {
|
||||
return UnboundedRenderViewport(
|
||||
axisDirection: axisDirection,
|
||||
crossAxisDirection: crossAxisDirection ??
|
||||
Viewport.getDefaultCrossAxisDirection(context, axisDirection),
|
||||
anchor: anchor,
|
||||
offset: offset,
|
||||
cacheExtent: cacheExtent,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A render object that is bigger on the inside.
|
||||
///
|
||||
/// Version of [RenderViewport] with some modifications to how extents are
|
||||
/// computed to allow scroll extents outside 0 to 1. See [RenderViewport]
|
||||
/// for more information.
|
||||
///
|
||||
// Differences from [RenderViewport] are marked with a //***** Differences
|
||||
// comment.
|
||||
class UnboundedRenderViewport extends RenderViewport {
|
||||
/// Creates a viewport for [RenderSliver] objects.
|
||||
UnboundedRenderViewport({
|
||||
AxisDirection axisDirection = AxisDirection.down,
|
||||
required AxisDirection crossAxisDirection,
|
||||
required ViewportOffset offset,
|
||||
double anchor = 0.0,
|
||||
List<RenderSliver>? children,
|
||||
RenderSliver? center,
|
||||
double? cacheExtent,
|
||||
}) : _anchor = anchor,
|
||||
super(
|
||||
axisDirection: axisDirection,
|
||||
crossAxisDirection: crossAxisDirection,
|
||||
offset: offset,
|
||||
center: center,
|
||||
cacheExtent: cacheExtent,
|
||||
children: children);
|
||||
|
||||
static const int _maxLayoutCycles = 10;
|
||||
|
||||
double _anchor;
|
||||
|
||||
// Out-of-band data computed during layout.
|
||||
late double _minScrollExtent;
|
||||
late double _maxScrollExtent;
|
||||
bool _hasVisualOverflow = false;
|
||||
|
||||
/// This value is set during layout based on the [CacheExtentStyle].
|
||||
///
|
||||
/// When the style is [CacheExtentStyle.viewport], it is the main axis extent
|
||||
/// of the viewport multiplied by the requested cache extent, which is still
|
||||
/// expressed in pixels.
|
||||
double? _calculatedCacheExtent;
|
||||
|
||||
@override
|
||||
double get anchor => _anchor;
|
||||
|
||||
@override
|
||||
set anchor(double value) {
|
||||
assert(value != null);
|
||||
if (value == _anchor) return;
|
||||
_anchor = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
@override
|
||||
void performResize() {
|
||||
super.performResize();
|
||||
// TODO: Figure out why this override is needed as a result of
|
||||
// https://github.com/flutter/flutter/pull/61973 and see if it can be
|
||||
// removed somehow.
|
||||
switch (axis) {
|
||||
case Axis.vertical:
|
||||
offset.applyViewportDimension(size.height);
|
||||
break;
|
||||
case Axis.horizontal:
|
||||
offset.applyViewportDimension(size.width);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Rect describeSemanticsClip(RenderSliver? child) {
|
||||
assert(axis != null);
|
||||
|
||||
if (_calculatedCacheExtent == null) {
|
||||
return semanticBounds;
|
||||
}
|
||||
|
||||
switch (axis) {
|
||||
case Axis.vertical:
|
||||
return Rect.fromLTRB(
|
||||
semanticBounds.left,
|
||||
semanticBounds.top - _calculatedCacheExtent!,
|
||||
semanticBounds.right,
|
||||
semanticBounds.bottom + _calculatedCacheExtent!,
|
||||
);
|
||||
default:
|
||||
return Rect.fromLTRB(
|
||||
semanticBounds.left - _calculatedCacheExtent!,
|
||||
semanticBounds.top,
|
||||
semanticBounds.right + _calculatedCacheExtent!,
|
||||
semanticBounds.bottom,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
if (center == null) {
|
||||
assert(firstChild == null);
|
||||
_minScrollExtent = 0.0;
|
||||
_maxScrollExtent = 0.0;
|
||||
_hasVisualOverflow = false;
|
||||
offset.applyContentDimensions(0.0, 0.0);
|
||||
return;
|
||||
}
|
||||
assert(center!.parent == this);
|
||||
|
||||
late double mainAxisExtent;
|
||||
late double crossAxisExtent;
|
||||
switch (axis) {
|
||||
case Axis.vertical:
|
||||
mainAxisExtent = size.height;
|
||||
crossAxisExtent = size.width;
|
||||
break;
|
||||
case Axis.horizontal:
|
||||
mainAxisExtent = size.width;
|
||||
crossAxisExtent = size.height;
|
||||
break;
|
||||
}
|
||||
|
||||
final centerOffsetAdjustment = center!.centerOffsetAdjustment;
|
||||
|
||||
double correction;
|
||||
var count = 0;
|
||||
do {
|
||||
assert(offset.pixels != null);
|
||||
correction = _attemptLayout(mainAxisExtent, crossAxisExtent,
|
||||
offset.pixels + centerOffsetAdjustment);
|
||||
if (correction != 0.0) {
|
||||
offset.correctBy(correction);
|
||||
} else {
|
||||
// *** Difference from [RenderViewport].
|
||||
final top = _minScrollExtent + mainAxisExtent * anchor;
|
||||
final bottom = _maxScrollExtent - mainAxisExtent * (1.0 - anchor);
|
||||
final maxScrollOffset = math.max(math.min(0.0, top), bottom);
|
||||
final minScrollOffset = math.min(top, maxScrollOffset);
|
||||
if (offset.applyContentDimensions(minScrollOffset, maxScrollOffset))
|
||||
break;
|
||||
// *** End of difference from [RenderViewport].
|
||||
}
|
||||
count += 1;
|
||||
} while (count < _maxLayoutCycles);
|
||||
assert(() {
|
||||
if (count >= _maxLayoutCycles) {
|
||||
assert(count != 1);
|
||||
throw FlutterError(
|
||||
'A RenderViewport exceeded its maximum number of layout cycles.\n'
|
||||
'RenderViewport render objects, during layout, can retry if either their '
|
||||
'slivers or their ViewportOffset decide that the offset should be corrected '
|
||||
'to take into account information collected during that layout.\n'
|
||||
'In the case of this RenderViewport object, however, this happened $count '
|
||||
'times and still there was no consensus on the scroll offset. This usually '
|
||||
'indicates a bug. Specifically, it means that one of the following three '
|
||||
'problems is being experienced by the RenderViewport object:\n'
|
||||
' * One of the RenderSliver children or the ViewportOffset have a bug such'
|
||||
' that they always think that they need to correct the offset regardless.\n'
|
||||
' * Some combination of the RenderSliver children and the ViewportOffset'
|
||||
' have a bad interaction such that one applies a correction then another'
|
||||
' applies a reverse correction, leading to an infinite loop of corrections.\n'
|
||||
' * There is a pathological case that would eventually resolve, but it is'
|
||||
' so complicated that it cannot be resolved in any reasonable number of'
|
||||
' layout passes.');
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
}
|
||||
|
||||
double _attemptLayout(
|
||||
double mainAxisExtent, double crossAxisExtent, double correctedOffset) {
|
||||
assert(!mainAxisExtent.isNaN);
|
||||
assert(mainAxisExtent >= 0.0);
|
||||
assert(crossAxisExtent.isFinite);
|
||||
assert(crossAxisExtent >= 0.0);
|
||||
assert(correctedOffset.isFinite);
|
||||
_minScrollExtent = 0.0;
|
||||
_maxScrollExtent = 0.0;
|
||||
_hasVisualOverflow = false;
|
||||
|
||||
// centerOffset is the offset from the leading edge of the RenderViewport
|
||||
// to the zero scroll offset (the line between the forward slivers and the
|
||||
// reverse slivers).
|
||||
final double centerOffset = mainAxisExtent * anchor - correctedOffset;
|
||||
final double reverseDirectionRemainingPaintExtent =
|
||||
centerOffset.clamp(0.0, mainAxisExtent);
|
||||
final double forwardDirectionRemainingPaintExtent =
|
||||
(mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent);
|
||||
|
||||
switch (cacheExtentStyle) {
|
||||
case CacheExtentStyle.pixel:
|
||||
_calculatedCacheExtent = cacheExtent;
|
||||
break;
|
||||
case CacheExtentStyle.viewport:
|
||||
_calculatedCacheExtent = mainAxisExtent * cacheExtent!;
|
||||
break;
|
||||
}
|
||||
|
||||
final double fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!;
|
||||
final double centerCacheOffset = centerOffset + _calculatedCacheExtent!;
|
||||
final double reverseDirectionRemainingCacheExtent =
|
||||
centerCacheOffset.clamp(0.0, fullCacheExtent);
|
||||
final double forwardDirectionRemainingCacheExtent =
|
||||
(fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent);
|
||||
|
||||
final RenderSliver? leadingNegativeChild = childBefore(center!);
|
||||
|
||||
if (leadingNegativeChild != null) {
|
||||
// negative scroll offsets
|
||||
final double result = layoutChildSequence(
|
||||
child: leadingNegativeChild,
|
||||
scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent,
|
||||
overlap: 0.0,
|
||||
layoutOffset: forwardDirectionRemainingPaintExtent,
|
||||
remainingPaintExtent: reverseDirectionRemainingPaintExtent,
|
||||
mainAxisExtent: mainAxisExtent,
|
||||
crossAxisExtent: crossAxisExtent,
|
||||
growthDirection: GrowthDirection.reverse,
|
||||
advance: childBefore,
|
||||
remainingCacheExtent: reverseDirectionRemainingCacheExtent,
|
||||
cacheOrigin: (mainAxisExtent - centerOffset)
|
||||
.clamp(-_calculatedCacheExtent!, 0.0),
|
||||
);
|
||||
if (result != 0.0) return -result;
|
||||
}
|
||||
|
||||
// positive scroll offsets
|
||||
return layoutChildSequence(
|
||||
child: center,
|
||||
scrollOffset: math.max(0.0, -centerOffset),
|
||||
overlap:
|
||||
leadingNegativeChild == null ? math.min(0.0, -centerOffset) : 0.0,
|
||||
layoutOffset: centerOffset >= mainAxisExtent
|
||||
? centerOffset
|
||||
: reverseDirectionRemainingPaintExtent,
|
||||
remainingPaintExtent: forwardDirectionRemainingPaintExtent,
|
||||
mainAxisExtent: mainAxisExtent,
|
||||
crossAxisExtent: crossAxisExtent,
|
||||
growthDirection: GrowthDirection.forward,
|
||||
advance: childAfter,
|
||||
remainingCacheExtent: forwardDirectionRemainingCacheExtent,
|
||||
cacheOrigin: centerOffset.clamp(-_calculatedCacheExtent!, 0.0),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get hasVisualOverflow => _hasVisualOverflow;
|
||||
|
||||
@override
|
||||
void updateOutOfBandData(
|
||||
GrowthDirection growthDirection, SliverGeometry childLayoutGeometry) {
|
||||
switch (growthDirection) {
|
||||
case GrowthDirection.forward:
|
||||
_maxScrollExtent += childLayoutGeometry.scrollExtent;
|
||||
break;
|
||||
case GrowthDirection.reverse:
|
||||
_minScrollExtent -= childLayoutGeometry.scrollExtent;
|
||||
break;
|
||||
}
|
||||
if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,995 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// A widget that is bigger on the inside and shrink wraps its children in the
|
||||
/// main axis.
|
||||
///
|
||||
/// [ShrinkWrappingViewport] displays a subset of its children according to its
|
||||
/// own dimensions and the given [offset]. As the offset varies, different
|
||||
/// children are visible through the viewport.
|
||||
///
|
||||
/// [ShrinkWrappingViewport] differs from [Viewport] in that [Viewport] expands
|
||||
/// to fill the main axis whereas [ShrinkWrappingViewport] sizes itself to match
|
||||
/// its children in the main axis. This shrink wrapping behavior is expensive
|
||||
/// because the children, and hence the viewport, could potentially change size
|
||||
/// whenever the [offset] changes (e.g., because of a collapsing header).
|
||||
///
|
||||
/// [ShrinkWrappingViewport] cannot contain box children directly. Instead, use
|
||||
/// a [SliverList], [SliverFixedExtentList], [SliverGrid], or a
|
||||
/// [SliverToBoxAdapter], for example.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [ListView], [PageView], [GridView], and [CustomScrollView], which combine
|
||||
/// [Scrollable] and [ShrinkWrappingViewport] into widgets that are easier to
|
||||
/// use.
|
||||
/// * [SliverToBoxAdapter], which allows a box widget to be placed inside a
|
||||
/// sliver context (the opposite of this widget).
|
||||
/// * [Viewport], a viewport that does not shrink-wrap its contents.
|
||||
class CustomShrinkWrappingViewport extends CustomViewport {
|
||||
/// Creates a widget that is bigger on the inside and shrink wraps its
|
||||
/// children in the main axis.
|
||||
///
|
||||
/// The viewport listens to the [offset], which means you do not need to
|
||||
/// rebuild this widget when the [offset] changes.
|
||||
///
|
||||
/// The [offset] argument must not be null.
|
||||
CustomShrinkWrappingViewport({
|
||||
Key? key,
|
||||
AxisDirection axisDirection = AxisDirection.down,
|
||||
AxisDirection? crossAxisDirection,
|
||||
double anchor = 0.0,
|
||||
required ViewportOffset offset,
|
||||
List<RenderSliver>? children,
|
||||
Key? center,
|
||||
double? cacheExtent,
|
||||
List<Widget> slivers = const <Widget>[],
|
||||
}) : _anchor = anchor,
|
||||
super(
|
||||
key: key,
|
||||
axisDirection: axisDirection,
|
||||
crossAxisDirection: crossAxisDirection,
|
||||
offset: offset,
|
||||
center: center,
|
||||
cacheExtent: cacheExtent,
|
||||
slivers: slivers);
|
||||
|
||||
// [Viewport] enforces constraints on [Viewport.anchor], so we need our own
|
||||
// version.
|
||||
final double _anchor;
|
||||
|
||||
@override
|
||||
double get anchor => _anchor;
|
||||
|
||||
@override
|
||||
CustomRenderShrinkWrappingViewport createRenderObject(BuildContext context) {
|
||||
return CustomRenderShrinkWrappingViewport(
|
||||
axisDirection: axisDirection,
|
||||
crossAxisDirection: crossAxisDirection ??
|
||||
Viewport.getDefaultCrossAxisDirection(context, axisDirection),
|
||||
offset: offset,
|
||||
anchor: anchor,
|
||||
cacheExtent: cacheExtent,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context, CustomRenderShrinkWrappingViewport renderObject) {
|
||||
renderObject
|
||||
..axisDirection = axisDirection
|
||||
..crossAxisDirection = crossAxisDirection ??
|
||||
Viewport.getDefaultCrossAxisDirection(context, axisDirection)
|
||||
..anchor = anchor
|
||||
..offset = offset
|
||||
..cacheExtent = cacheExtent
|
||||
..cacheExtentStyle = cacheExtentStyle
|
||||
..clipBehavior = clipBehavior;
|
||||
}
|
||||
}
|
||||
|
||||
/// A render object that is bigger on the inside and shrink wraps its children
|
||||
/// in the main axis.
|
||||
///
|
||||
/// [RenderShrinkWrappingViewport] displays a subset of its children according
|
||||
/// to its own dimensions and the given [offset]. As the offset varies, different
|
||||
/// children are visible through the viewport.
|
||||
///
|
||||
/// [RenderShrinkWrappingViewport] differs from [RenderViewport] in that
|
||||
/// [RenderViewport] expands to fill the main axis whereas
|
||||
/// [RenderShrinkWrappingViewport] sizes itself to match its children in the
|
||||
/// main axis. This shrink wrapping behavior is expensive because the children,
|
||||
/// and hence the viewport, could potentially change size whenever the [offset]
|
||||
/// changes (e.g., because of a collapsing header).
|
||||
///
|
||||
/// [RenderShrinkWrappingViewport] cannot contain [RenderBox] children directly.
|
||||
/// Instead, use a [RenderSliverList], [RenderSliverFixedExtentList],
|
||||
/// [RenderSliverGrid], or a [RenderSliverToBoxAdapter], for example.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [RenderViewport], a viewport that does not shrink-wrap its contents.
|
||||
/// * [RenderSliver], which explains more about the Sliver protocol.
|
||||
/// * [RenderBox], which explains more about the Box protocol.
|
||||
/// * [RenderSliverToBoxAdapter], which allows a [RenderBox] object to be
|
||||
/// placed inside a [RenderSliver] (the opposite of this class).
|
||||
class CustomRenderShrinkWrappingViewport extends CustomRenderViewport {
|
||||
/// Creates a viewport (for [RenderSliver] objects) that shrink-wraps its
|
||||
/// contents.
|
||||
///
|
||||
/// The [offset] must be specified. For testing purposes, consider passing a
|
||||
/// [ViewportOffset.zero] or [ViewportOffset.fixed].
|
||||
CustomRenderShrinkWrappingViewport({
|
||||
AxisDirection axisDirection = AxisDirection.down,
|
||||
required AxisDirection crossAxisDirection,
|
||||
required ViewportOffset offset,
|
||||
double anchor = 0.0,
|
||||
List<RenderSliver>? children,
|
||||
RenderSliver? center,
|
||||
double? cacheExtent,
|
||||
}) : _anchor = anchor,
|
||||
super(
|
||||
axisDirection: axisDirection,
|
||||
crossAxisDirection: crossAxisDirection,
|
||||
offset: offset,
|
||||
center: center,
|
||||
cacheExtent: cacheExtent,
|
||||
children: children,
|
||||
);
|
||||
|
||||
double _anchor;
|
||||
|
||||
@override
|
||||
double get anchor => _anchor;
|
||||
|
||||
@override
|
||||
bool get sizedByParent => false;
|
||||
|
||||
double lastMainAxisExtent = -1;
|
||||
|
||||
@override
|
||||
set anchor(double value) {
|
||||
if (value == _anchor) return;
|
||||
_anchor = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
late double _shrinkWrapExtent;
|
||||
|
||||
/// This value is set during layout based on the [CacheExtentStyle].
|
||||
///
|
||||
/// When the style is [CacheExtentStyle.viewport], it is the main axis extent
|
||||
/// of the viewport multiplied by the requested cache extent, which is still
|
||||
/// expressed in pixels.
|
||||
double? _calculatedCacheExtent;
|
||||
|
||||
/// While List in a wrapping container, eg. ListView,the mainAxisExtent will
|
||||
/// be infinite. This time need to change mainAxisExtent to this value.
|
||||
final double _maxMainAxisExtent = double.maxFinite;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
if (center == null) {
|
||||
assert(firstChild == null);
|
||||
_minScrollExtent = 0.0;
|
||||
_maxScrollExtent = 0.0;
|
||||
_hasVisualOverflow = false;
|
||||
offset.applyContentDimensions(0.0, 0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
assert(center!.parent == this);
|
||||
|
||||
final BoxConstraints constraints = this.constraints;
|
||||
if (firstChild == null) {
|
||||
switch (axis) {
|
||||
case Axis.vertical:
|
||||
assert(constraints.hasBoundedWidth);
|
||||
size = Size(constraints.maxWidth, constraints.minHeight);
|
||||
break;
|
||||
case Axis.horizontal:
|
||||
assert(constraints.hasBoundedHeight);
|
||||
size = Size(constraints.minWidth, constraints.maxHeight);
|
||||
break;
|
||||
}
|
||||
offset.applyViewportDimension(0.0);
|
||||
_maxScrollExtent = 0.0;
|
||||
_shrinkWrapExtent = 0.0;
|
||||
_hasVisualOverflow = false;
|
||||
offset.applyContentDimensions(0.0, 0.0);
|
||||
return;
|
||||
}
|
||||
|
||||
double mainAxisExtent;
|
||||
final double crossAxisExtent;
|
||||
switch (axis) {
|
||||
case Axis.vertical:
|
||||
assert(constraints.hasBoundedWidth);
|
||||
mainAxisExtent = constraints.maxHeight;
|
||||
crossAxisExtent = constraints.maxWidth;
|
||||
break;
|
||||
case Axis.horizontal:
|
||||
assert(constraints.hasBoundedHeight);
|
||||
mainAxisExtent = constraints.maxWidth;
|
||||
crossAxisExtent = constraints.maxHeight;
|
||||
break;
|
||||
}
|
||||
|
||||
if (mainAxisExtent.isInfinite) {
|
||||
mainAxisExtent = _maxMainAxisExtent;
|
||||
}
|
||||
|
||||
final centerOffsetAdjustment = center!.centerOffsetAdjustment;
|
||||
|
||||
double correction;
|
||||
double effectiveExtent;
|
||||
do {
|
||||
correction = _attemptLayout(mainAxisExtent, crossAxisExtent,
|
||||
offset.pixels + centerOffsetAdjustment);
|
||||
if (correction != 0.0) {
|
||||
offset.correctBy(correction);
|
||||
} else {
|
||||
switch (axis) {
|
||||
case Axis.vertical:
|
||||
effectiveExtent = constraints.constrainHeight(_shrinkWrapExtent);
|
||||
break;
|
||||
case Axis.horizontal:
|
||||
effectiveExtent = constraints.constrainWidth(_shrinkWrapExtent);
|
||||
break;
|
||||
}
|
||||
// *** Difference from [RenderViewport].
|
||||
final top = _minScrollExtent + mainAxisExtent * anchor;
|
||||
final bottom = _maxScrollExtent - mainAxisExtent * (1.0 - anchor);
|
||||
|
||||
final maxScrollOffset = math.max(math.min(0.0, top), bottom);
|
||||
final minScrollOffset = math.min(top, maxScrollOffset);
|
||||
|
||||
final bool didAcceptViewportDimension =
|
||||
offset.applyViewportDimension(effectiveExtent);
|
||||
final bool didAcceptContentDimension =
|
||||
offset.applyContentDimensions(minScrollOffset, maxScrollOffset);
|
||||
if (didAcceptViewportDimension && didAcceptContentDimension) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} while (true);
|
||||
switch (axis) {
|
||||
case Axis.vertical:
|
||||
size =
|
||||
constraints.constrainDimensions(crossAxisExtent, effectiveExtent);
|
||||
break;
|
||||
case Axis.horizontal:
|
||||
size =
|
||||
constraints.constrainDimensions(effectiveExtent, crossAxisExtent);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
double _attemptLayout(
|
||||
double mainAxisExtent, double crossAxisExtent, double correctedOffset) {
|
||||
assert(!mainAxisExtent.isNaN);
|
||||
assert(mainAxisExtent >= 0.0);
|
||||
assert(crossAxisExtent.isFinite);
|
||||
assert(crossAxisExtent >= 0.0);
|
||||
assert(correctedOffset.isFinite);
|
||||
_minScrollExtent = 0.0;
|
||||
_maxScrollExtent = 0.0;
|
||||
_hasVisualOverflow = false;
|
||||
_shrinkWrapExtent = 0.0;
|
||||
|
||||
// centerOffset is the offset from the leading edge of the RenderViewport
|
||||
// to the zero scroll offset (the line between the forward slivers and the
|
||||
// reverse slivers).
|
||||
final centerOffset = mainAxisExtent * anchor - correctedOffset;
|
||||
final reverseDirectionRemainingPaintExtent =
|
||||
centerOffset.clamp(0.0, mainAxisExtent);
|
||||
final forwardDirectionRemainingPaintExtent =
|
||||
(mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent);
|
||||
|
||||
switch (cacheExtentStyle) {
|
||||
case CacheExtentStyle.pixel:
|
||||
_calculatedCacheExtent = cacheExtent;
|
||||
break;
|
||||
case CacheExtentStyle.viewport:
|
||||
_calculatedCacheExtent = mainAxisExtent * cacheExtent!;
|
||||
break;
|
||||
}
|
||||
|
||||
final fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!;
|
||||
final centerCacheOffset = centerOffset + _calculatedCacheExtent!;
|
||||
final reverseDirectionRemainingCacheExtent =
|
||||
centerCacheOffset.clamp(0.0, fullCacheExtent);
|
||||
final forwardDirectionRemainingCacheExtent =
|
||||
(fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent);
|
||||
|
||||
final leadingNegativeChild = childBefore(center!);
|
||||
|
||||
if (leadingNegativeChild != null) {
|
||||
// negative scroll offsets
|
||||
final result = layoutChildSequence(
|
||||
child: leadingNegativeChild,
|
||||
scrollOffset: math.max(mainAxisExtent, centerOffset) - mainAxisExtent,
|
||||
overlap: 0.0,
|
||||
layoutOffset: forwardDirectionRemainingPaintExtent,
|
||||
remainingPaintExtent: reverseDirectionRemainingPaintExtent,
|
||||
mainAxisExtent: mainAxisExtent,
|
||||
crossAxisExtent: crossAxisExtent,
|
||||
growthDirection: GrowthDirection.reverse,
|
||||
advance: childBefore,
|
||||
remainingCacheExtent: reverseDirectionRemainingCacheExtent,
|
||||
cacheOrigin: (mainAxisExtent - centerOffset)
|
||||
.clamp(-_calculatedCacheExtent!, 0.0),
|
||||
);
|
||||
if (result != 0.0) return -result;
|
||||
}
|
||||
|
||||
// positive scroll offsets
|
||||
return layoutChildSequence(
|
||||
child: center,
|
||||
scrollOffset: math.max(0.0, -centerOffset),
|
||||
overlap:
|
||||
leadingNegativeChild == null ? math.min(0.0, -centerOffset) : 0.0,
|
||||
layoutOffset: centerOffset >= mainAxisExtent
|
||||
? centerOffset
|
||||
: reverseDirectionRemainingPaintExtent,
|
||||
remainingPaintExtent: forwardDirectionRemainingPaintExtent,
|
||||
mainAxisExtent: mainAxisExtent,
|
||||
crossAxisExtent: crossAxisExtent,
|
||||
growthDirection: GrowthDirection.forward,
|
||||
advance: childAfter,
|
||||
remainingCacheExtent: forwardDirectionRemainingCacheExtent,
|
||||
cacheOrigin: centerOffset.clamp(-_calculatedCacheExtent!, 0.0),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get hasVisualOverflow => _hasVisualOverflow;
|
||||
|
||||
@override
|
||||
void updateOutOfBandData(
|
||||
GrowthDirection growthDirection, SliverGeometry childLayoutGeometry) {
|
||||
switch (growthDirection) {
|
||||
case GrowthDirection.forward:
|
||||
_maxScrollExtent += childLayoutGeometry.scrollExtent;
|
||||
break;
|
||||
case GrowthDirection.reverse:
|
||||
_minScrollExtent -= childLayoutGeometry.scrollExtent;
|
||||
break;
|
||||
}
|
||||
if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true;
|
||||
_shrinkWrapExtent += childLayoutGeometry.maxPaintExtent;
|
||||
growSize = _shrinkWrapExtent;
|
||||
}
|
||||
|
||||
@override
|
||||
String labelForChild(int index) => 'child $index';
|
||||
}
|
||||
|
||||
/// A widget that is bigger on the inside.
|
||||
///
|
||||
/// [Viewport] is the visual workhorse of the scrolling machinery. It displays a
|
||||
/// subset of its children according to its own dimensions and the given
|
||||
/// [offset]. As the offset varies, different children are visible through
|
||||
/// the viewport.
|
||||
///
|
||||
/// [Viewport] hosts a bidirectional list of slivers, anchored on a [center]
|
||||
/// sliver, which is placed at the zero scroll offset. The center widget is
|
||||
/// displayed in the viewport according to the [anchor] property.
|
||||
///
|
||||
/// Slivers that are earlier in the child list than [center] are displayed in
|
||||
/// reverse order in the reverse [axisDirection] starting from the [center]. For
|
||||
/// example, if the [axisDirection] is [AxisDirection.down], the first sliver
|
||||
/// before [center] is placed above the [center]. The slivers that are later in
|
||||
/// the child list than [center] are placed in order in the [axisDirection]. For
|
||||
/// example, in the preceding scenario, the first sliver after [center] is
|
||||
/// placed below the [center].
|
||||
///
|
||||
/// [Viewport] cannot contain box children directly. Instead, use a
|
||||
/// [SliverList], [SliverFixedExtentList], [SliverGrid], or a
|
||||
/// [SliverToBoxAdapter], for example.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [ListView], [PageView], [GridView], and [CustomScrollView], which combine
|
||||
/// [Scrollable] and [Viewport] into widgets that are easier to use.
|
||||
/// * [SliverToBoxAdapter], which allows a box widget to be placed inside a
|
||||
/// sliver context (the opposite of this widget).
|
||||
/// * [ShrinkWrappingViewport], a variant of [Viewport] that shrink-wraps its
|
||||
/// contents along the main axis.
|
||||
abstract class CustomViewport extends MultiChildRenderObjectWidget {
|
||||
/// Creates a widget that is bigger on the inside.
|
||||
///
|
||||
/// The viewport listens to the [offset], which means you do not need to
|
||||
/// rebuild this widget when the [offset] changes.
|
||||
///
|
||||
/// The [offset] argument must not be null.
|
||||
///
|
||||
/// The [cacheExtent] must be specified if the [cacheExtentStyle] is
|
||||
/// not [CacheExtentStyle.pixel].
|
||||
CustomViewport({
|
||||
Key? key,
|
||||
this.axisDirection = AxisDirection.down,
|
||||
this.crossAxisDirection,
|
||||
this.anchor = 0.0,
|
||||
required this.offset,
|
||||
this.center,
|
||||
this.cacheExtent,
|
||||
this.cacheExtentStyle = CacheExtentStyle.pixel,
|
||||
this.clipBehavior = Clip.hardEdge,
|
||||
List<Widget> slivers = const <Widget>[],
|
||||
}) : assert(offset != null),
|
||||
assert(slivers != null),
|
||||
assert(center == null ||
|
||||
slivers.where((Widget child) => child.key == center).length == 1),
|
||||
assert(cacheExtentStyle != null),
|
||||
assert(cacheExtentStyle != CacheExtentStyle.viewport ||
|
||||
cacheExtent != null),
|
||||
assert(clipBehavior != null),
|
||||
super(key: key, children: slivers);
|
||||
|
||||
/// The direction in which the [offset]'s [ViewportOffset.pixels] increases.
|
||||
///
|
||||
/// For example, if the [axisDirection] is [AxisDirection.down], a scroll
|
||||
/// offset of zero is at the top of the viewport and increases towards the
|
||||
/// bottom of the viewport.
|
||||
final AxisDirection axisDirection;
|
||||
|
||||
/// The direction in which child should be laid out in the cross axis.
|
||||
///
|
||||
/// If the [axisDirection] is [AxisDirection.down] or [AxisDirection.up], this
|
||||
/// property defaults to [AxisDirection.left] if the ambient [Directionality]
|
||||
/// is [TextDirection.rtl] and [AxisDirection.right] if the ambient
|
||||
/// [Directionality] is [TextDirection.ltr].
|
||||
///
|
||||
/// If the [axisDirection] is [AxisDirection.left] or [AxisDirection.right],
|
||||
/// this property defaults to [AxisDirection.down].
|
||||
final AxisDirection? crossAxisDirection;
|
||||
|
||||
/// The relative position of the zero scroll offset.
|
||||
///
|
||||
/// For example, if [anchor] is 0.5 and the [axisDirection] is
|
||||
/// [AxisDirection.down] or [AxisDirection.up], then the zero scroll offset is
|
||||
/// vertically centered within the viewport. If the [anchor] is 1.0, and the
|
||||
/// [axisDirection] is [AxisDirection.right], then the zero scroll offset is
|
||||
/// on the left edge of the viewport.
|
||||
final double anchor;
|
||||
|
||||
/// Which part of the content inside the viewport should be visible.
|
||||
///
|
||||
/// The [ViewportOffset.pixels] value determines the scroll offset that the
|
||||
/// viewport uses to select which part of its content to display. As the user
|
||||
/// scrolls the viewport, this value changes, which changes the content that
|
||||
/// is displayed.
|
||||
///
|
||||
/// Typically a [ScrollPosition].
|
||||
final ViewportOffset offset;
|
||||
|
||||
/// The first child in the [GrowthDirection.forward] growth direction.
|
||||
///
|
||||
/// Children after [center] will be placed in the [axisDirection] relative to
|
||||
/// the [center]. Children before [center] will be placed in the opposite of
|
||||
/// the [axisDirection] relative to the [center].
|
||||
///
|
||||
/// The [center] must be the key of a child of the viewport.
|
||||
final Key? center;
|
||||
|
||||
/// {@macro flutter.rendering.RenderViewportBase.cacheExtent}
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [cacheExtentStyle], which controls the units of the [cacheExtent].
|
||||
final double? cacheExtent;
|
||||
|
||||
/// {@macro flutter.rendering.RenderViewportBase.cacheExtentStyle}
|
||||
final CacheExtentStyle cacheExtentStyle;
|
||||
|
||||
/// {@macro flutter.material.Material.clipBehavior}
|
||||
///
|
||||
/// Defaults to [Clip.hardEdge].
|
||||
final Clip clipBehavior;
|
||||
|
||||
/// Given a [BuildContext] and an [AxisDirection], determine the correct cross
|
||||
/// axis direction.
|
||||
///
|
||||
/// This depends on the [Directionality] if the `axisDirection` is vertical;
|
||||
/// otherwise, the default cross axis direction is downwards.
|
||||
static AxisDirection getDefaultCrossAxisDirection(
|
||||
BuildContext context, AxisDirection axisDirection) {
|
||||
assert(axisDirection != null);
|
||||
switch (axisDirection) {
|
||||
case AxisDirection.up:
|
||||
assert(debugCheckHasDirectionality(
|
||||
context,
|
||||
why:
|
||||
'to determine the cross-axis direction when the viewport has an \'up\' axisDirection',
|
||||
alternative:
|
||||
'Alternatively, consider specifying the \'crossAxisDirection\' argument on the Viewport.',
|
||||
));
|
||||
return textDirectionToAxisDirection(Directionality.of(context));
|
||||
case AxisDirection.right:
|
||||
return AxisDirection.down;
|
||||
case AxisDirection.down:
|
||||
assert(debugCheckHasDirectionality(
|
||||
context,
|
||||
why:
|
||||
'to determine the cross-axis direction when the viewport has a \'down\' axisDirection',
|
||||
alternative:
|
||||
'Alternatively, consider specifying the \'crossAxisDirection\' argument on the Viewport.',
|
||||
));
|
||||
return textDirectionToAxisDirection(Directionality.of(context));
|
||||
case AxisDirection.left:
|
||||
return AxisDirection.down;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
CustomRenderViewport createRenderObject(BuildContext context);
|
||||
|
||||
@override
|
||||
_ViewportElement createElement() => _ViewportElement(this);
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection));
|
||||
properties.add(EnumProperty<AxisDirection>(
|
||||
'crossAxisDirection', crossAxisDirection,
|
||||
defaultValue: null));
|
||||
properties.add(DoubleProperty('anchor', anchor));
|
||||
properties.add(DiagnosticsProperty<ViewportOffset>('offset', offset));
|
||||
if (center != null) {
|
||||
properties.add(DiagnosticsProperty<Key>('center', center));
|
||||
} else if (children.isNotEmpty && children.first.key != null) {
|
||||
properties.add(DiagnosticsProperty<Key>('center', children.first.key,
|
||||
tooltip: 'implicit'));
|
||||
}
|
||||
properties.add(DiagnosticsProperty<double>('cacheExtent', cacheExtent));
|
||||
properties.add(DiagnosticsProperty<CacheExtentStyle>(
|
||||
'cacheExtentStyle', cacheExtentStyle));
|
||||
}
|
||||
}
|
||||
|
||||
class _ViewportElement extends MultiChildRenderObjectElement {
|
||||
/// Creates an element that uses the given widget as its configuration.
|
||||
_ViewportElement(CustomViewport widget) : super(widget);
|
||||
|
||||
@override
|
||||
CustomViewport get widget => super.widget as CustomViewport;
|
||||
|
||||
@override
|
||||
CustomRenderViewport get renderObject =>
|
||||
super.renderObject as CustomRenderViewport;
|
||||
|
||||
@override
|
||||
void mount(Element? parent, dynamic newSlot) {
|
||||
super.mount(parent, newSlot);
|
||||
_updateCenter();
|
||||
}
|
||||
|
||||
@override
|
||||
void update(MultiChildRenderObjectWidget newWidget) {
|
||||
super.update(newWidget);
|
||||
_updateCenter();
|
||||
}
|
||||
|
||||
void _updateCenter() {
|
||||
if (widget.center != null) {
|
||||
renderObject.center = children
|
||||
.singleWhere((Element element) => element.widget.key == widget.center)
|
||||
.renderObject as RenderSliver?;
|
||||
} else if (children.isNotEmpty) {
|
||||
renderObject.center = children.first.renderObject as RenderSliver?;
|
||||
} else {
|
||||
renderObject.center = null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void debugVisitOnstageChildren(ElementVisitor visitor) {
|
||||
children.where((Element e) {
|
||||
final RenderSliver renderSliver = e.renderObject! as RenderSliver;
|
||||
return renderSliver.geometry!.visible;
|
||||
}).forEach(visitor);
|
||||
}
|
||||
}
|
||||
|
||||
class CustomSliverPhysicalContainerParentData
|
||||
extends SliverPhysicalContainerParentData {
|
||||
/// The position of the child relative to the zero scroll offset.
|
||||
///
|
||||
/// The number of pixels from from the zero scroll offset of the parent sliver
|
||||
/// (the line at which its [SliverConstraints.scrollOffset] is zero) to the
|
||||
/// side of the child closest to that offset. A [layoutOffset] can be null
|
||||
/// when it cannot be determined. The value will be set after layout.
|
||||
///
|
||||
/// In a typical list, this does not change as the parent is scrolled.
|
||||
///
|
||||
/// Defaults to null.
|
||||
double? layoutOffset;
|
||||
|
||||
GrowthDirection? growthDirection;
|
||||
}
|
||||
|
||||
/// A render object that is bigger on the inside.
|
||||
///
|
||||
/// [RenderViewport] is the visual workhorse of the scrolling machinery. It
|
||||
/// displays a subset of its children according to its own dimensions and the
|
||||
/// given [offset]. As the offset varies, different children are visible through
|
||||
/// the viewport.
|
||||
///
|
||||
/// [RenderViewport] hosts a bidirectional list of slivers, anchored on a
|
||||
/// [center] sliver, which is placed at the zero scroll offset. The center
|
||||
/// widget is displayed in the viewport according to the [anchor] property.
|
||||
///
|
||||
/// Slivers that are earlier in the child list than [center] are displayed in
|
||||
/// reverse order in the reverse [axisDirection] starting from the [center]. For
|
||||
/// example, if the [axisDirection] is [AxisDirection.down], the first sliver
|
||||
/// before [center] is placed above the [center]. The slivers that are later in
|
||||
/// the child list than [center] are placed in order in the [axisDirection]. For
|
||||
/// example, in the preceding scenario, the first sliver after [center] is
|
||||
/// placed below the [center].
|
||||
///
|
||||
/// [RenderViewport] cannot contain [RenderBox] children directly. Instead, use
|
||||
/// a [RenderSliverList], [RenderSliverFixedExtentList], [RenderSliverGrid], or
|
||||
/// a [RenderSliverToBoxAdapter], for example.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [RenderSliver], which explains more about the Sliver protocol.
|
||||
/// * [RenderBox], which explains more about the Box protocol.
|
||||
/// * [RenderSliverToBoxAdapter], which allows a [RenderBox] object to be
|
||||
/// placed inside a [RenderSliver] (the opposite of this class).
|
||||
/// * [RenderShrinkWrappingViewport], a variant of [RenderViewport] that
|
||||
/// shrink-wraps its contents along the main axis.
|
||||
abstract class CustomRenderViewport
|
||||
extends RenderViewportBase<CustomSliverPhysicalContainerParentData> {
|
||||
/// Creates a viewport for [RenderSliver] objects.
|
||||
///
|
||||
/// If the [center] is not specified, then the first child in the `children`
|
||||
/// list, if any, is used.
|
||||
///
|
||||
/// The [offset] must be specified. For testing purposes, consider passing a
|
||||
/// [ViewportOffset.zero] or [ViewportOffset.fixed].
|
||||
CustomRenderViewport({
|
||||
AxisDirection axisDirection = AxisDirection.down,
|
||||
required AxisDirection crossAxisDirection,
|
||||
required ViewportOffset offset,
|
||||
double anchor = 0.0,
|
||||
List<RenderSliver>? children,
|
||||
RenderSliver? center,
|
||||
double? cacheExtent,
|
||||
CacheExtentStyle cacheExtentStyle = CacheExtentStyle.pixel,
|
||||
Clip clipBehavior = Clip.hardEdge,
|
||||
}) : assert(anchor != null),
|
||||
assert(anchor >= 0.0 && anchor <= 1.0),
|
||||
assert(cacheExtentStyle != CacheExtentStyle.viewport ||
|
||||
cacheExtent != null),
|
||||
assert(clipBehavior != null),
|
||||
_center = center,
|
||||
super(
|
||||
axisDirection: axisDirection,
|
||||
crossAxisDirection: crossAxisDirection,
|
||||
offset: offset,
|
||||
cacheExtent: cacheExtent,
|
||||
cacheExtentStyle: cacheExtentStyle,
|
||||
clipBehavior: clipBehavior,
|
||||
) {
|
||||
addAll(children);
|
||||
if (center == null && firstChild != null) _center = firstChild;
|
||||
}
|
||||
|
||||
/// If a [RenderAbstractViewport] overrides
|
||||
/// [RenderObject.describeSemanticsConfiguration] to add the [SemanticsTag]
|
||||
/// [useTwoPaneSemantics] to its [SemanticsConfiguration], two semantics nodes
|
||||
/// will be used to represent the viewport with its associated scrolling
|
||||
/// actions in the semantics tree.
|
||||
///
|
||||
/// Two semantics nodes (an inner and an outer node) are necessary to exclude
|
||||
/// certain child nodes (via the [excludeFromScrolling] tag) from the
|
||||
/// scrollable area for semantic purposes: The [SemanticsNode]s of children
|
||||
/// that should be excluded from scrolling will be attached to the outer node.
|
||||
/// The semantic scrolling actions and the [SemanticsNode]s of scrollable
|
||||
/// children will be attached to the inner node, which itself is a child of
|
||||
/// the outer node.
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [RenderViewportBase.describeSemanticsConfiguration], which adds this
|
||||
/// tag to its [SemanticsConfiguration].
|
||||
static const SemanticsTag useTwoPaneSemantics =
|
||||
SemanticsTag('RenderViewport.twoPane');
|
||||
|
||||
/// When a top-level [SemanticsNode] below a [RenderAbstractViewport] is
|
||||
/// tagged with [excludeFromScrolling] it will not be part of the scrolling
|
||||
/// area for semantic purposes.
|
||||
///
|
||||
/// This behavior is only active if the [RenderAbstractViewport]
|
||||
/// tagged its [SemanticsConfiguration] with [useTwoPaneSemantics].
|
||||
/// Otherwise, the [excludeFromScrolling] tag is ignored.
|
||||
///
|
||||
/// As an example, a [RenderSliver] that stays on the screen within a
|
||||
/// [Scrollable] even though the user has scrolled past it (e.g. a pinned app
|
||||
/// bar) can tag its [SemanticsNode] with [excludeFromScrolling] to indicate
|
||||
/// that it should no longer be considered for semantic actions related to
|
||||
/// scrolling.
|
||||
static const SemanticsTag excludeFromScrolling =
|
||||
SemanticsTag('RenderViewport.excludeFromScrolling');
|
||||
|
||||
@override
|
||||
void setupParentData(RenderObject child) {
|
||||
if (child.parentData is! CustomSliverPhysicalContainerParentData)
|
||||
child.parentData = CustomSliverPhysicalContainerParentData();
|
||||
}
|
||||
|
||||
/// The relative position of the zero scroll offset.
|
||||
///
|
||||
/// For example, if [anchor] is 0.5 and the [axisDirection] is
|
||||
/// [AxisDirection.down] or [AxisDirection.up], then the zero scroll offset is
|
||||
/// vertically centered within the viewport. If the [anchor] is 1.0, and the
|
||||
/// [axisDirection] is [AxisDirection.right], then the zero scroll offset is
|
||||
/// on the left edge of the viewport.
|
||||
double get anchor;
|
||||
|
||||
set anchor(double value);
|
||||
|
||||
/// The first child in the [GrowthDirection.forward] growth direction.
|
||||
///
|
||||
/// This child that will be at the position defined by [anchor] when the
|
||||
/// [ViewportOffset.pixels] of [offset] is `0`.
|
||||
///
|
||||
/// Children after [center] will be placed in the [axisDirection] relative to
|
||||
/// the [center]. Children before [center] will be placed in the opposite of
|
||||
/// the [axisDirection] relative to the [center].
|
||||
///
|
||||
/// The [center] must be a child of the viewport.
|
||||
RenderSliver? get center => _center;
|
||||
RenderSliver? _center;
|
||||
|
||||
set center(RenderSliver? value) {
|
||||
if (value == _center) return;
|
||||
_center = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get sizedByParent => true;
|
||||
|
||||
@override
|
||||
Size computeDryLayout(BoxConstraints constraints) {
|
||||
assert(() {
|
||||
if (!constraints.hasBoundedHeight || !constraints.hasBoundedWidth) {
|
||||
switch (axis) {
|
||||
case Axis.vertical:
|
||||
if (!constraints.hasBoundedHeight) {
|
||||
throw FlutterError.fromParts(<DiagnosticsNode>[
|
||||
ErrorSummary('Vertical viewport was given unbounded height.'),
|
||||
ErrorDescription(
|
||||
'Viewports expand in the scrolling direction to fill their container. '
|
||||
'In this case, a vertical viewport was given an unlimited amount of '
|
||||
'vertical space in which to expand. This situation typically happens '
|
||||
'when a scrollable widget is nested inside another scrollable widget.'),
|
||||
ErrorHint(
|
||||
'If this widget is always nested in a scrollable widget there '
|
||||
'is no need to use a viewport because there will always be enough '
|
||||
'vertical space for the children. In this case, consider using a '
|
||||
'Column instead. Otherwise, consider using the "shrinkWrap" property '
|
||||
'(or a ShrinkWrappingViewport) to size the height of the viewport '
|
||||
'to the sum of the heights of its children.')
|
||||
]);
|
||||
}
|
||||
if (!constraints.hasBoundedWidth) {
|
||||
throw FlutterError(
|
||||
'Vertical viewport was given unbounded width.\n'
|
||||
'Viewports expand in the cross axis to fill their container and '
|
||||
'constrain their children to match their extent in the cross axis. '
|
||||
'In this case, a vertical viewport was given an unlimited amount of '
|
||||
'horizontal space in which to expand.');
|
||||
}
|
||||
break;
|
||||
case Axis.horizontal:
|
||||
if (!constraints.hasBoundedWidth) {
|
||||
throw FlutterError.fromParts(<DiagnosticsNode>[
|
||||
ErrorSummary('Horizontal viewport was given unbounded width.'),
|
||||
ErrorDescription(
|
||||
'Viewports expand in the scrolling direction to fill their container. '
|
||||
'In this case, a horizontal viewport was given an unlimited amount of '
|
||||
'horizontal space in which to expand. This situation typically happens '
|
||||
'when a scrollable widget is nested inside another scrollable widget.'),
|
||||
ErrorHint(
|
||||
'If this widget is always nested in a scrollable widget there '
|
||||
'is no need to use a viewport because there will always be enough '
|
||||
'horizontal space for the children. In this case, consider using a '
|
||||
'Row instead. Otherwise, consider using the "shrinkWrap" property '
|
||||
'(or a ShrinkWrappingViewport) to size the width of the viewport '
|
||||
'to the sum of the widths of its children.')
|
||||
]);
|
||||
}
|
||||
if (!constraints.hasBoundedHeight) {
|
||||
throw FlutterError(
|
||||
'Horizontal viewport was given unbounded height.\n'
|
||||
'Viewports expand in the cross axis to fill their container and '
|
||||
'constrain their children to match their extent in the cross axis. '
|
||||
'In this case, a horizontal viewport was given an unlimited amount of '
|
||||
'vertical space in which to expand.');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
return constraints.biggest;
|
||||
}
|
||||
|
||||
// Out-of-band data computed during layout.
|
||||
late double _minScrollExtent;
|
||||
late double _maxScrollExtent;
|
||||
bool _hasVisualOverflow = false;
|
||||
|
||||
double growSize = 0;
|
||||
|
||||
@override
|
||||
bool get hasVisualOverflow => _hasVisualOverflow;
|
||||
|
||||
@override
|
||||
void updateOutOfBandData(
|
||||
GrowthDirection growthDirection, SliverGeometry childLayoutGeometry) {
|
||||
switch (growthDirection) {
|
||||
case GrowthDirection.forward:
|
||||
_maxScrollExtent += childLayoutGeometry.scrollExtent;
|
||||
break;
|
||||
case GrowthDirection.reverse:
|
||||
_minScrollExtent -= childLayoutGeometry.scrollExtent;
|
||||
break;
|
||||
}
|
||||
if (childLayoutGeometry.hasVisualOverflow) _hasVisualOverflow = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void updateChildLayoutOffset(RenderSliver child, double layoutOffset,
|
||||
GrowthDirection growthDirection) {
|
||||
final CustomSliverPhysicalContainerParentData childParentData =
|
||||
child.parentData! as CustomSliverPhysicalContainerParentData;
|
||||
childParentData.layoutOffset = layoutOffset;
|
||||
childParentData.growthDirection = growthDirection;
|
||||
}
|
||||
|
||||
@override
|
||||
Offset paintOffsetOf(RenderSliver child) {
|
||||
final CustomSliverPhysicalContainerParentData childParentData =
|
||||
child.parentData! as CustomSliverPhysicalContainerParentData;
|
||||
return computeAbsolutePaintOffset(
|
||||
child, childParentData.layoutOffset!, childParentData.growthDirection!);
|
||||
}
|
||||
|
||||
@override
|
||||
double scrollOffsetOf(RenderSliver child, double scrollOffsetWithinChild) {
|
||||
assert(child.parent == this);
|
||||
final GrowthDirection growthDirection = child.constraints.growthDirection;
|
||||
switch (growthDirection) {
|
||||
case GrowthDirection.forward:
|
||||
double scrollOffsetToChild = 0.0;
|
||||
RenderSliver? current = center;
|
||||
while (current != child) {
|
||||
scrollOffsetToChild += current!.geometry!.scrollExtent;
|
||||
current = childAfter(current);
|
||||
}
|
||||
return scrollOffsetToChild + scrollOffsetWithinChild;
|
||||
case GrowthDirection.reverse:
|
||||
double scrollOffsetToChild = 0.0;
|
||||
RenderSliver? current = childBefore(center!);
|
||||
while (current != child) {
|
||||
scrollOffsetToChild -= current!.geometry!.scrollExtent;
|
||||
current = childBefore(current);
|
||||
}
|
||||
return scrollOffsetToChild - scrollOffsetWithinChild;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
double maxScrollObstructionExtentBefore(RenderSliver child) {
|
||||
assert(child.parent == this);
|
||||
final GrowthDirection growthDirection = child.constraints.growthDirection;
|
||||
switch (growthDirection) {
|
||||
case GrowthDirection.forward:
|
||||
double pinnedExtent = 0.0;
|
||||
RenderSliver? current = center;
|
||||
while (current != child) {
|
||||
pinnedExtent += current!.geometry!.maxScrollObstructionExtent;
|
||||
current = childAfter(current);
|
||||
}
|
||||
return pinnedExtent;
|
||||
case GrowthDirection.reverse:
|
||||
double pinnedExtent = 0.0;
|
||||
RenderSliver? current = childBefore(center!);
|
||||
while (current != child) {
|
||||
pinnedExtent += current!.geometry!.maxScrollObstructionExtent;
|
||||
current = childBefore(current);
|
||||
}
|
||||
return pinnedExtent;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void applyPaintTransform(RenderObject child, Matrix4 transform) {
|
||||
final Offset offset = paintOffsetOf(child as RenderSliver);
|
||||
transform.translate(offset.dx, offset.dy);
|
||||
}
|
||||
|
||||
@override
|
||||
double computeChildMainAxisPosition(
|
||||
RenderSliver child, double parentMainAxisPosition) {
|
||||
final CustomSliverPhysicalContainerParentData childParentData =
|
||||
child.parentData! as CustomSliverPhysicalContainerParentData;
|
||||
switch (applyGrowthDirectionToAxisDirection(
|
||||
child.constraints.axisDirection, child.constraints.growthDirection)) {
|
||||
case AxisDirection.down:
|
||||
case AxisDirection.right:
|
||||
return parentMainAxisPosition - childParentData.layoutOffset!;
|
||||
case AxisDirection.up:
|
||||
return (size.height - parentMainAxisPosition) -
|
||||
childParentData.layoutOffset!;
|
||||
case AxisDirection.left:
|
||||
return (size.width - parentMainAxisPosition) -
|
||||
childParentData.layoutOffset!;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
int get indexOfFirstChild {
|
||||
assert(center != null);
|
||||
assert(center!.parent == this);
|
||||
assert(firstChild != null);
|
||||
int count = 0;
|
||||
RenderSliver? child = center;
|
||||
while (child != firstChild) {
|
||||
count -= 1;
|
||||
child = childBefore(child!);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@override
|
||||
String labelForChild(int index) {
|
||||
if (index == 0) return 'center child';
|
||||
return 'child $index';
|
||||
}
|
||||
|
||||
@override
|
||||
Iterable<RenderSliver> get childrenInPaintOrder sync* {
|
||||
if (firstChild == null) return;
|
||||
RenderSliver? child = firstChild;
|
||||
while (child != center) {
|
||||
yield child!;
|
||||
child = childAfter(child);
|
||||
}
|
||||
child = lastChild;
|
||||
while (true) {
|
||||
yield child!;
|
||||
if (child == center) return;
|
||||
child = childBefore(child);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Iterable<RenderSliver> get childrenInHitTestOrder sync* {
|
||||
if (firstChild == null) return;
|
||||
RenderSliver? child = center;
|
||||
while (child != null) {
|
||||
yield child;
|
||||
child = childAfter(child);
|
||||
}
|
||||
child = childBefore(center!);
|
||||
while (child != null) {
|
||||
yield child;
|
||||
child = childBefore(child);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(DoubleProperty('anchor', anchor));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 吸底弹窗顶部的拖动条(36×5 胶囊),全站 bottomSheet 共用。
|
||||
class SheetHandleBar extends StatelessWidget {
|
||||
/// 深色弹窗默认白色 20% 透明;浅底或设计另配了色的弹窗自己传
|
||||
final Color color;
|
||||
const SheetHandleBar({super.key, this.color = const Color(0x33FFFFFF)});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 36,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(100)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ShrinkWrap extends MultiChildRenderObjectWidget {
|
||||
ShrinkWrap({
|
||||
super.key,
|
||||
this.direction = Axis.horizontal,
|
||||
this.alignment = WrapAlignment.start,
|
||||
this.spacing = 0.0,
|
||||
this.runAlignment = WrapAlignment.start,
|
||||
this.runSpacing = 0.0,
|
||||
this.crossAxisAlignment = WrapCrossAlignment.start,
|
||||
this.textDirection,
|
||||
this.verticalDirection = VerticalDirection.down,
|
||||
this.clipBehavior = Clip.none,
|
||||
this.maxLines = 0,
|
||||
super.children,
|
||||
}) : assert(maxLines >= 0, 'maxLines must be >= 0');
|
||||
|
||||
final Axis direction;
|
||||
final WrapAlignment alignment;
|
||||
final double spacing;
|
||||
final WrapAlignment runAlignment;
|
||||
final double runSpacing;
|
||||
final WrapCrossAlignment crossAxisAlignment;
|
||||
final TextDirection? textDirection;
|
||||
final VerticalDirection verticalDirection;
|
||||
final Clip clipBehavior;
|
||||
|
||||
/// maximum rows when expand; when it is 0, the maximum rows is not limited;
|
||||
final int maxLines;
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
RenderShrinkWrap renderShrinkWrap = RenderShrinkWrap(
|
||||
direction: direction,
|
||||
alignment: alignment,
|
||||
spacing: spacing,
|
||||
runAlignment: runAlignment,
|
||||
runSpacing: runSpacing,
|
||||
crossAxisAlignment: crossAxisAlignment,
|
||||
textDirection: textDirection ?? Directionality.maybeOf(context),
|
||||
verticalDirection: verticalDirection,
|
||||
clipBehavior: clipBehavior,
|
||||
maxLines: maxLines,
|
||||
);
|
||||
return renderShrinkWrap;
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(BuildContext context, RenderShrinkWrap renderObject) {
|
||||
renderObject
|
||||
..alignment = alignment
|
||||
..spacing = spacing
|
||||
..runAlignment = runAlignment
|
||||
..runSpacing = runSpacing
|
||||
..crossAxisAlignment = crossAxisAlignment
|
||||
..textDirection = textDirection ?? Directionality.maybeOf(context)
|
||||
..verticalDirection = verticalDirection
|
||||
..clipBehavior = clipBehavior
|
||||
..maxLines = maxLines;
|
||||
}
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(EnumProperty<Axis>('direction', direction));
|
||||
properties.add(EnumProperty<WrapAlignment>('alignment', alignment));
|
||||
properties.add(DoubleProperty('spacing', spacing));
|
||||
properties.add(EnumProperty<WrapAlignment>('runAlignment', runAlignment));
|
||||
properties.add(DoubleProperty('runSpacing', runSpacing));
|
||||
properties.add(EnumProperty<WrapCrossAlignment>('crossAxisAlignment', crossAxisAlignment));
|
||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||
properties.add(EnumProperty<VerticalDirection>('verticalDirection', verticalDirection, defaultValue: VerticalDirection.down));
|
||||
properties.add(IntProperty('maxLines', maxLines, defaultValue: 0));
|
||||
}
|
||||
}
|
||||
|
||||
class _RunMetrics {
|
||||
_RunMetrics(this.mainAxisExtent, this.crossAxisExtent, this.childCount);
|
||||
|
||||
final double mainAxisExtent;
|
||||
final double crossAxisExtent;
|
||||
final int childCount;
|
||||
}
|
||||
|
||||
/// Parent data for use with [RenderWrap].
|
||||
class ShrinkWrapParentData extends ContainerBoxParentData<RenderBox> {
|
||||
int _runIndex = 0;
|
||||
}
|
||||
|
||||
/// Displays its children in multiple horizontal or vertical runs.
|
||||
///
|
||||
/// A [RenderWrap] lays out each child and attempts to place the child adjacent
|
||||
/// to the previous child in the main axis, given by [direction], leaving
|
||||
/// [spacing] space in between. If there is not enough space to fit the child,
|
||||
/// [RenderWrap] creates a new _run_ adjacent to the existing children in the
|
||||
/// cross axis.
|
||||
///
|
||||
/// After all the children have been allocated to runs, the children within the
|
||||
/// runs are positioned according to the [alignment] in the main axis and
|
||||
/// according to the [crossAxisAlignment] in the cross axis.
|
||||
///
|
||||
/// The runs themselves are then positioned in the cross axis according to the
|
||||
/// [runSpacing] and [runAlignment].
|
||||
class RenderShrinkWrap extends RenderBox
|
||||
with ContainerRenderObjectMixin<RenderBox, ShrinkWrapParentData>, RenderBoxContainerDefaultsMixin<RenderBox, ShrinkWrapParentData> {
|
||||
/// Creates a wrap render object.
|
||||
///
|
||||
/// By default, the wrap layout is horizontal and both the children and the
|
||||
/// runs are aligned to the start.
|
||||
RenderShrinkWrap({
|
||||
List<RenderBox>? children,
|
||||
Axis direction = Axis.horizontal,
|
||||
WrapAlignment alignment = WrapAlignment.start,
|
||||
double spacing = 0.0,
|
||||
WrapAlignment runAlignment = WrapAlignment.start,
|
||||
double runSpacing = 0.0,
|
||||
WrapCrossAlignment crossAxisAlignment = WrapCrossAlignment.start,
|
||||
TextDirection? textDirection,
|
||||
VerticalDirection verticalDirection = VerticalDirection.down,
|
||||
Clip clipBehavior = Clip.none,
|
||||
int maxLines = 0,
|
||||
}) : _direction = direction,
|
||||
_alignment = alignment,
|
||||
_spacing = spacing,
|
||||
_runAlignment = runAlignment,
|
||||
_runSpacing = runSpacing,
|
||||
_crossAxisAlignment = crossAxisAlignment,
|
||||
_textDirection = textDirection,
|
||||
_verticalDirection = verticalDirection,
|
||||
_clipBehavior = clipBehavior,
|
||||
_maxLines = maxLines {
|
||||
addAll(children);
|
||||
}
|
||||
|
||||
/// The direction to use as the main axis.
|
||||
///
|
||||
/// For example, if [direction] is [Axis.horizontal], the default, the
|
||||
/// children are placed adjacent to one another in a horizontal run until the
|
||||
/// available horizontal space is consumed, at which point a subsequent
|
||||
/// children are placed in a new run vertically adjacent to the previous run.
|
||||
Axis get direction => _direction;
|
||||
Axis _direction;
|
||||
|
||||
set direction(Axis value) {
|
||||
if (_direction == value) return;
|
||||
_direction = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
/// How the children within a run should be placed in the main axis.
|
||||
///
|
||||
/// For example, if [alignment] is [WrapAlignment.center], the children in
|
||||
/// each run are grouped together in the center of their run in the main axis.
|
||||
///
|
||||
/// Defaults to [WrapAlignment.start].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [runAlignment], which controls how the runs are placed relative to each
|
||||
/// other in the cross axis.
|
||||
/// * [crossAxisAlignment], which controls how the children within each run
|
||||
/// are placed relative to each other in the cross axis.
|
||||
WrapAlignment get alignment => _alignment;
|
||||
WrapAlignment _alignment;
|
||||
|
||||
set alignment(WrapAlignment value) {
|
||||
if (_alignment == value) return;
|
||||
_alignment = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
/// How much space to place between children in a run in the main axis.
|
||||
///
|
||||
/// For example, if [spacing] is 10.0, the children will be spaced at least
|
||||
/// 10.0 logical pixels apart in the main axis.
|
||||
///
|
||||
/// If there is additional free space in a run (e.g., because the wrap has a
|
||||
/// minimum size that is not filled or because some runs are longer than
|
||||
/// others), the additional free space will be allocated according to the
|
||||
/// [alignment].
|
||||
///
|
||||
/// Defaults to 0.0.
|
||||
double get spacing => _spacing;
|
||||
double _spacing;
|
||||
|
||||
set spacing(double value) {
|
||||
if (_spacing == value) return;
|
||||
_spacing = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
/// How the runs themselves should be placed in the cross axis.
|
||||
///
|
||||
/// For example, if [runAlignment] is [WrapAlignment.center], the runs are
|
||||
/// grouped together in the center of the overall [RenderWrap] in the cross
|
||||
/// axis.
|
||||
///
|
||||
/// Defaults to [WrapAlignment.start].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [alignment], which controls how the children within each run are placed
|
||||
/// relative to each other in the main axis.
|
||||
/// * [crossAxisAlignment], which controls how the children within each run
|
||||
/// are placed relative to each other in the cross axis.
|
||||
WrapAlignment get runAlignment => _runAlignment;
|
||||
WrapAlignment _runAlignment;
|
||||
|
||||
set runAlignment(WrapAlignment value) {
|
||||
if (_runAlignment == value) return;
|
||||
_runAlignment = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
/// How much space to place between the runs themselves in the cross axis.
|
||||
///
|
||||
/// For example, if [runSpacing] is 10.0, the runs will be spaced at least
|
||||
/// 10.0 logical pixels apart in the cross axis.
|
||||
///
|
||||
/// If there is additional free space in the overall [RenderWrap] (e.g.,
|
||||
/// because the wrap has a minimum size that is not filled), the additional
|
||||
/// free space will be allocated according to the [runAlignment].
|
||||
///
|
||||
/// Defaults to 0.0.
|
||||
double get runSpacing => _runSpacing;
|
||||
double _runSpacing;
|
||||
|
||||
set runSpacing(double value) {
|
||||
if (_runSpacing == value) return;
|
||||
_runSpacing = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
/// How the children within a run should be aligned relative to each other in
|
||||
/// the cross axis.
|
||||
///
|
||||
/// For example, if this is set to [WrapCrossAlignment.end], and the
|
||||
/// [direction] is [Axis.horizontal], then the children within each
|
||||
/// run will have their bottom edges aligned to the bottom edge of the run.
|
||||
///
|
||||
/// Defaults to [WrapCrossAlignment.start].
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [alignment], which controls how the children within each run are placed
|
||||
/// relative to each other in the main axis.
|
||||
/// * [runAlignment], which controls how the runs are placed relative to each
|
||||
/// other in the cross axis.
|
||||
WrapCrossAlignment get crossAxisAlignment => _crossAxisAlignment;
|
||||
WrapCrossAlignment _crossAxisAlignment;
|
||||
|
||||
set crossAxisAlignment(WrapCrossAlignment value) {
|
||||
if (_crossAxisAlignment == value) return;
|
||||
_crossAxisAlignment = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
/// Determines the order to lay children out horizontally and how to interpret
|
||||
/// `start` and `end` in the horizontal direction.
|
||||
///
|
||||
/// If the [direction] is [Axis.horizontal], this controls the order in which
|
||||
/// children are positioned (left-to-right or right-to-left), and the meaning
|
||||
/// of the [alignment] property's [WrapAlignment.start] and
|
||||
/// [WrapAlignment.end] values.
|
||||
///
|
||||
/// If the [direction] is [Axis.horizontal], and either the
|
||||
/// [alignment] is either [WrapAlignment.start] or [WrapAlignment.end], or
|
||||
/// there's more than one child, then the [textDirection] must not be null.
|
||||
///
|
||||
/// If the [direction] is [Axis.vertical], this controls the order in
|
||||
/// which runs are positioned, the meaning of the [runAlignment] property's
|
||||
/// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the
|
||||
/// [crossAxisAlignment] property's [WrapCrossAlignment.start] and
|
||||
/// [WrapCrossAlignment.end] values.
|
||||
///
|
||||
/// If the [direction] is [Axis.vertical], and either the
|
||||
/// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the
|
||||
/// [crossAxisAlignment] is either [WrapCrossAlignment.start] or
|
||||
/// [WrapCrossAlignment.end], or there's more than one child, then the
|
||||
/// [textDirection] must not be null.
|
||||
TextDirection? get textDirection => _textDirection;
|
||||
TextDirection? _textDirection;
|
||||
|
||||
set textDirection(TextDirection? value) {
|
||||
if (_textDirection == value) return;
|
||||
_textDirection = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
/// Determines the order to lay children out vertically and how to interpret
|
||||
/// `start` and `end` in the vertical direction.
|
||||
///
|
||||
/// If the [direction] is [Axis.vertical], this controls which order children
|
||||
/// are painted in (down or up), the meaning of the [alignment] property's
|
||||
/// [WrapAlignment.start] and [WrapAlignment.end] values.
|
||||
///
|
||||
/// If the [direction] is [Axis.vertical], and either the [alignment]
|
||||
/// is either [WrapAlignment.start] or [WrapAlignment.end], or there's
|
||||
/// more than one child, then the [verticalDirection] must not be null.
|
||||
///
|
||||
/// If the [direction] is [Axis.horizontal], this controls the order in which
|
||||
/// runs are positioned, the meaning of the [runAlignment] property's
|
||||
/// [WrapAlignment.start] and [WrapAlignment.end] values, as well as the
|
||||
/// [crossAxisAlignment] property's [WrapCrossAlignment.start] and
|
||||
/// [WrapCrossAlignment.end] values.
|
||||
///
|
||||
/// If the [direction] is [Axis.horizontal], and either the
|
||||
/// [runAlignment] is either [WrapAlignment.start] or [WrapAlignment.end], the
|
||||
/// [crossAxisAlignment] is either [WrapCrossAlignment.start] or
|
||||
/// [WrapCrossAlignment.end], or there's more than one child, then the
|
||||
/// [verticalDirection] must not be null.
|
||||
VerticalDirection get verticalDirection => _verticalDirection;
|
||||
VerticalDirection _verticalDirection;
|
||||
|
||||
set verticalDirection(VerticalDirection value) {
|
||||
if (_verticalDirection == value) return;
|
||||
_verticalDirection = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
/// {@macro flutter.material.Material.clipBehavior}
|
||||
///
|
||||
/// Defaults to [Clip.none], and must not be null.
|
||||
Clip get clipBehavior => _clipBehavior;
|
||||
Clip _clipBehavior = Clip.none;
|
||||
|
||||
set clipBehavior(Clip value) {
|
||||
if (value == _clipBehavior) return;
|
||||
_clipBehavior = value;
|
||||
markNeedsPaint();
|
||||
markNeedsSemanticsUpdate();
|
||||
}
|
||||
|
||||
/// maximum rows when expand; when it is 0, the maximum rows is not limited;
|
||||
int _maxLines;
|
||||
|
||||
int get maxLines => _maxLines;
|
||||
|
||||
set maxLines(int value) {
|
||||
if (_maxLines == value) return;
|
||||
_maxLines = value;
|
||||
markNeedsLayout();
|
||||
}
|
||||
|
||||
bool get _debugHasNecessaryDirections {
|
||||
if (firstChild != null && lastChild != firstChild) {
|
||||
// i.e. there's more than one child
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
assert(textDirection != null,
|
||||
'Horizontal $runtimeType with multiple children has a null textDirection, so the layout order is undefined.');
|
||||
break;
|
||||
case Axis.vertical:
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (alignment == WrapAlignment.start || alignment == WrapAlignment.end) {
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
assert(textDirection != null,
|
||||
'Horizontal $runtimeType with alignment $alignment has a null textDirection, so the alignment cannot be resolved.');
|
||||
break;
|
||||
case Axis.vertical:
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (runAlignment == WrapAlignment.start || runAlignment == WrapAlignment.end) {
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
break;
|
||||
case Axis.vertical:
|
||||
assert(textDirection != null,
|
||||
'Vertical $runtimeType with runAlignment $runAlignment has a null textDirection, so the alignment cannot be resolved.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (crossAxisAlignment == WrapCrossAlignment.start || crossAxisAlignment == WrapCrossAlignment.end) {
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
break;
|
||||
case Axis.vertical:
|
||||
assert(textDirection != null,
|
||||
'Vertical $runtimeType with crossAxisAlignment $crossAxisAlignment has a null textDirection, so the alignment cannot be resolved.');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void setupParentData(RenderBox child) {
|
||||
if (child.parentData is! ShrinkWrapParentData) {
|
||||
child.parentData = ShrinkWrapParentData();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicWidth(double height) {
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
double width = 0.0;
|
||||
RenderBox? child = firstChild;
|
||||
while (child != null) {
|
||||
width = math.max(width, child.getMinIntrinsicWidth(double.infinity));
|
||||
child = childAfter(child);
|
||||
}
|
||||
return width;
|
||||
case Axis.vertical:
|
||||
return computeDryLayout(BoxConstraints(maxHeight: height)).width;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicWidth(double height) {
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
double width = 0.0;
|
||||
RenderBox? child = firstChild;
|
||||
while (child != null) {
|
||||
width += child.getMaxIntrinsicWidth(double.infinity);
|
||||
child = childAfter(child);
|
||||
}
|
||||
return width;
|
||||
case Axis.vertical:
|
||||
return computeDryLayout(BoxConstraints(maxHeight: height)).width;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMinIntrinsicHeight(double width) {
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
return computeDryLayout(BoxConstraints(maxWidth: width)).height;
|
||||
case Axis.vertical:
|
||||
double height = 0.0;
|
||||
RenderBox? child = firstChild;
|
||||
while (child != null) {
|
||||
height = math.max(height, child.getMinIntrinsicHeight(double.infinity));
|
||||
child = childAfter(child);
|
||||
}
|
||||
return height;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
double computeMaxIntrinsicHeight(double width) {
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
return computeDryLayout(BoxConstraints(maxWidth: width)).height;
|
||||
case Axis.vertical:
|
||||
double height = 0.0;
|
||||
RenderBox? child = firstChild;
|
||||
while (child != null) {
|
||||
height += child.getMaxIntrinsicHeight(double.infinity);
|
||||
child = childAfter(child);
|
||||
}
|
||||
return height;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
double? computeDistanceToActualBaseline(TextBaseline baseline) {
|
||||
return defaultComputeDistanceToHighestActualBaseline(baseline);
|
||||
}
|
||||
|
||||
double _getMainAxisExtent(Size childSize) {
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
return childSize.width;
|
||||
case Axis.vertical:
|
||||
return childSize.height;
|
||||
}
|
||||
}
|
||||
|
||||
double _getCrossAxisExtent(Size childSize) {
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
return childSize.height;
|
||||
case Axis.vertical:
|
||||
return childSize.width;
|
||||
}
|
||||
}
|
||||
|
||||
Offset _getOffset(double mainAxisOffset, double crossAxisOffset) {
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
return Offset(mainAxisOffset, crossAxisOffset);
|
||||
case Axis.vertical:
|
||||
return Offset(crossAxisOffset, mainAxisOffset);
|
||||
}
|
||||
}
|
||||
|
||||
double _getChildCrossAxisOffset(bool flipCrossAxis, double runCrossAxisExtent, double childCrossAxisExtent) {
|
||||
final double freeSpace = runCrossAxisExtent - childCrossAxisExtent;
|
||||
switch (crossAxisAlignment) {
|
||||
case WrapCrossAlignment.start:
|
||||
return flipCrossAxis ? freeSpace : 0.0;
|
||||
case WrapCrossAlignment.end:
|
||||
return flipCrossAxis ? 0.0 : freeSpace;
|
||||
case WrapCrossAlignment.center:
|
||||
return freeSpace / 2.0;
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasVisualOverflow = false;
|
||||
|
||||
@override
|
||||
Size computeDryLayout(BoxConstraints constraints) {
|
||||
return _computeDryLayout(constraints);
|
||||
}
|
||||
|
||||
Size _computeDryLayout(BoxConstraints constraints, [ChildLayouter layoutChild = ChildLayoutHelper.dryLayoutChild]) {
|
||||
final BoxConstraints childConstraints;
|
||||
double mainAxisLimit = 0.0;
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
childConstraints = BoxConstraints(maxWidth: constraints.maxWidth);
|
||||
mainAxisLimit = constraints.maxWidth;
|
||||
break;
|
||||
case Axis.vertical:
|
||||
childConstraints = BoxConstraints(maxHeight: constraints.maxHeight);
|
||||
mainAxisLimit = constraints.maxHeight;
|
||||
break;
|
||||
}
|
||||
|
||||
double mainAxisExtent = 0.0;
|
||||
double crossAxisExtent = 0.0;
|
||||
double runMainAxisExtent = 0.0;
|
||||
double runCrossAxisExtent = 0.0;
|
||||
int childCount = 0;
|
||||
RenderBox? child = firstChild;
|
||||
int runMainIndex = 0;
|
||||
while (child != null) {
|
||||
final Size childSize = layoutChild(child, childConstraints);
|
||||
final double childMainAxisExtent = _getMainAxisExtent(childSize);
|
||||
final double childCrossAxisExtent = _getCrossAxisExtent(childSize);
|
||||
// There must be at least one child before we move on to the next run.
|
||||
if (childCount > 0 && runMainAxisExtent + childMainAxisExtent + spacing > mainAxisLimit) {
|
||||
mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
|
||||
crossAxisExtent += runCrossAxisExtent + runSpacing;
|
||||
runMainAxisExtent = 0.0;
|
||||
runCrossAxisExtent = 0.0;
|
||||
childCount = 0;
|
||||
if (_maxLines > 0 && ++runMainIndex > _maxLines) break;
|
||||
}
|
||||
runMainAxisExtent += childMainAxisExtent;
|
||||
runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent);
|
||||
if (childCount > 0) runMainAxisExtent += spacing;
|
||||
childCount += 1;
|
||||
child = childAfter(child);
|
||||
}
|
||||
crossAxisExtent += runCrossAxisExtent;
|
||||
mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
|
||||
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
return constraints.constrain(Size(mainAxisExtent, crossAxisExtent));
|
||||
case Axis.vertical:
|
||||
return constraints.constrain(Size(crossAxisExtent, mainAxisExtent));
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取真实行数
|
||||
int _getRowCount(BoxConstraints constraints) {
|
||||
ChildLayouter layoutChild = ChildLayoutHelper.dryLayoutChild;
|
||||
final BoxConstraints childConstraints;
|
||||
double mainAxisLimit = 0.0;
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
childConstraints = BoxConstraints(maxWidth: constraints.maxWidth);
|
||||
mainAxisLimit = constraints.maxWidth;
|
||||
break;
|
||||
case Axis.vertical:
|
||||
childConstraints = BoxConstraints(maxHeight: constraints.maxHeight);
|
||||
mainAxisLimit = constraints.maxHeight;
|
||||
break;
|
||||
}
|
||||
double runMainAxisExtent = 0.0;
|
||||
double runCrossAxisExtent = 0.0;
|
||||
int childCount = 0;
|
||||
RenderBox? child = firstChild;
|
||||
int runMainCount = child != null ? 1 : 0;
|
||||
while (child != null) {
|
||||
final Size childSize = layoutChild(child, childConstraints);
|
||||
final double childMainAxisExtent = _getMainAxisExtent(childSize);
|
||||
final double childCrossAxisExtent = _getCrossAxisExtent(childSize);
|
||||
// There must be at least one child before we move on to the next run.
|
||||
if (childCount > 0 && runMainAxisExtent + childMainAxisExtent + spacing > mainAxisLimit) {
|
||||
runMainAxisExtent = 0.0;
|
||||
runCrossAxisExtent = 0.0;
|
||||
childCount = 0;
|
||||
runMainCount++;
|
||||
}
|
||||
runMainAxisExtent += childMainAxisExtent;
|
||||
runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent);
|
||||
if (childCount > 0) runMainAxisExtent += spacing;
|
||||
childCount += 1;
|
||||
child = childAfter(child);
|
||||
}
|
||||
return runMainCount;
|
||||
}
|
||||
|
||||
/// 总行数
|
||||
int _totalRowCount = 0;
|
||||
|
||||
int get totalRowCount => _totalRowCount;
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
final BoxConstraints constraints = this.constraints;
|
||||
assert(_debugHasNecessaryDirections);
|
||||
|
||||
_totalRowCount = _getRowCount(constraints); // 计算总行数
|
||||
|
||||
_hasVisualOverflow = false;
|
||||
RenderBox? child = firstChild;
|
||||
if (child == null) {
|
||||
size = constraints.smallest;
|
||||
return;
|
||||
}
|
||||
final BoxConstraints childConstraints;
|
||||
double mainAxisLimit = 0.0;
|
||||
bool flipMainAxis = false;
|
||||
bool flipCrossAxis = false;
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
childConstraints = BoxConstraints(maxWidth: constraints.maxWidth);
|
||||
mainAxisLimit = constraints.maxWidth;
|
||||
if (textDirection == TextDirection.rtl) flipMainAxis = true;
|
||||
if (verticalDirection == VerticalDirection.up) flipCrossAxis = true;
|
||||
break;
|
||||
case Axis.vertical:
|
||||
childConstraints = BoxConstraints(maxHeight: constraints.maxHeight);
|
||||
mainAxisLimit = constraints.maxHeight;
|
||||
if (verticalDirection == VerticalDirection.up) flipMainAxis = true;
|
||||
if (textDirection == TextDirection.rtl) flipCrossAxis = true;
|
||||
break;
|
||||
}
|
||||
final double spacing = this.spacing;
|
||||
final double runSpacing = this.runSpacing;
|
||||
final List<_RunMetrics> runMetrics = <_RunMetrics>[];
|
||||
double mainAxisExtent = 0.0;
|
||||
double crossAxisExtent = 0.0;
|
||||
double runMainAxisExtent = 0.0;
|
||||
double runCrossAxisExtent = 0.0;
|
||||
int childCount = 0;
|
||||
int runMainIndex = 1;
|
||||
while (child != null) {
|
||||
final childParentData = child.parentData! as ShrinkWrapParentData;
|
||||
if (_maxLines > 0 && runMainIndex > _maxLines) {
|
||||
child.layout(BoxConstraints.loose(Size.zero), parentUsesSize: true);
|
||||
child = childParentData.nextSibling;
|
||||
continue;
|
||||
} else {
|
||||
child.layout(childConstraints, parentUsesSize: true);
|
||||
}
|
||||
final double childMainAxisExtent = _getMainAxisExtent(child.size);
|
||||
final double childCrossAxisExtent = _getCrossAxisExtent(child.size);
|
||||
if (childCount > 0 && runMainAxisExtent + spacing + childMainAxisExtent > mainAxisLimit) {
|
||||
mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
|
||||
crossAxisExtent += runCrossAxisExtent;
|
||||
if (runMetrics.isNotEmpty) crossAxisExtent += runSpacing;
|
||||
runMetrics.add(_RunMetrics(runMainAxisExtent, runCrossAxisExtent, childCount));
|
||||
runMainAxisExtent = 0.0;
|
||||
runCrossAxisExtent = 0.0;
|
||||
childCount = 0;
|
||||
if (_maxLines > 0 && ++runMainIndex > _maxLines) {
|
||||
child.layout(BoxConstraints.loose(Size.zero), parentUsesSize: true);
|
||||
child = childParentData.nextSibling;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
runMainAxisExtent += childMainAxisExtent;
|
||||
if (childCount > 0) runMainAxisExtent += spacing;
|
||||
runCrossAxisExtent = math.max(runCrossAxisExtent, childCrossAxisExtent);
|
||||
childCount += 1;
|
||||
|
||||
childParentData._runIndex = runMetrics.length;
|
||||
child = childParentData.nextSibling;
|
||||
}
|
||||
if (childCount > 0) {
|
||||
mainAxisExtent = math.max(mainAxisExtent, runMainAxisExtent);
|
||||
crossAxisExtent += runCrossAxisExtent;
|
||||
if (runMetrics.isNotEmpty) crossAxisExtent += runSpacing;
|
||||
runMetrics.add(_RunMetrics(runMainAxisExtent, runCrossAxisExtent, childCount));
|
||||
}
|
||||
|
||||
final int runCount = runMetrics.length;
|
||||
assert(runCount > 0);
|
||||
|
||||
double containerMainAxisExtent = 0.0;
|
||||
double containerCrossAxisExtent = 0.0;
|
||||
|
||||
switch (direction) {
|
||||
case Axis.horizontal:
|
||||
size = constraints.constrain(Size(mainAxisExtent, crossAxisExtent));
|
||||
containerMainAxisExtent = size.width;
|
||||
containerCrossAxisExtent = size.height;
|
||||
break;
|
||||
case Axis.vertical:
|
||||
size = constraints.constrain(Size(crossAxisExtent, mainAxisExtent));
|
||||
containerMainAxisExtent = size.height;
|
||||
containerCrossAxisExtent = size.width;
|
||||
break;
|
||||
}
|
||||
|
||||
_hasVisualOverflow = containerMainAxisExtent < mainAxisExtent || containerCrossAxisExtent < crossAxisExtent;
|
||||
|
||||
final double crossAxisFreeSpace = math.max(0.0, containerCrossAxisExtent - crossAxisExtent);
|
||||
double runLeadingSpace = 0.0;
|
||||
double runBetweenSpace = 0.0;
|
||||
switch (runAlignment) {
|
||||
case WrapAlignment.start:
|
||||
break;
|
||||
case WrapAlignment.end:
|
||||
runLeadingSpace = crossAxisFreeSpace;
|
||||
break;
|
||||
case WrapAlignment.center:
|
||||
runLeadingSpace = crossAxisFreeSpace / 2.0;
|
||||
break;
|
||||
case WrapAlignment.spaceBetween:
|
||||
runBetweenSpace = runCount > 1 ? crossAxisFreeSpace / (runCount - 1) : 0.0;
|
||||
break;
|
||||
case WrapAlignment.spaceAround:
|
||||
runBetweenSpace = crossAxisFreeSpace / runCount;
|
||||
runLeadingSpace = runBetweenSpace / 2.0;
|
||||
break;
|
||||
case WrapAlignment.spaceEvenly:
|
||||
runBetweenSpace = crossAxisFreeSpace / (runCount + 1);
|
||||
runLeadingSpace = runBetweenSpace;
|
||||
break;
|
||||
}
|
||||
|
||||
runBetweenSpace += runSpacing;
|
||||
double crossAxisOffset = flipCrossAxis ? containerCrossAxisExtent - runLeadingSpace : runLeadingSpace;
|
||||
|
||||
child = firstChild;
|
||||
for (int i = 0; i < runCount; ++i) {
|
||||
final _RunMetrics metrics = runMetrics[i];
|
||||
final double runMainAxisExtent = metrics.mainAxisExtent;
|
||||
final double runCrossAxisExtent = metrics.crossAxisExtent;
|
||||
final int childCount = metrics.childCount;
|
||||
|
||||
final double mainAxisFreeSpace = math.max(0.0, containerMainAxisExtent - runMainAxisExtent);
|
||||
double childLeadingSpace = 0.0;
|
||||
double childBetweenSpace = 0.0;
|
||||
|
||||
switch (alignment) {
|
||||
case WrapAlignment.start:
|
||||
break;
|
||||
case WrapAlignment.end:
|
||||
childLeadingSpace = mainAxisFreeSpace;
|
||||
break;
|
||||
case WrapAlignment.center:
|
||||
childLeadingSpace = mainAxisFreeSpace / 2.0;
|
||||
break;
|
||||
case WrapAlignment.spaceBetween:
|
||||
childBetweenSpace = childCount > 1 ? mainAxisFreeSpace / (childCount - 1) : 0.0;
|
||||
break;
|
||||
case WrapAlignment.spaceAround:
|
||||
childBetweenSpace = mainAxisFreeSpace / childCount;
|
||||
childLeadingSpace = childBetweenSpace / 2.0;
|
||||
break;
|
||||
case WrapAlignment.spaceEvenly:
|
||||
childBetweenSpace = mainAxisFreeSpace / (childCount + 1);
|
||||
childLeadingSpace = childBetweenSpace;
|
||||
break;
|
||||
}
|
||||
|
||||
childBetweenSpace += spacing;
|
||||
double childMainPosition = flipMainAxis ? containerMainAxisExtent - childLeadingSpace : childLeadingSpace;
|
||||
|
||||
if (flipCrossAxis) crossAxisOffset -= runCrossAxisExtent;
|
||||
|
||||
while (child != null) {
|
||||
final ShrinkWrapParentData childParentData = child.parentData! as ShrinkWrapParentData;
|
||||
if (childParentData._runIndex != i) break;
|
||||
final double childMainAxisExtent = _getMainAxisExtent(child.size);
|
||||
final double childCrossAxisExtent = _getCrossAxisExtent(child.size);
|
||||
final double childCrossAxisOffset = _getChildCrossAxisOffset(flipCrossAxis, runCrossAxisExtent, childCrossAxisExtent);
|
||||
if (flipMainAxis) childMainPosition -= childMainAxisExtent;
|
||||
childParentData.offset = _getOffset(childMainPosition, crossAxisOffset + childCrossAxisOffset);
|
||||
if (flipMainAxis) {
|
||||
childMainPosition -= childBetweenSpace;
|
||||
} else {
|
||||
childMainPosition += childMainAxisExtent + childBetweenSpace;
|
||||
}
|
||||
child = childParentData.nextSibling;
|
||||
}
|
||||
|
||||
if (flipCrossAxis) {
|
||||
crossAxisOffset -= runBetweenSpace;
|
||||
} else {
|
||||
crossAxisOffset += runCrossAxisExtent + runBetweenSpace;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
|
||||
return defaultHitTestChildren(result, position: position);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
// TODO(ianh): move the debug flex overflow paint logic somewhere common so
|
||||
// it can be reused here
|
||||
if (_hasVisualOverflow && clipBehavior != Clip.none) {
|
||||
_clipRectLayer.layer = context.pushClipRect(
|
||||
needsCompositing,
|
||||
offset,
|
||||
Offset.zero & size,
|
||||
defaultPaint,
|
||||
clipBehavior: clipBehavior,
|
||||
oldLayer: _clipRectLayer.layer,
|
||||
);
|
||||
} else {
|
||||
_clipRectLayer.layer = null;
|
||||
defaultPaint(context, offset);
|
||||
}
|
||||
}
|
||||
|
||||
final LayerHandle<ClipRectLayer> _clipRectLayer = LayerHandle<ClipRectLayer>();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_clipRectLayer.layer = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
properties.add(EnumProperty<Axis>('direction', direction));
|
||||
properties.add(EnumProperty<WrapAlignment>('alignment', alignment));
|
||||
properties.add(DoubleProperty('spacing', spacing));
|
||||
properties.add(EnumProperty<WrapAlignment>('runAlignment', runAlignment));
|
||||
properties.add(DoubleProperty('runSpacing', runSpacing));
|
||||
properties.add(DoubleProperty('crossAxisAlignment', runSpacing));
|
||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
|
||||
properties.add(EnumProperty<VerticalDirection>('verticalDirection', verticalDirection, defaultValue: VerticalDirection.down));
|
||||
properties.add(IntProperty('maxLines', maxLines, defaultValue: 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 抽屉/列表项逐个入场动画:淡入 + 轻微上滑,按 index 错峰启动。
|
||||
/// 仅前 [maxAnimatedIndex] 个 item 播放动画,更靠后的直接显示,
|
||||
/// 避免长章节列表滚动时反复触发动画。
|
||||
class StaggerInItem extends StatefulWidget {
|
||||
final int index;
|
||||
final Widget child;
|
||||
final int maxAnimatedIndex;
|
||||
|
||||
const StaggerInItem({
|
||||
super.key,
|
||||
required this.index,
|
||||
required this.child,
|
||||
this.maxAnimatedIndex = 14,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StaggerInItem> createState() => _StaggerInItemState();
|
||||
}
|
||||
|
||||
class _StaggerInItemState extends State<StaggerInItem> with SingleTickerProviderStateMixin {
|
||||
AnimationController? _ctr;
|
||||
|
||||
bool get _animate => widget.index <= widget.maxAnimatedIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!_animate) return;
|
||||
_ctr = AnimationController(vsync: this, duration: const Duration(milliseconds: 260));
|
||||
// 按 index 错峰,最多延迟 280ms
|
||||
final delay = (widget.index * 35).clamp(0, 280);
|
||||
Future.delayed(Duration(milliseconds: delay), () {
|
||||
if (mounted) _ctr?.forward();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctr?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ctr = _ctr;
|
||||
if (ctr == null) return widget.child; // 靠后的 item 不做动画
|
||||
return FadeTransition(
|
||||
opacity: ctr,
|
||||
child: SlideTransition(
|
||||
position: Tween(begin: const Offset(0, .12), end: Offset.zero)
|
||||
.animate(CurvedAnimation(parent: ctr, curve: Curves.easeOut)),
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
/// 左滑露出操作按钮的列表项包装(如「我的关注」左滑取消关注)。
|
||||
///
|
||||
/// 不用 Dismissible:那个滑到底即执行,取关这种不可撤销的操作误触代价太大,
|
||||
/// 这里做成「左滑露出按钮、点按钮才执行」。
|
||||
class SwipeActionItem extends StatefulWidget {
|
||||
final Widget child;
|
||||
final String actionText;
|
||||
final VoidCallback onAction;
|
||||
final Color actionColor;
|
||||
final double actionWidth;
|
||||
|
||||
/// 滑动层的底色。列表项自身多半是透明的,不垫一层实色,底下的按钮会直接透上来
|
||||
final Color? backgroundColor;
|
||||
|
||||
const SwipeActionItem({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.actionText,
|
||||
required this.onAction,
|
||||
this.actionColor = const Color(0xffE03017),
|
||||
this.actionWidth = 88,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
/// 关掉当前展开的那一项(列表滚动时调,避免滑走了还留着一个张开的)
|
||||
static void closeOpened() => _opened?._close();
|
||||
|
||||
@override
|
||||
State<SwipeActionItem> createState() => _SwipeActionItemState();
|
||||
}
|
||||
|
||||
/// 全局只允许一项展开:展开新的会自动收起旧的
|
||||
_SwipeActionItemState? _opened;
|
||||
|
||||
class _SwipeActionItemState extends State<SwipeActionItem> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctr = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
|
||||
bool get _isOpen => _ctr.value > 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_opened == this) _opened = null;
|
||||
_ctr.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _open() {
|
||||
if (_opened != null && _opened != this) _opened!._close();
|
||||
_opened = this;
|
||||
_ctr.forward();
|
||||
}
|
||||
|
||||
void _close() {
|
||||
if (_opened == this) _opened = null;
|
||||
if (mounted) _ctr.reverse();
|
||||
}
|
||||
|
||||
void _onDragUpdate(DragUpdateDetails d) {
|
||||
// 手指往左走 primaryDelta 为负,换算成 0~1 的展开进度
|
||||
_ctr.value -= (d.primaryDelta ?? 0) / widget.actionWidth;
|
||||
}
|
||||
|
||||
void _onDragEnd(DragEndDetails d) {
|
||||
final v = d.primaryVelocity ?? 0;
|
||||
if (v < -300) return _open(); // 甩一下就展开,不看位置
|
||||
if (v > 300) return _close();
|
||||
_ctr.value > 0.5 ? _open() : _close();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onHorizontalDragUpdate: _onDragUpdate,
|
||||
onHorizontalDragEnd: _onDragEnd,
|
||||
child: AnimatedBuilder(
|
||||
animation: _ctr,
|
||||
builder: (_, child) {
|
||||
return Stack(
|
||||
children: [
|
||||
// 按钮垫在底下,靠内容左移露出来;跟着内容一起裁,避免收起时露边
|
||||
Positioned.fill(child: _actionButton()),
|
||||
Transform.translate(
|
||||
offset: Offset(-widget.actionWidth * _ctr.value, 0),
|
||||
child: ColoredBox(
|
||||
color: widget.backgroundColor ?? Get.theme.scaffoldBackgroundColor,
|
||||
// 展开状态下先吞掉一次点击用于收起,别直接把用户点进详情页
|
||||
child: _isOpen
|
||||
? GestureDetector(
|
||||
onTap: _close,
|
||||
child: AbsorbPointer(child: child),
|
||||
)
|
||||
: child,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _actionButton() {
|
||||
return Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
_close();
|
||||
widget.onAction();
|
||||
},
|
||||
child: Container(
|
||||
width: widget.actionWidth,
|
||||
alignment: Alignment.center,
|
||||
color: widget.actionColor,
|
||||
child: Text(
|
||||
widget.actionText,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user