Created
April 16, 2020 00:42
-
-
Save MedRedha/95cfdfc792ba7355d36e18dd11d81357 to your computer and use it in GitHub Desktop.
Gooey edge - @gskinner
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import 'package:flutter/material.dart'; | |
import 'package:flutter/scheduler.dart'; | |
import 'dart:math'; | |
void main() => runApp(Gooey()); | |
class Gooey extends StatelessWidget { | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
debugShowCheckedModeBanner: false, | |
home: Scaffold( | |
body: Center( | |
child: GooeyCarousel(), | |
), | |
), | |
); | |
} | |
} | |
enum Side { left, top, right, bottom } | |
class GooeyCarousel extends StatefulWidget { | |
final List<Widget> children; | |
GooeyCarousel({this.children}) : super(); | |
@override | |
GooeyCarouselState createState () => GooeyCarouselState(); | |
} | |
class GooeyCarouselState extends State<GooeyCarousel> with SingleTickerProviderStateMixin { | |
int _index = 0; // index of the base (bottom) child | |
int _dragIndex; // index of the top child | |
Offset _dragOffset; // starting offset of the drag | |
double _dragDirection; // +1 when dragging left to right, -1 for right to left | |
bool _dragCompleted; // has the drag successfully resulted in a swipe | |
Image _blueImage; | |
Image _redImage; | |
Image _yellowImage; | |
Image _blueBg; | |
Image _redBg; | |
Image _yellowBg; | |
GooeyEdge _edge; | |
Ticker _ticker; | |
GlobalKey _key = GlobalKey(); | |
@override | |
void initState() { | |
_edge = GooeyEdge(count: 25); | |
_ticker = createTicker(_tick)..start(); | |
_blueImage = Image.network('https://firebasestorage.googleapis.com/v0/b/vgv-flutter-vignettes.appspot.com/o/gooey_edge%2FIllustration-Blue.png?alt=media&token=7a55c1fc-0cb1-4f98-bafd-81780cd42775',); | |
_redImage = Image.network('https://firebasestorage.googleapis.com/v0/b/vgv-flutter-vignettes.appspot.com/o/gooey_edge%2FIllustration-Red.png?alt=media&token=69eef39d-b806-49c1-943c-1e5c5173859a',); | |
_yellowImage = Image.network('https://firebasestorage.googleapis.com/v0/b/vgv-flutter-vignettes.appspot.com/o/gooey_edge%2FIllustration-Yellow.png?alt=media&token=bcd5498e-8745-43a4-8938-d9fc69d58b49',); | |
_blueBg = Image.network( | |
'https://firebasestorage.googleapis.com/v0/b/vgv-flutter-vignettes.appspot.com/o/gooey_edge%2FBg-Blue.png?alt=media&token=e00eaf19-3a5f-4133-a0f7-68ab7afe95ab', | |
fit: BoxFit.cover,); | |
_yellowBg = Image.network( | |
'https://firebasestorage.googleapis.com/v0/b/vgv-flutter-vignettes.appspot.com/o/gooey_edge%2FBg-Yellow.png?alt=media&token=a012c201-a8a4-4ec2-854c-acc92c291113', | |
fit: BoxFit.cover,); | |
_redBg = Image.network( | |
'https://firebasestorage.googleapis.com/v0/b/vgv-flutter-vignettes.appspot.com/o/gooey_edge%2FBg-Red.png?alt=media&token=bc44fec1-89fd-41d3-baca-85fadad5e5f0', | |
fit: BoxFit.cover,); | |
super.initState(); | |
} | |
@override | |
void didChangeDependencies() { | |
precacheImage(_blueImage.image, context); | |
precacheImage(_yellowImage.image, context); | |
precacheImage(_redImage.image, context); | |
precacheImage(_blueBg.image, context); | |
precacheImage(_yellowBg.image, context); | |
precacheImage(_redBg.image, context); | |
super.didChangeDependencies(); | |
} | |
@override | |
void dispose() { | |
_ticker.dispose(); | |
super.dispose(); | |
} | |
void _tick(Duration duration) { | |
_edge.tick(duration); | |
setState(() {}); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return GestureDetector( | |
key: _key, | |
onPanDown: (details) => _handlePanDown(details, _getSize()), | |
onPanUpdate: (details) => _handlePanUpdate(details, _getSize()), | |
onPanEnd: (details) => _handlePanEnd(details, _getSize()), | |
child: Stack( | |
children: <Widget>[ | |
cards(_index % 3), | |
_dragIndex == null | |
? SizedBox() | |
: ClipPath( | |
child: cards(_dragIndex % 3), | |
clipBehavior: Clip.hardEdge, | |
clipper: GooeyEdgeClipper(_edge, margin: 10.0), | |
), | |
], | |
)); | |
} | |
Widget cards(int index) { | |
if (index == 0) { | |
return ContentCard( | |
index: index, | |
color: Color.fromARGB(255, 53, 101, 248), | |
image: _redImage, | |
background: _redBg, | |
); | |
} | |
if (index == 1) { | |
return ContentCard( | |
index: index, | |
color: Color.fromARGB(255, 240, 101, 79), | |
image: _blueImage, | |
background: _blueBg, | |
); | |
} | |
if (index == 2) { | |
return ContentCard( | |
index: index, | |
color: Color.fromARGB(255, 240, 147, 61), | |
image: _yellowImage, | |
background: _yellowBg, | |
); | |
} | |
return Container(); | |
} | |
Size _getSize() { | |
final RenderBox box = _key.currentContext.findRenderObject(); | |
return box.size; | |
} | |
void _handlePanDown(DragDownDetails details, Size size) { | |
if (_dragIndex != null && _dragCompleted) { | |
_index = _dragIndex; | |
} | |
_dragIndex = null; | |
_dragOffset = details.localPosition; | |
_dragCompleted = false; | |
_dragDirection = 0; | |
_edge.farEdgeTension = 0.0; | |
_edge.edgeTension = 0.01; | |
_edge.reset(); | |
} | |
void _handlePanUpdate(DragUpdateDetails details, Size size) { | |
double dx = details.globalPosition.dx - _dragOffset.dx; | |
if (!_isSwipeActive(dx)) { | |
return; | |
} | |
if (_isSwipeComplete(dx, size.width)) { | |
return; | |
} | |
if (_dragDirection == -1) { | |
dx = size.width + dx; | |
} | |
_edge.applyTouchOffset(Offset(dx, details.localPosition.dy), size); | |
} | |
bool _isSwipeActive(double dx) { | |
// check if a swipe is just starting: | |
if (_dragDirection == 0.0 && dx.abs() > 20.0) { | |
_dragDirection = dx.sign; | |
_edge.side = _dragDirection == 1.0 ? Side.left : Side.right; | |
setState(() { | |
_dragIndex = _index - _dragDirection.toInt(); | |
}); | |
} | |
return _dragDirection != 0.0; | |
} | |
bool _isSwipeComplete(double dx, double width) { | |
if (_dragDirection == 0.0) { | |
return false; | |
} // haven't started | |
if (_dragCompleted) { | |
return true; | |
} // already done | |
// check if swipe is just completed: | |
double availW = _dragOffset.dx; | |
if (_dragDirection == 1) { | |
availW = width - availW; | |
} | |
double ratio = dx * _dragDirection / availW; | |
if (ratio > 0.8 && availW / width > 0.5) { | |
_dragCompleted = true; | |
_edge.farEdgeTension = 0.01; | |
_edge.edgeTension = 0.0; | |
_edge.applyTouchOffset(); | |
} | |
return _dragCompleted; | |
} | |
void _handlePanEnd(DragEndDetails details, Size size) { | |
_edge.applyTouchOffset(); | |
} | |
} | |
class ContentCard extends StatefulWidget { | |
final Color color; | |
final int index; | |
final Widget image; | |
final Widget background; | |
ContentCard({this.color, this.index, this.image, this.background}) : super(); | |
@override | |
_ContentCardState createState() => _ContentCardState(); | |
} | |
class _ContentCardState extends State<ContentCard> { | |
Ticker _ticker; | |
@override | |
void initState() { | |
_ticker = Ticker((d) { | |
setState(() {}); | |
}) | |
..start(); | |
super.initState(); | |
} | |
@override | |
void dispose() { | |
_ticker.dispose(); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
var size = MediaQuery.of(context).size; | |
var time = DateTime.now().millisecondsSinceEpoch / 2000; | |
var scaleX = 1.2 + sin(time) * .05; | |
var scaleY = 1.2 + cos(time) * .07; | |
var offsetY = 20 + cos(time) * 20; | |
return Stack( | |
alignment: Alignment.center, | |
fit: StackFit.expand, | |
children: <Widget>[ | |
Transform( | |
transform: Matrix4.diagonal3Values(scaleX, scaleY, 1), | |
child: Transform.translate( | |
offset: Offset(-(scaleX - 1) / 2 * size.width, -(scaleY - 1) / 2 * size.height + offsetY), | |
child: widget.background, | |
), | |
), | |
Container( | |
child: Column( | |
mainAxisAlignment: MainAxisAlignment.center, | |
children: <Widget>[ | |
Expanded( | |
child: Container( | |
child: widget.image, | |
padding: EdgeInsets.symmetric(horizontal: 20), | |
)), | |
_buildPageIndicator(this.widget.index), | |
], | |
)) | |
], | |
); | |
} | |
Widget _buildPageIndicator(int index) { | |
return Padding( | |
padding: const EdgeInsets.all(32.0), | |
child: Row( | |
mainAxisAlignment: MainAxisAlignment.center, | |
children: <Widget>[ | |
Expanded( | |
child: Row( | |
mainAxisAlignment: MainAxisAlignment.start, | |
children: <Widget>[ | |
Text("SWIPE", | |
style: TextStyle(color: Colors.white) | |
), | |
Icon(Icons.arrow_forward, | |
color: Colors.white, | |
), | |
] | |
), | |
), | |
_indicator(0), | |
SizedBox( | |
width: 10, | |
), | |
_indicator(1), | |
SizedBox( | |
width: 10, | |
), | |
_indicator(2), | |
Expanded( | |
child: Row( | |
mainAxisAlignment: MainAxisAlignment.end, | |
children: <Widget>[ | |
Icon(Icons.arrow_back, | |
color: Colors.white, | |
), | |
Text("SWIPE", | |
style: TextStyle(color: Colors.white) | |
), | |
] | |
), | |
), | |
], | |
) | |
); | |
} | |
Widget _indicator(int idx) { | |
BoxDecoration _selected = | |
BoxDecoration(color: Colors.white, shape: BoxShape.circle); | |
BoxDecoration _unselected = BoxDecoration( | |
border: Border.all(color: Colors.white), | |
shape: BoxShape.circle, | |
); | |
return Container( | |
decoration: this.widget.index == idx ? _selected : _unselected, | |
height: 30, | |
width: 30, | |
// width: 30, | |
); | |
} | |
} | |
class GooeyEdge { | |
List<_GooeyPoint> points; | |
Side side; | |
double edgeTension = 0.01; | |
double farEdgeTension = 0.0; | |
double touchTension = 0.1; | |
double pointTension = 0.25; | |
double damping = 0.9; | |
double maxTouchDistance = 0.15; | |
int lastT = 0; | |
FractionalOffset touchOffset; | |
GooeyEdge({count = 10, this.side = Side.left}) { | |
points = []; | |
for (int i = 0; i < count; i++) { | |
points.add(_GooeyPoint(0.0, i / (count - 1))); | |
} | |
} | |
void reset() { | |
points.forEach((pt) => pt.x = pt.velX = pt.velY = 0.0); | |
} | |
void applyTouchOffset([Offset offset, Size size]) { | |
if (offset == null) { | |
touchOffset = null; | |
return; | |
} | |
FractionalOffset o = FractionalOffset.fromOffsetAndSize(offset, size); | |
if (side == Side.left) { | |
touchOffset = o; | |
} else if (side == Side.right) { | |
touchOffset = FractionalOffset(1.0 - o.dx, 1.0 - o.dy); | |
} else if (side == Side.top) { | |
touchOffset = FractionalOffset(o.dy, 1.0 - o.dx); | |
} else { | |
touchOffset = FractionalOffset(1.0 - o.dy, o.dx); | |
} | |
} | |
Path buildPath(Size size, {double margin = 0.0}) { | |
if (points == null || points.length == 0) { | |
return null; | |
} | |
Matrix4 mtx = _getTransform(size, margin); | |
Path path = Path(); | |
int l = points.length; | |
Offset pt = _GooeyPoint(-margin, 1.0).toOffset(mtx), pt1; | |
path.moveTo(pt.dx, pt.dy); // bl | |
pt = _GooeyPoint(-margin, 0.0).toOffset(mtx); | |
path.lineTo(pt.dx, pt.dy); // tl | |
pt = points[0].toOffset(mtx); | |
path.lineTo(pt.dx, pt.dy); // tr | |
pt1 = points[1].toOffset(mtx); | |
path.lineTo(pt.dx + (pt1.dx - pt.dx) / 2, pt.dy + (pt1.dy - pt.dy) / 2); | |
for (int i = 2; i < l; i++) { | |
pt = pt1; | |
pt1 = points[i].toOffset(mtx); | |
double midX = pt.dx + (pt1.dx - pt.dx) / 2; | |
double midY = pt.dy + (pt1.dy - pt.dy) / 2; | |
path.quadraticBezierTo(pt.dx, pt.dy, midX, midY); | |
} | |
path.lineTo(pt1.dx, pt1.dy); // br | |
path.close(); // bl | |
return path; | |
} | |
void tick(Duration duration) { | |
if (points == null || points.length == 0) { | |
return; | |
} | |
int l = points.length; | |
double t = min(1.5, (duration.inMilliseconds - lastT) / 1000 * 60); | |
lastT = duration.inMilliseconds; | |
double dampingT = pow(damping, t); | |
for (int i = 0; i < l; i++) { | |
_GooeyPoint pt = points[i]; | |
pt.velX -= pt.x * edgeTension * t; | |
pt.velX += (1.0 - pt.x) * farEdgeTension * t; | |
if (touchOffset != null) { | |
double ratio = | |
max(0.0, 1.0 - (pt.y - touchOffset.dy).abs() / maxTouchDistance); | |
pt.velX += (touchOffset.dx - pt.x) * touchTension * ratio * t; | |
} | |
if (i > 0) { | |
_addPointTension(pt, points[i - 1].x, t); | |
} | |
if (i < l - 1) { | |
_addPointTension(pt, points[i + 1].x, t); | |
} | |
pt.velX *= dampingT; | |
} | |
for (int i = 0; i < l; i++) { | |
_GooeyPoint pt = points[i]; | |
pt.x += pt.velX * t; | |
} | |
} | |
Matrix4 _getTransform(Size size, double margin) { | |
bool vertical = side == Side.top || side == Side.bottom; | |
double w = (vertical ? size.height : size.width) + margin * 2; | |
double h = (vertical ? size.width : size.height) + margin * 2; | |
Matrix4 mtx = Matrix4.identity() | |
..translate(-margin, 0.0) | |
..scale(w, h); | |
if (side == Side.top) { | |
mtx | |
..rotateZ(pi / 2) | |
..translate(0.0, -1.0); | |
} else if (side == Side.right) { | |
mtx | |
..rotateZ(pi) | |
..translate(-1.0, -1.0); | |
} else if (side == Side.bottom) { | |
mtx | |
..rotateZ(pi * 3 / 2) | |
..translate(-1.0, 0.0); | |
} | |
return mtx; | |
} | |
void _addPointTension(_GooeyPoint pt0, double x, double t) { | |
pt0.velX += (x - pt0.x) * pointTension * t; | |
} | |
} | |
class _GooeyPoint { | |
double x; | |
double y; | |
double velX = 0.0; | |
double velY = 0.0; | |
_GooeyPoint([this.x = 0.0, this.y = 0.0]); | |
Offset toOffset([Matrix4 transform]) { | |
Offset o = Offset(x, y); | |
if (transform == null) { | |
return o; | |
} | |
return MatrixUtils.transformPoint(transform, o); | |
} | |
} | |
class GooeyEdgeClipper extends CustomClipper<Path> { | |
GooeyEdge edge; | |
double margin; | |
GooeyEdgeClipper(this.edge, {this.margin = 0.0}) : super(); | |
@override | |
Path getClip(Size size) { | |
return edge.buildPath(size, margin: margin); | |
} | |
@override | |
bool shouldReclip(CustomClipper<Path> oldClipper) { | |
return true; | |
} | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment