693 lines
18 KiB
Dart
693 lines
18 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
class OnBoardingSlider extends StatefulWidget {
|
|
const OnBoardingSlider({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<OnBoardingSlider> createState() => _OnBoardingSliderState();
|
|
}
|
|
|
|
class _OnBoardingSliderState extends State<OnBoardingSlider> {
|
|
final PageController _pageController = PageController();
|
|
int _currentPage = 0;
|
|
|
|
@override
|
|
var imageList = [
|
|
'assets/s1.png',
|
|
'assets/s2.png',
|
|
'assets/s3.png',
|
|
'assets/s4.png',
|
|
'assets/s5.png'
|
|
];
|
|
|
|
int currentIndex = 0;
|
|
|
|
Widget buildDot(int index) {
|
|
return Container(
|
|
margin: EdgeInsets.all(5),
|
|
width: 10,
|
|
height: 10,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: currentIndex == index ? Colors.white : Colors.grey,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget buildGallery3D() {
|
|
return Gallery3D(
|
|
controller: Gallery3DController(
|
|
itemCount: imageList.length,
|
|
ellipseHeight: 0,
|
|
autoLoop: true,
|
|
minScale: 0.4,
|
|
delayTime: 2000,
|
|
scrollTime: 1000,
|
|
),
|
|
width: MediaQuery.of(context).size.width,
|
|
height: 300,
|
|
isClip: true,
|
|
onItemChanged: (index) {
|
|
setState(
|
|
() {
|
|
currentIndex = index;
|
|
_currentPage = index;
|
|
},
|
|
);
|
|
},
|
|
itemConfig: const GalleryItemConfig(
|
|
width: 180,
|
|
height: 850,
|
|
radius: 10,
|
|
isShowTransformMask: false,
|
|
),
|
|
onClickItem: (index) {
|
|
if (kDebugMode) print("currentIndex:$index");
|
|
},
|
|
itemBuilder: (context, index) {
|
|
return Image.asset(
|
|
imageList[index],
|
|
fit: BoxFit.fill,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
var screenHeight = MediaQuery.of(context).size.height;
|
|
|
|
return Expanded(
|
|
child: Column(
|
|
children: [
|
|
// Expanded(
|
|
// child:
|
|
Container(
|
|
height: screenHeight / 4,
|
|
child: PageView.builder(
|
|
controller: _pageController,
|
|
itemCount: imageList.length,
|
|
itemBuilder: ((context, index) {
|
|
return buildGallery3D();
|
|
}),
|
|
onPageChanged: (index) {
|
|
setState(() {
|
|
_currentPage = index;
|
|
});
|
|
},
|
|
),
|
|
// ),
|
|
),
|
|
SizedBox(height: 16),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: List.generate(
|
|
imageList.length,
|
|
(index) => buildDot(index),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class Gallery3D extends StatefulWidget {
|
|
final double? height;
|
|
final double width;
|
|
final IndexedWidgetBuilder itemBuilder;
|
|
final ValueChanged<int>? onItemChanged;
|
|
final ValueChanged<int>? onClickItem;
|
|
final Gallery3DController controller;
|
|
final GalleryItemConfig itemConfig;
|
|
final EdgeInsetsGeometry? padding;
|
|
final bool isClip;
|
|
|
|
Gallery3D(
|
|
{Key? key,
|
|
this.onClickItem,
|
|
this.onItemChanged,
|
|
this.isClip = true,
|
|
this.height,
|
|
this.padding,
|
|
required this.itemConfig,
|
|
required this.controller,
|
|
required this.width,
|
|
required this.itemBuilder})
|
|
: super(key: key);
|
|
|
|
@override
|
|
_Gallery3DState createState() => _Gallery3DState();
|
|
}
|
|
|
|
class _Gallery3DState extends State<Gallery3D>
|
|
with TickerProviderStateMixin, WidgetsBindingObserver, Gallery3DMixin {
|
|
List<Widget> _galleryItemWidgetList = [];
|
|
AnimationController? _autoScrollAnimationController;
|
|
Timer? _timer;
|
|
|
|
late Gallery3DController controller = widget.controller;
|
|
|
|
AppLifecycleState appLifecycleState = AppLifecycleState.resumed;
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
appLifecycleState = state;
|
|
super.didChangeAppLifecycleState(state);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
controller.widgetWidth = widget.width;
|
|
controller.vsync = this;
|
|
controller.init(widget.itemConfig);
|
|
|
|
_updateWidgetIndexOnStack();
|
|
if (controller.autoLoop) {
|
|
this._timer =
|
|
Timer.periodic(Duration(milliseconds: controller.delayTime), (timer) {
|
|
if (!mounted) return;
|
|
if (appLifecycleState != AppLifecycleState.resumed) return;
|
|
if (DateTime.now().millisecondsSinceEpoch - _lastTouchMillisecond <
|
|
controller.delayTime) return;
|
|
if (_isTouching) return;
|
|
animateTo(controller.getOffsetAngleFormTargetIndex(
|
|
getNextIndex(controller.currentIndex)));
|
|
});
|
|
}
|
|
|
|
WidgetsBinding.instance.addObserver(this);
|
|
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
_timer?.cancel();
|
|
_timer = null;
|
|
_autoScrollAnimationController?.stop(canceled: true);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void animateTo(angle) {
|
|
_isTouching = true;
|
|
_lastTouchMillisecond = DateTime.now().millisecondsSinceEpoch;
|
|
_scrollToAngle(angle);
|
|
}
|
|
|
|
@override
|
|
void jumpTo(angle) {
|
|
setState(() {
|
|
_updateAllGalleryItemTransformByAngle(angle);
|
|
});
|
|
}
|
|
|
|
var _isTouching = false;
|
|
var _lastTouchMillisecond = 0;
|
|
Offset? _panDownLocation;
|
|
Offset? _lastUpdateLocation;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: widget.width,
|
|
height: widget.height ?? widget.itemConfig.height,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onHorizontalDragCancel: (() {
|
|
_onFingerUp();
|
|
}),
|
|
onHorizontalDragDown: (details) {
|
|
_isTouching = true;
|
|
_panDownLocation = details.localPosition;
|
|
_lastUpdateLocation = details.localPosition;
|
|
_lastTouchMillisecond = DateTime.now().millisecondsSinceEpoch;
|
|
},
|
|
onHorizontalDragEnd: (details) {
|
|
_onFingerUp();
|
|
},
|
|
onHorizontalDragStart: (details) {},
|
|
onHorizontalDragUpdate: (details) {
|
|
setState(() {
|
|
_lastUpdateLocation = details.localPosition;
|
|
_lastTouchMillisecond = DateTime.now().millisecondsSinceEpoch;
|
|
_updateAllGalleryItemTransformByOffsetDx(details.delta.dx);
|
|
});
|
|
},
|
|
child: _buildWidgetList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildWidgetList() {
|
|
if (widget.isClip) {
|
|
return ClipRect(
|
|
child: Stack(
|
|
children: _galleryItemWidgetList,
|
|
));
|
|
}
|
|
return Stack(
|
|
children: _galleryItemWidgetList,
|
|
);
|
|
}
|
|
|
|
void _scrollToAngle(double angle) {
|
|
_autoScrollAnimationController =
|
|
AnimationController(duration: Duration(milliseconds: 100), vsync: this);
|
|
|
|
Animation animation;
|
|
|
|
if (angle.ceil().abs() == 0) return;
|
|
animation =
|
|
Tween(begin: 0.0, end: angle).animate(_autoScrollAnimationController!);
|
|
|
|
double lastValue = 0;
|
|
animation.addListener(() {
|
|
setState(() {
|
|
_updateAllGalleryItemTransformByAngle(animation.value - lastValue);
|
|
lastValue = animation.value;
|
|
});
|
|
});
|
|
_autoScrollAnimationController?.forward();
|
|
_autoScrollAnimationController?.addListener(() {
|
|
if (_autoScrollAnimationController != null &&
|
|
_autoScrollAnimationController!.isCompleted) {
|
|
_isTouching = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
void _onFingerUp() {
|
|
if (_lastUpdateLocation == null) {
|
|
_isTouching = false;
|
|
return;
|
|
}
|
|
|
|
double angle = controller.getTransformInfo(controller.currentIndex).angle;
|
|
double targetAngle = 0;
|
|
|
|
var offsetX = _lastUpdateLocation!.dx - _panDownLocation!.dx;
|
|
|
|
// Adjust the threshold for considering it a swipe
|
|
double swipeThreshold = widget.width * 0.1;
|
|
|
|
if (offsetX.abs() > swipeThreshold) {
|
|
// Swipe detected
|
|
if (offsetX > 0) {
|
|
// Swipe to the right, move to the bottom
|
|
targetAngle = controller
|
|
.getTransformInfo(getPreIndex(controller.currentIndex))
|
|
.angle +
|
|
180; // Move to the bottom
|
|
} else {
|
|
// Swipe to the left
|
|
targetAngle = angle + 180; // Maintain the current position
|
|
}
|
|
} else {
|
|
// No swipe, move to the bottom from the current position
|
|
targetAngle = angle + 180; // Move to the bottom
|
|
}
|
|
|
|
_scrollToAngle(targetAngle);
|
|
}
|
|
|
|
void _updateAllGalleryItemTransformByAngle(double angle) {
|
|
controller.updateTransformByAngle(angle);
|
|
_updateAllGalleryItemTransform();
|
|
}
|
|
|
|
void _updateAllGalleryItemTransformByOffsetDx(double offsetDx) {
|
|
controller.updateTransformByOffsetDx(offsetDx);
|
|
_updateAllGalleryItemTransform();
|
|
}
|
|
|
|
void _updateAllGalleryItemTransform() {
|
|
for (var i = 0; i < controller.getTransformInfoListSize(); i++) {
|
|
var item = controller.getTransformInfo(i);
|
|
|
|
if (item.angle > 180 - controller.unitAngle / 2 &&
|
|
item.angle < 180 + controller.unitAngle / 2) {
|
|
if (controller.currentIndex != i) {
|
|
controller.currentIndex = i;
|
|
widget.onItemChanged?.call(controller.currentIndex);
|
|
}
|
|
}
|
|
_updateWidgetIndexOnStack();
|
|
}
|
|
}
|
|
|
|
/// Get Pre Index >>>
|
|
int getPreIndex(int index) {
|
|
var preIndex = index - 1;
|
|
if (preIndex < 0) {
|
|
preIndex = controller.itemCount - 1;
|
|
}
|
|
return preIndex;
|
|
}
|
|
|
|
/// Get Next Index >>>
|
|
int getNextIndex(int index) {
|
|
var nextIndex = index + 1;
|
|
if (nextIndex == controller.itemCount) {
|
|
nextIndex = 0;
|
|
}
|
|
return nextIndex;
|
|
}
|
|
|
|
List<GalleryItem> _leftWidgetList = [];
|
|
List<GalleryItem> _rightWidgetList = [];
|
|
List<GalleryItem> _tempList = [];
|
|
|
|
/// Update Widget Index >>>
|
|
void _updateWidgetIndexOnStack() {
|
|
_leftWidgetList.clear();
|
|
_rightWidgetList.clear();
|
|
_tempList.clear();
|
|
|
|
for (var i = 0; i < controller.getTransformInfoListSize(); i++) {
|
|
var angle = controller.getTransformInfo(i).angle;
|
|
|
|
if (angle >= 180 + controller.unitAngle / 2) {
|
|
_leftWidgetList.add(_buildGalleryItem(
|
|
i,
|
|
));
|
|
} else {
|
|
_rightWidgetList.add(_buildGalleryItem(
|
|
i,
|
|
));
|
|
}
|
|
}
|
|
|
|
_rightWidgetList.sort((widget1, widget2) =>
|
|
widget1.transformInfo.angle.compareTo(widget2.transformInfo.angle));
|
|
|
|
_rightWidgetList.forEach((element) {
|
|
if (element.transformInfo.angle < controller.unitAngle / 2) {
|
|
element.transformInfo.angle += 360;
|
|
_tempList.add(element);
|
|
}
|
|
});
|
|
|
|
_tempList.forEach((element) {
|
|
_rightWidgetList.remove(element);
|
|
});
|
|
|
|
_leftWidgetList.sort((widget1, widget2) =>
|
|
widget2.transformInfo.angle.compareTo(widget1.transformInfo.angle));
|
|
|
|
_galleryItemWidgetList = [
|
|
..._leftWidgetList,
|
|
..._rightWidgetList,
|
|
];
|
|
}
|
|
|
|
/// Build Gallery Item >>>>>
|
|
GalleryItem _buildGalleryItem(int index) {
|
|
return GalleryItem(
|
|
index: index,
|
|
ellipseHeight: controller.ellipseHeight,
|
|
builder: widget.itemBuilder,
|
|
config: widget.itemConfig,
|
|
onClick: (index) {
|
|
if (widget.onClickItem != null && index == controller.currentIndex) {
|
|
widget.onClickItem?.call(index);
|
|
}
|
|
},
|
|
transformInfo: controller.getTransformInfo(index),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GalleryItemTransformInfo {
|
|
Offset offset;
|
|
double scale;
|
|
double angle;
|
|
int index;
|
|
|
|
_GalleryItemTransformInfo(
|
|
{required this.index,
|
|
this.scale = 1,
|
|
this.angle = 0,
|
|
this.offset = Offset.zero});
|
|
}
|
|
|
|
class GalleryItem extends StatelessWidget {
|
|
final GalleryItemConfig config;
|
|
final double ellipseHeight;
|
|
final int index;
|
|
final IndexedWidgetBuilder builder;
|
|
final ValueChanged<int>? onClick;
|
|
final _GalleryItemTransformInfo transformInfo;
|
|
|
|
final double minScale;
|
|
GalleryItem({
|
|
Key? key,
|
|
required this.index,
|
|
required this.transformInfo,
|
|
required this.config,
|
|
required this.builder,
|
|
this.minScale = 0.4,
|
|
this.onClick,
|
|
this.ellipseHeight = 0,
|
|
}) : super(key: key);
|
|
|
|
Widget _buildItem(BuildContext context) {
|
|
return Container(
|
|
width: config.width,
|
|
height: config.height,
|
|
child: builder(context, index));
|
|
}
|
|
|
|
Widget _buildMaskTransformItem(Widget child) {
|
|
if (!config.isShowTransformMask) return child;
|
|
return Stack(children: [
|
|
child,
|
|
Container(
|
|
width: config.width,
|
|
height: config.height,
|
|
color: Color.fromARGB(
|
|
100 * (1 - transformInfo.scale) ~/ (1 - minScale), 0, 0, 0),
|
|
)
|
|
]);
|
|
}
|
|
|
|
Widget _buildRadiusItem(Widget child) {
|
|
if (config.radius <= 0) return child;
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(config.radius), child: child);
|
|
}
|
|
|
|
Widget _buildShadowItem(Widget child) {
|
|
if (config.shadows.isEmpty) return child;
|
|
return Container(
|
|
child: child, decoration: BoxDecoration(boxShadow: config.shadows));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Transform.translate(
|
|
offset: transformInfo.offset,
|
|
child: Container(
|
|
width: config.width,
|
|
height: config.height,
|
|
child: Transform.scale(
|
|
scale: transformInfo.scale,
|
|
child: InkWell(
|
|
highlightColor: Colors.transparent,
|
|
splashColor: Colors.transparent,
|
|
onTap: () => onClick?.call(index),
|
|
child: _buildShadowItem(
|
|
_buildRadiusItem(_buildMaskTransformItem(_buildItem(context)))),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Gallery Item Config >>>>>
|
|
class GalleryItemConfig {
|
|
final double width;
|
|
final double height;
|
|
final double radius;
|
|
final List<BoxShadow> shadows;
|
|
final bool isShowTransformMask;
|
|
|
|
const GalleryItemConfig(
|
|
{this.width = 220,
|
|
this.height = 600,
|
|
this.radius = 0,
|
|
this.isShowTransformMask = true,
|
|
this.shadows = const []});
|
|
}
|
|
|
|
class Gallery3DController {
|
|
double perimeter = 0;
|
|
double unitAngle = 0;
|
|
final double minScale;
|
|
double widgetWidth = 0;
|
|
double ellipseHeight;
|
|
int itemCount;
|
|
late GalleryItemConfig itemConfig;
|
|
int currentIndex = 0;
|
|
final int delayTime;
|
|
final int scrollTime;
|
|
final bool autoLoop;
|
|
late Gallery3DMixin vsync;
|
|
List<_GalleryItemTransformInfo> _galleryItemTransformInfoList = [];
|
|
double baseAngleOffset = 0;
|
|
Gallery3DController(
|
|
{required this.itemCount,
|
|
this.ellipseHeight = 0,
|
|
this.autoLoop = true,
|
|
this.minScale = 0.4,
|
|
this.delayTime = 5000,
|
|
this.scrollTime = 1000})
|
|
: assert(itemCount >= 3, 'ItemCount must be greater than or equal to 3');
|
|
|
|
void init(GalleryItemConfig itemConfig) {
|
|
this.itemConfig = itemConfig;
|
|
unitAngle = 360 / itemCount;
|
|
perimeter = calculatePerimeter(widgetWidth * 0.7, 50);
|
|
|
|
_galleryItemTransformInfoList.clear();
|
|
for (var i = 0; i < itemCount; i++) {
|
|
var itemAngle = getItemAngle(i);
|
|
_galleryItemTransformInfoList.add(_GalleryItemTransformInfo(
|
|
index: i,
|
|
angle: itemAngle,
|
|
scale: calculateScale(itemAngle),
|
|
offset: calculateOffset(itemAngle)));
|
|
}
|
|
}
|
|
|
|
_GalleryItemTransformInfo getTransformInfo(int index) {
|
|
return _galleryItemTransformInfoList[index];
|
|
}
|
|
|
|
int getTransformInfoListSize() {
|
|
return _galleryItemTransformInfoList.length;
|
|
}
|
|
|
|
double getItemAngle(int index) {
|
|
double angle = 360 - (index * unitAngle + 180) % 360;
|
|
return angle;
|
|
}
|
|
|
|
void updateTransformByAngle(double offsetAngle) {
|
|
baseAngleOffset -= offsetAngle;
|
|
for (int index = 0; index < _galleryItemTransformInfoList.length; index++) {
|
|
_GalleryItemTransformInfo transformInfo =
|
|
_galleryItemTransformInfoList[index];
|
|
|
|
double angle = getItemAngle(index);
|
|
double scale = transformInfo.scale;
|
|
Offset offset = transformInfo.offset;
|
|
|
|
if (baseAngleOffset.abs() > 360) {
|
|
baseAngleOffset %= 360;
|
|
}
|
|
|
|
angle += baseAngleOffset;
|
|
angle = angle % 360;
|
|
|
|
offset = calculateOffset(angle);
|
|
|
|
scale = calculateScale(angle);
|
|
|
|
transformInfo
|
|
..angle = angle
|
|
..scale = scale
|
|
..offset = offset;
|
|
}
|
|
}
|
|
|
|
void updateTransformByOffsetDx(double offsetDx) {
|
|
double offsetAngle = offsetDx / perimeter / 2 * 360;
|
|
updateTransformByAngle(offsetAngle);
|
|
}
|
|
|
|
/// Calculate Scale >>>>
|
|
double calculateScale(double angle) {
|
|
angle = angle % 360;
|
|
if (angle > 180) {
|
|
angle = 360 - angle;
|
|
}
|
|
|
|
angle += 30;
|
|
|
|
var scale = angle / 180.0;
|
|
|
|
if (scale > 1) {
|
|
scale = 1;
|
|
} else if (scale < minScale) {
|
|
scale = minScale;
|
|
}
|
|
|
|
return scale;
|
|
}
|
|
|
|
/// Calculate Offset >>>>
|
|
Offset calculateOffset(double angle) {
|
|
double width = widgetWidth * 1;
|
|
|
|
double radiusOuterX = width / 2.4;
|
|
|
|
double radiusOuterY = ellipseHeight;
|
|
|
|
double angleOuter = (4 * pi / 360) * angle;
|
|
double x = radiusOuterX * sin(angleOuter);
|
|
double y = radiusOuterY > 0 ? radiusOuterY * cos(angleOuter) : 0;
|
|
|
|
return Offset(x + (widgetWidth - itemConfig.width) / 2, -y);
|
|
}
|
|
|
|
/// Calculate Perimeter >>>>
|
|
double calculatePerimeter(double width, double height) {
|
|
var a = width;
|
|
// var a = width * 0.8;
|
|
var b = height;
|
|
return 2 * pi * b + 4 * (a - b);
|
|
}
|
|
|
|
/// Get Final Angle >>>>
|
|
double getFinalAngle(double angle) {
|
|
if (angle >= 360) {
|
|
angle -= 360;
|
|
} else if (angle < 0) {
|
|
angle += 360;
|
|
}
|
|
return angle;
|
|
}
|
|
|
|
double getOffsetAngleFormTargetIndex(int index) {
|
|
double targetItemAngle = getItemAngle(index) + baseAngleOffset;
|
|
|
|
double offsetAngle = targetItemAngle % 180;
|
|
if (targetItemAngle < 180 || targetItemAngle > 360) {
|
|
offsetAngle = offsetAngle - 180;
|
|
}
|
|
|
|
return offsetAngle;
|
|
}
|
|
|
|
void animateTo(int index) {
|
|
if (index == currentIndex) return;
|
|
vsync.animateTo(getOffsetAngleFormTargetIndex(index));
|
|
}
|
|
|
|
void jumpTo(int index) {
|
|
if (index == currentIndex) return;
|
|
vsync.jumpTo(getOffsetAngleFormTargetIndex(index));
|
|
}
|
|
}
|
|
|
|
mixin class Gallery3DMixin {
|
|
void animateTo(angle) {}
|
|
void jumpTo(angle) {}
|
|
}
|