初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
@@ -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;
@@ -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;
}
}
@@ -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;
+157
View File
@@ -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),
),
),
),
),
],
),
),
],
),
);
}
}
+61
View File
@@ -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,
),
),
),
),
),
);
}
}
+118
View File
@@ -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,
),
),
);
}
}
+278
View File
@@ -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),
),
),
),
);
}
}
+69
View File
@@ -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;进过一次就一直留在树里,
/// 切走只是不 paintstate / 滚动位置 / 播放器全部保留。
/// 相比 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),
),
),
),
);
}
}
+68
View File
@@ -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,
);
}
}
+121
View File
@@ -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 用廉价 bilineariOS 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. ListViewthe 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)),
);
}
}
+847
View File
@@ -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),
),
),
),
);
}
}