diff --git a/.gitignore b/.gitignore index 07488ba..3a6378f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ .pub-cache/ .pub/ /build/ +/example/ios/Flutter/ +/example/pubspec.lock +pubspec.lock # Android related **/android/**/gradle-wrapper.jar diff --git a/example/android/gradle.properties b/example/android/gradle.properties index 8bd86f6..7be3d8b 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -1 +1,2 @@ org.gradle.jvmargs=-Xmx1536M +android.enableR8=true diff --git a/example/ios/Flutter/flutter_export_environment.sh b/example/ios/Flutter/flutter_export_environment.sh new file mode 100755 index 0000000..ec4b1f9 --- /dev/null +++ b/example/ios/Flutter/flutter_export_environment.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# This is a generated file; do not edit or check into version control. +export "FLUTTER_ROOT=/Users/macserverm1/Development/flutter" +export "FLUTTER_APPLICATION_PATH=/Users/macserverm1/Development/github_crmall/spinning_wheel_caio/example" +export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_TARGET=lib/main.dart" +export "FLUTTER_BUILD_DIR=build" +export "FLUTTER_BUILD_NAME=1.0.0" +export "FLUTTER_BUILD_NUMBER=1" +export "DART_OBFUSCATION=false" +export "TRACK_WIDGET_CREATION=false" +export "TREE_SHAKE_ICONS=false" +export "PACKAGE_CONFIG=.dart_tool/package_config.json" diff --git a/example/lib/main.dart b/example/lib/main.dart index f3c8582..2647a63 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'dart:math'; import 'package:flutter/material.dart'; @@ -6,7 +7,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_spinning_wheel/flutter_spinning_wheel.dart'; void main() { - SystemChrome.setEnabledSystemUIOverlays([]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); runApp(MyApp()); } @@ -61,11 +62,13 @@ class MyHomePage extends StatelessWidget { } Widget buildNavigationButton({String text, Function onPressedFn}) { - return FlatButton( - color: Color.fromRGBO(255, 255, 255, 0.3), - textColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(50.0), + return TextButton( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all(Color.fromRGBO(255, 255, 255, 0.3)), + foregroundColor: MaterialStateProperty.all(Colors.white), + shape: MaterialStateProperty.all(RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50.0), + )), ), onPressed: onPressedFn, child: Text( @@ -76,8 +79,66 @@ class MyHomePage extends StatelessWidget { } } +enum CircleAlignment { + topLeft, + topRight, + bottomLeft, + bottomRight, +} + +class QuarterCircle extends StatelessWidget { + final CircleAlignment circleAlignment; + final Color color; + + const QuarterCircle({ + this.color = Colors.grey, + this.circleAlignment = CircleAlignment.topLeft, + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox.expand( + child: ClipRect( + child: CustomPaint( + painter: QuarterCirclePainter( + circleAlignment: circleAlignment, + color: color, + ), + ), + ), + ); + } +} + +class QuarterCirclePainter extends CustomPainter { + final CircleAlignment circleAlignment; + final Color color; + + const QuarterCirclePainter({this.circleAlignment, this.color}); + + @override + void paint(Canvas canvas, Size size) { + final radius = math.min(size.height, size.width); + final offset = circleAlignment == CircleAlignment.topLeft + ? Offset(.0, .0) + : circleAlignment == CircleAlignment.topRight + ? Offset(size.width, .0) + : circleAlignment == CircleAlignment.bottomLeft + ? Offset(.0, size.height) + : Offset(size.width, size.height); + canvas.drawCircle(offset, radius, Paint()..color = color); + } + + @override + bool shouldRepaint(QuarterCirclePainter oldDelegate) { + return color == oldDelegate.color && circleAlignment == oldDelegate.circleAlignment; + } +} + class Basic extends StatelessWidget { final StreamController _dividerController = StreamController(); + final SpinningWheelController _spinController = SpinningWheelController(); dispose() { _dividerController.close(); @@ -88,32 +149,58 @@ class Basic extends StatelessWidget { return Scaffold( appBar: AppBar(backgroundColor: Color(0xffB0F9D2), elevation: 0.0), backgroundColor: Color(0xffB0F9D2), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SpinningWheel( - Image.asset('assets/images/wheel-6-300.png'), - width: 310, - height: 310, - initialSpinAngle: _generateRandomAngle(), - spinResistance: 0.2, - dividers: 6, - onUpdate: _dividerController.add, - onEnd: _dividerController.add, + body: GestureDetector( + onTap: () => _spinController.spin(4000), + child: Center( + child: Container( + width: 310, + height: 310, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SpinningWheel.custom( + children: List.generate( + 4, + (index) => Container( + width: 110, + height: 110, + color: Color.fromRGBO(math.Random().nextInt(255), math.Random().nextInt(255), math.Random().nextInt(255), 1), + child: Text( + (index + 1).toString(), + textAlign: TextAlign.center, + style: TextStyle(fontSize: 30, backgroundColor: Colors.white), + ), + ), + ), + controller: _spinController, + width: 310, + height: 310, + // initialSpinAngle: _generateRandomAngle(), + spinResistance: 0.2, + onUpdate: _dividerController.add, + onEnd: _dividerController.add, + ), + // SpinningWheel( + // child: Image.asset('assets/images/wheel-6-300.png'), + // width: 310, + // height: 310, + // // initialSpinAngle: _generateRandomAngle(), + // spinResistance: 0.2, + // dividers: 6, + // onUpdate: _dividerController.add, + // onEnd: _dividerController.add, + // ), + StreamBuilder( + stream: _dividerController.stream, + builder: (context, snapshot) => snapshot.hasData ? BasicScore(snapshot.data) : Container(), + ), + ], ), - StreamBuilder( - stream: _dividerController.stream, - builder: (context, snapshot) => - snapshot.hasData ? BasicScore(snapshot.data) : Container(), - ) - ], + ), ), ), ); } - - double _generateRandomAngle() => Random().nextDouble() * pi * 2; } class BasicScore extends StatelessWidget { @@ -132,19 +219,17 @@ class BasicScore extends StatelessWidget { @override Widget build(BuildContext context) { - return Text('${labels[selected]}', - style: TextStyle(fontStyle: FontStyle.italic)); + // return Text('${selected.toString()}', style: TextStyle(fontStyle: FontStyle.italic)); + return Text('${selected.toString()} - ${labels[selected]}', style: TextStyle(fontStyle: FontStyle.italic)); } } class Roulette extends StatelessWidget { final StreamController _dividerController = StreamController(); - - final _wheelNotifier = StreamController(); + final SpinningWheelController _spinController = SpinningWheelController(); dispose() { _dividerController.close(); - _wheelNotifier.close(); } @override @@ -153,38 +238,39 @@ class Roulette extends StatelessWidget { appBar: AppBar(backgroundColor: Color(0xffDDC3FF), elevation: 0.0), backgroundColor: Color(0xffDDC3FF), body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SpinningWheel( - Image.asset('assets/images/roulette-8-300.png'), - width: 310, - height: 310, - initialSpinAngle: _generateRandomAngle(), - spinResistance: 0.6, - canInteractWhileSpinning: false, - dividers: 8, - onUpdate: _dividerController.add, - onEnd: _dividerController.add, - secondaryImage: - Image.asset('assets/images/roulette-center-300.png'), - secondaryImageHeight: 110, - secondaryImageWidth: 110, - shouldStartOrStop: _wheelNotifier.stream, - ), - SizedBox(height: 30), - StreamBuilder( - stream: _dividerController.stream, - builder: (context, snapshot) => - snapshot.hasData ? RouletteScore(snapshot.data) : Container(), - ), - SizedBox(height: 30), - new RaisedButton( - child: new Text("Start"), - onPressed: () => - _wheelNotifier.sink.add(_generateRandomVelocity()), - ) - ], + child: Container( + width: 310, + height: 310, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SpinningWheel( + child: Image.asset('assets/images/roulette-8-300.png'), + width: 310, + height: 310, + initialSpinAngle: _generateRandomAngle(), + spinResistance: 0.6, + canInteractWhileSpinning: false, + dividers: 8, + onUpdate: _dividerController.add, + onEnd: _dividerController.add, + secondaryImage: Image.asset('assets/images/roulette-center-300.png'), + secondaryImageHeight: 110, + secondaryImageWidth: 110, + controller: _spinController, + ), + SizedBox(height: 30), + StreamBuilder( + stream: _dividerController.stream, + builder: (context, snapshot) => snapshot.hasData ? RouletteScore(snapshot.data) : Container(), + ), + SizedBox(height: 30), + new ElevatedButton( + child: new Text("Start"), + onPressed: () => _spinController.spin(_generateRandomVelocity()), + ) + ], + ), ), ), ); @@ -213,7 +299,6 @@ class RouletteScore extends StatelessWidget { @override Widget build(BuildContext context) { - return Text('${labels[selected]}', - style: TextStyle(fontStyle: FontStyle.italic, fontSize: 24.0)); + return Text('${labels[selected]}', style: TextStyle(fontStyle: FontStyle.italic, fontSize: 24.0)); } } diff --git a/example/pubspec.lock b/example/pubspec.lock index 889ba8c..24ff737 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -1,34 +1,54 @@ # Generated by pub -# See https://www.dartlang.org/tools/pub/glossary#lockfile +# See https://dart.dev/tools/pub/glossary#lockfile packages: async: dependency: transitive description: name: async - url: "https://pub.dartlang.org" + sha256: bfe67ef28df125b7dddcea62755991f807aa39a2492a23e1550161692950bbe0 + url: "https://pub.dev" source: hosted - version: "2.0.8" + version: "2.10.0" boolean_selector: dependency: transitive description: name: boolean_selector - url: "https://pub.dartlang.org" + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" source: hosted - version: "1.0.4" - charcode: + version: "2.1.1" + characters: dependency: transitive description: - name: charcode - url: "https://pub.dartlang.org" + name: characters + sha256: e6a326c8af69605aec75ed6c187d06b349707a27fbff8222ca9cc2cff167975c + url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.2.1" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" collection: dependency: transitive description: name: collection - url: "https://pub.dartlang.org" + sha256: cfc915e6923fe5ce6e153b0723c753045de46de1b4d63771530504004a45fae0 + url: "https://pub.dev" + source: hosted + version: "1.17.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" source: hosted - version: "1.14.11" + version: "1.3.1" flutter: dependency: "direct main" description: flutter @@ -46,41 +66,46 @@ packages: description: flutter source: sdk version: "0.0.0" - matcher: + js: dependency: transitive description: - name: matcher - url: "https://pub.dartlang.org" + name: js + sha256: "5528c2f391ededb7775ec1daa69e65a2d61276f7552de2b5f7b8d34ee9fd4ab7" + url: "https://pub.dev" source: hosted - version: "0.12.3+1" - meta: + version: "0.6.5" + matcher: dependency: transitive description: - name: meta - url: "https://pub.dartlang.org" + name: matcher + sha256: "16db949ceee371e9b99d22f88fa3a73c4e59fd0afed0bd25fc336eb76c198b72" + url: "https://pub.dev" source: hosted - version: "1.1.6" - path: + version: "0.12.13" + material_color_utilities: dependency: transitive description: - name: path - url: "https://pub.dartlang.org" + name: material_color_utilities + sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 + url: "https://pub.dev" source: hosted - version: "1.6.2" - pedantic: + version: "0.2.0" + meta: dependency: transitive description: - name: pedantic - url: "https://pub.dartlang.org" + name: meta + sha256: "6c268b42ed578a53088d834796959e4a1814b5e9e164f147f580a386e5decf42" + url: "https://pub.dev" source: hosted - version: "1.4.0" - quiver: + version: "1.8.0" + path: dependency: transitive description: - name: quiver - url: "https://pub.dartlang.org" + name: path + sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b + url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "1.8.2" sky_engine: dependency: transitive description: flutter @@ -90,57 +115,57 @@ packages: dependency: transitive description: name: source_span - url: "https://pub.dartlang.org" + sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + url: "https://pub.dev" source: hosted - version: "1.5.4" + version: "1.9.1" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.dartlang.org" + sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + url: "https://pub.dev" source: hosted - version: "1.9.3" + version: "1.11.0" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.dartlang.org" + sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + url: "https://pub.dev" source: hosted - version: "1.6.8" + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - url: "https://pub.dartlang.org" + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.2.0" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.dartlang.org" + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.1" test_api: dependency: transitive description: name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.2" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" + sha256: ad540f65f92caa91bf21dfc8ffb8c589d6e4dc0c2267818b4cc2792857706206 + url: "https://pub.dev" source: hosted - version: "1.1.6" + version: "0.4.16" vector_math: dependency: transitive description: name: vector_math - url: "https://pub.dartlang.org" + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" source: hosted - version: "2.0.8" + version: "2.1.4" sdks: - dart: ">=2.1.0 <3.0.0" + dart: ">=2.18.0 <3.0.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 544d234..e88e199 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -4,7 +4,7 @@ description: A new Flutter project. version: 1.0.0+1 environment: - sdk: ">=2.1.0 <3.0.0" + sdk: ">=2.12.0 <3.0.0" dependencies: flutter: diff --git a/lib/flutter_spinning_wheel.dart b/lib/flutter_spinning_wheel.dart index 5e82d55..8f6ad5d 100644 --- a/lib/flutter_spinning_wheel.dart +++ b/lib/flutter_spinning_wheel.dart @@ -1,3 +1,4 @@ library flutter_spinning_wheel; +export 'src/pie.dart'; export 'src/spinning_wheel.dart'; diff --git a/lib/src/pie.dart b/lib/src/pie.dart new file mode 100644 index 0000000..87b1b3a --- /dev/null +++ b/lib/src/pie.dart @@ -0,0 +1,105 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; + +class Pie extends StatelessWidget { + final List children; + + const Pie({Key? key, required this.children}) : super(key: key); + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (context, consts) { + final emptyPie = (children.length) == 0; + final hasPieces = (children.length) > 1; + final size = math.min(consts.maxWidth, consts.maxHeight); + return Center( + child: Container( + width: size, + height: size, + clipBehavior: hasPieces ? Clip.none : Clip.antiAlias, + decoration: hasPieces ? null : BoxDecoration(shape: BoxShape.circle), + child: hasPieces + ? Stack( + clipBehavior: Clip.none, + children: [ + for (var i = 0; i < children.length; i++) + Transform.rotate( + angle: (360 / children.length * (i + 0.5)) * math.pi / 180, + origin: Offset.fromDirection(math.pi / 2, consts.maxWidth / 4), + child: AspectRatio( + aspectRatio: 2 / 1, + child: PiePiece( + pieces: children.length, + child: children[i], + ), + ), + ), + ], + ) + : emptyPie + ? null + : children[0], + ), + ); + }); + } +} + +class PiePiece extends StatelessWidget { + final Widget child; + final int pieces; + + const PiePiece({ + Key? key, + required this.child, + this.pieces = 2, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + var clipper = PieClipper(pieces); + return ClipPath( + clipper: clipper, + child: child, + ); + } +} + +class PieClipper extends CustomClipper { + PieClipper(this.pieces); + + final int pieces; + + @override + Path getClip(Size size) { + final radius = size.width / 2; + + var pathArc = Path(); + pathArc.moveTo(.0, size.height); + pathArc.arcToPoint(Offset(size.width, size.height), radius: Radius.circular(radius)); + pathArc.lineTo(0, size.height); + pathArc.close(); + + if (pieces > 2) { + var pathTriangle = Path(); + final radians = (2 * math.pi) / pieces; + final distanceFromCenter = radius * math.tan(radians / 2); + pathTriangle.moveTo(radius - distanceFromCenter, size.height - radius); + pathTriangle.lineTo(radius + distanceFromCenter, size.height - radius); + pathTriangle.lineTo(size.width * 0.5, size.height); + pathTriangle.close(); + + return Path.combine(PathOperation.intersect, pathArc, pathTriangle); + } + + return pathArc; + } + + @override + bool shouldReclip(covariant CustomClipper oldClipper) { + final oldie = oldClipper as PieClipper; + return pieces != oldie.pieces; + } +} diff --git a/lib/src/spinning_wheel.dart b/lib/src/spinning_wheel.dart index 750226f..a14ee01 100644 --- a/lib/src/spinning_wheel.dart +++ b/lib/src/spinning_wheel.dart @@ -2,8 +2,11 @@ import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:flutter_spinning_wheel/src/pie.dart'; import 'package:flutter_spinning_wheel/src/utils.dart'; +typedef SpinningWheelCallback = void Function(int currentDivider); + /// Returns a widget which displays a rotating image. /// This widget can be interacted with with drag gestures and could be used as a "fortune wheel". /// @@ -17,8 +20,8 @@ class SpinningWheel extends StatefulWidget { /// height used by the container with the image final double height; - /// image that will be used as wheel - final Image image; + /// widget that will be used as wheel + final Widget child; /// number of equal divisions in the wheel final int dividers; @@ -36,41 +39,80 @@ class SpinningWheel extends StatefulWidget { final bool canInteractWhileSpinning; /// will be rendered on top of the wheel and can be used to show a selector - final Image secondaryImage; + final Image? secondaryImage; /// x dimension for the secondaty image, if provided /// if provided, has to be smaller than widget height - final double secondaryImageHeight; + final double? secondaryImageHeight; /// y dimension for the secondary image, if provided /// if provided, has to be smaller than widget width - final double secondaryImageWidth; + final double? secondaryImageWidth; /// can be used to fine tune the position for the secondary image, otherwise it will be centered - final double secondaryImageTop; + final double? secondaryImageTop; /// can be used to fine tune the position for the secondary image, otherwise it will be centered - final double secondaryImageLeft; + final double? secondaryImageLeft; /// callback function to be executed when the wheel selection changes - final Function onUpdate; + final SpinningWheelCallback? onUpdate; /// callback function to be executed when the animation stops - final Function onEnd; + final SpinningWheelCallback? onEnd; /// Stream used to trigger an animation /// if triggered in an animation it will stop it, unless canInteractWhileSpinning is false /// the parameter is a double for pixelsPerSecond in axis Y, which defaults to 8000.0 as a medium-high velocity - final Stream shouldStartOrStop; - - SpinningWheel( - this.image, { - @required this.width, - @required this.height, - @required this.dividers, - this.initialSpinAngle: 0.0, - this.spinResistance: 0.5, - this.canInteractWhileSpinning: true, + + final SpinningWheelController? controller; + + SpinningWheel.custom({ + required List children, + required double width, + required double height, + SpinningWheelController? controller, + double initialSpinAngle = 0.0, + double spinResistance = 0.5, + bool? canInteractWhileSpinning, + Image? secondaryImage, + double? secondaryImageHeight, + double? secondaryImageWidth, + double? secondaryImageTop, + double? secondaryImageLeft, + SpinningWheelCallback? onUpdate, + SpinningWheelCallback? onEnd, + // Stream shouldStartOrStop, + }) : this( + child: Pie( + children: children, + ), + controller: controller, + width: width, + height: height, + dividers: children.length, + initialSpinAngle: initialSpinAngle, + spinResistance: spinResistance, + canInteractWhileSpinning: canInteractWhileSpinning ?? true, + secondaryImage: secondaryImage, + secondaryImageHeight: secondaryImageHeight, + secondaryImageWidth: secondaryImageWidth, + secondaryImageTop: secondaryImageTop, + secondaryImageLeft: secondaryImageLeft, + onUpdate: onUpdate, + onEnd: onEnd, + // shouldStartOrStop: shouldStartOrStop, + ); + + SpinningWheel({ + required this.child, + required this.width, + required this.height, + required this.dividers, + this.controller, + this.initialSpinAngle = 0.0, + this.spinResistance = 0.5, + this.canInteractWhileSpinning = true, this.secondaryImage, this.secondaryImageHeight, this.secondaryImageWidth, @@ -78,32 +120,30 @@ class SpinningWheel extends StatefulWidget { this.secondaryImageLeft, this.onUpdate, this.onEnd, - this.shouldStartOrStop, + // this.shouldStartOrStop, }) : assert(width > 0.0 && height > 0.0), assert(spinResistance > 0.0 && spinResistance <= 1.0), assert(initialSpinAngle >= 0.0 && initialSpinAngle <= (2 * pi)), - assert(secondaryImage == null || - (secondaryImageHeight <= height && secondaryImageWidth <= width)); + assert(secondaryImage == null || secondaryImageHeight == null || secondaryImageWidth == null || (secondaryImageHeight <= height && secondaryImageWidth <= width)); @override _SpinningWheelState createState() => _SpinningWheelState(); } -class _SpinningWheelState extends State - with SingleTickerProviderStateMixin { - AnimationController _animationController; - Animation _animation; +class _SpinningWheelState extends State with SingleTickerProviderStateMixin { + late Animation _animation; + late AnimationController _animationController; // we need to store if has the widget behaves differently depending on the status // AnimationStatus _animationStatus = AnimationStatus.dismissed; // it helps calculating the velocity based on position and pixels per second velocity and angle - SpinVelocity _spinVelocity; - NonUniformCircularMotion _motion; + late SpinVelocity _spinVelocity; + late NonUniformCircularMotion _motion; // keeps the last local position on pan update // we need it onPanEnd to calculate in which cuadrant the user was when last dragged - Offset _localPositionOnPanUpdate; + Offset? _localPositionOnPanUpdate; // duration of the animation based on the initial velocity double _totalDuration = 0; @@ -112,28 +152,28 @@ class _SpinningWheelState extends State double _initialCircularVelocity = 0; // angle for each divider: 2*pi / numberOfDividers - double _dividerAngle; + late double _dividerAngle; // current (circular) distance (angle) covered during the animation double _currentDistance = 0; // initial spin angle when the wheels starts the animation - double _initialSpinAngle; + late double _initialSpinAngle; // dividider which is selected (positive y-coord) - int _currentDivider; + int? _currentDivider; // spining backwards - bool _isBackwards; + bool? _isBackwards; // if the user drags outside the wheel, won't be able to get back in - DateTime _offsetOutsideTimestamp; + DateTime? _offsetOutsideTimestamp; // will be used to do transformations between global and local - RenderBox _renderBox; + RenderBox? _renderBox; // subscription to the stream used to trigger an animation - StreamSubscription _subscription; + StreamSubscription? _subscription; @override void initState() { @@ -146,8 +186,7 @@ class _SpinningWheelState extends State vsync: this, duration: Duration(seconds: 0), ); - _animation = Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: _animationController, curve: Curves.linear)); + _animation = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOut)); _dividerAngle = _motion.anglePerDivision(widget.dividers); _initialSpinAngle = widget.initialSpinAngle; @@ -157,9 +196,7 @@ class _SpinningWheelState extends State if (status == AnimationStatus.completed) _stopAnimation(); }); - if (widget.shouldStartOrStop != null) { - _subscription = widget.shouldStartOrStop.listen(_startOrStop); - } + widget.controller?._attach(this); } _startOrStop(dynamic velocity) { @@ -174,41 +211,39 @@ class _SpinningWheelState extends State } } - double get topSecondaryImage => - widget.secondaryImageTop ?? - (widget.height / 2) - (widget.secondaryImageHeight / 2); + double? get topSecondaryImage => widget.secondaryImageTop ?? (widget.secondaryImageHeight != null ? (widget.height / 2) - (widget.secondaryImageHeight! / 2) : null); - double get leftSecondaryImage => - widget.secondaryImageLeft ?? - (widget.width / 2) - (widget.secondaryImageWidth / 2); + double? get leftSecondaryImage => widget.secondaryImageLeft ?? (widget.secondaryImageWidth != null ? (widget.width / 2) - (widget.secondaryImageWidth! / 2) : null); - double get widthSecondaryImage => widget.secondaryImageWidth ?? widget.width; + double? get widthSecondaryImage => widget.secondaryImageWidth ?? widget.width; - double get heightSecondaryImage => - widget.secondaryImageHeight ?? widget.height; + double? get heightSecondaryImage => widget.secondaryImageHeight ?? widget.height; @override Widget build(BuildContext context) { return Container( - height: widget.height, - width: widget.width, + // height: widget.height, + // width: widget.width, child: Stack( + clipBehavior: Clip.none, children: [ - GestureDetector( - onPanUpdate: _moveWheel, - onPanEnd: _startAnimationOnPanEnd, - onPanDown: (_details) => _stopAnimation(), - child: AnimatedBuilder( - animation: _animation, - child: Container(child: widget.image), - builder: (context, child) { - _updateAnimationValues(); - widget.onUpdate(_currentDivider); - return Transform.rotate( - angle: _initialSpinAngle + _currentDistance, - child: child, - ); - }), + Positioned.fill( + child: GestureDetector( + onPanUpdate: _moveWheel, + onPanEnd: _startAnimationOnPanEnd, + onPanDown: (_details) => _stopAnimation(), + child: AnimatedBuilder( + animation: _animation, + child: widget.child, + builder: (context, child) { + _updateAnimationValues(); + if (widget.onUpdate != null && _currentDivider != null) widget.onUpdate!(_currentDivider!); + return Transform.rotate( + angle: _initialSpinAngle + _currentDistance, + child: child, + ); + }), + ), ), widget.secondaryImage != null ? Positioned( @@ -226,15 +261,17 @@ class _SpinningWheelState extends State } // user can interact only if widget allows or wheel is not spinning - bool get _userCanInteract => - !_animationController.isAnimating || widget.canInteractWhileSpinning; + bool get _userCanInteract => !_animationController.isAnimating || widget.canInteractWhileSpinning; // transforms from global coordinates to local and store the value void _updateLocalPosition(Offset position) { if (_renderBox == null) { - _renderBox = context.findRenderObject(); + final renderObject = context.findRenderObject(); + if (renderObject is RenderBox) { + _renderBox = renderObject; + } } - _localPositionOnPanUpdate = _renderBox.globalToLocal(position); + _localPositionOnPanUpdate = _renderBox?.globalToLocal(position); } /// returns true if (x,y) is outside the boundaries from size @@ -245,15 +282,14 @@ class _SpinningWheelState extends State if (_animationController.isAnimating) { // calculate total distance covered var currentTime = _totalDuration * _animation.value; - _currentDistance = - _motion.distance(_initialCircularVelocity, currentTime); - if (_isBackwards) { + _currentDistance = _motion.distance(_initialCircularVelocity, currentTime); + if (_isBackwards == true) { _currentDistance = -_currentDistance; } } // calculate current divider selected var modulo = _motion.modulo(_currentDistance + _initialSpinAngle); - _currentDivider = widget.dividers - (modulo ~/ _dividerAngle); + _currentDivider = (widget.dividers - (modulo ~/ _dividerAngle)).toInt(); if (_animationController.isCompleted) { _initialSpinAngle = modulo; _currentDistance = 0; @@ -268,10 +304,10 @@ class _SpinningWheelState extends State _updateLocalPosition(details.globalPosition); - if (_contains(_localPositionOnPanUpdate)) { + if (_localPositionOnPanUpdate != null && _contains(_localPositionOnPanUpdate!)) { // we need to update the rotation // so, calculate the new rotation angle and rebuild the widget - var angle = _spinVelocity.offsetToRadians(_localPositionOnPanUpdate); + var angle = _spinVelocity.offsetToRadians(_localPositionOnPanUpdate!); setState(() { // initialSpinAngle will be added later on build _currentDistance = angle - _initialSpinAngle; @@ -290,14 +326,14 @@ class _SpinningWheelState extends State _animationController.stop(); _animationController.reset(); - widget.onEnd(_currentDivider); + if (widget.onEnd != null && _currentDivider != null) widget.onEnd!(_currentDivider!); } void _startAnimationOnPanEnd(DragEndDetails details) { if (!_userCanInteract) return; if (_offsetOutsideTimestamp != null) { - var difference = DateTime.now().difference(_offsetOutsideTimestamp); + var difference = DateTime.now().difference(_offsetOutsideTimestamp!); _offsetOutsideTimestamp = null; // if more than 50 seconds passed since user dragged outside the boundaries, dont start animation if (difference.inMilliseconds > 50) return; @@ -310,16 +346,14 @@ class _SpinningWheelState extends State } void _startAnimation(Offset pixelsPerSecond) { - var velocity = - _spinVelocity.getVelocity(_localPositionOnPanUpdate, pixelsPerSecond); + double velocity = _localPositionOnPanUpdate != null ? _spinVelocity.getVelocity(_localPositionOnPanUpdate!, pixelsPerSecond) : 0; _localPositionOnPanUpdate = null; _isBackwards = velocity < 0; _initialCircularVelocity = pixelsPerSecondToRadians(velocity.abs()); _totalDuration = _motion.duration(_initialCircularVelocity); - _animationController.duration = - Duration(milliseconds: (_totalDuration * 1000).round()); + _animationController.duration = Duration(milliseconds: (_totalDuration * 2000).round()); _animationController.reset(); _animationController.forward(); @@ -327,9 +361,41 @@ class _SpinningWheelState extends State dispose() { _animationController.dispose(); - if (_subscription != null) { - _subscription.cancel(); - } + _subscription?.cancel(); super.dispose(); } } + +class SpinningWheelController { + _SpinningWheelState? _state; + + bool get _isAttached => _state != null; + + bool get isSpinning => _isAttached && (_state?._animationController.isAnimating ?? false); + + void _attach(_SpinningWheelState state) { + _state = state; + } + + void spin(double velocity, {int? dividerIndex}) { + if (!_isAttached) return; + if (_state?._animationController.isAnimating ?? false) stop(); + if (_state != null) { + final state = _state!; + if (dividerIndex != null && dividerIndex >= 1 && dividerIndex <= state.widget.dividers) { + final dividerSpinAngle = + dividerIndex == state.widget.dividers ? 0 : (((state.widget.dividers - dividerIndex) / state.widget.dividers) * pi * 2); + final dividerInternalAngle = (pi * 2 / state.widget.dividers) * max(0.02, min(0.98, Random().nextDouble())); + _state?._currentDistance = 0; + _state?._initialSpinAngle = dividerSpinAngle + dividerInternalAngle; + } + } + _state?._startOrStop(velocity); + } + + void stop() { + if (!_isAttached) return; + if (!(_state?._animationController.isAnimating ?? false)) return; + _state?._stopAnimation(); + } +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 7296da1..f25b123 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -1,8 +1,6 @@ import 'dart:math'; import 'dart:ui'; -import 'package:meta/meta.dart'; - const Map cuadrants = const { 1: Offset(0.5, 0.5), 2: Offset(-0.5, 0.5), @@ -21,12 +19,16 @@ class SpinVelocity { double get width_0_5 => width / 2; double get height_0_5 => height / 2; - SpinVelocity({@required this.height, @required this.width}); + SpinVelocity({required this.height, required this.width}); double getVelocity(Offset position, Offset pps) { - var cuadrantIndex = _getCuadrantFromOffset(position); - var cuadrant = cuadrants[cuadrantIndex]; - return (cuadrant.dx * pps.dx) + (cuadrant.dy * pps.dy); + final cuadrantIndex = _getCuadrantFromOffset(position); + final cuadrant = cuadrants[cuadrantIndex]; + if (cuadrant != null) { + return (cuadrant.dx * pps.dx) + (cuadrant.dy * pps.dy); + } else { + return 0; + } } /// transforms (x,y) into radians assuming we start at positive y axis as 0 @@ -37,15 +39,11 @@ class SpinVelocity { return _normalizeAngle(angle); } - int _getCuadrantFromOffset(Offset p) => p.dx > width_0_5 - ? (p.dy > height_0_5 ? 2 : 1) - : (p.dy > height_0_5 ? 3 : 4); + int _getCuadrantFromOffset(Offset p) => p.dx > width_0_5 ? (p.dy > height_0_5 ? 2 : 1) : (p.dy > height_0_5 ? 3 : 4); // radians go from 0 to pi (positive y axis) and 0 to -pi (negative y axis) // we need radians from positive y axis (0) clockwise back to y axis (2pi) - double _normalizeAngle(double angle) => angle > 0 - ? (angle > pi_0_5 ? (pi_2_5 - angle) : (pi_0_5 - angle)) - : pi_0_5 - angle; + double _normalizeAngle(double angle) => angle > 0 ? (angle > pi_0_5 ? (pi_2_5 - angle) : (pi_0_5 - angle)) : pi_0_5 - angle; bool contains(Offset p) => Size(width, height).contains(p); } @@ -53,15 +51,14 @@ class SpinVelocity { class NonUniformCircularMotion { final double resistance; - NonUniformCircularMotion({@required this.resistance}); + NonUniformCircularMotion({required this.resistance}); /// returns the acceleration based on the resistance provided in the constructor double get acceleration => resistance * -7 * pi; /// distance covered in a specified time with initial velocity /// ๐œ‘=๐œ‘0+๐œ”ยท๐‘ก+1/2ยท๐›ผยท๐‘ก2 - distance(double velocity, double time) => - (velocity * time) + (0.5 * acceleration * pow(time, 2)); + distance(double velocity, double time) => (velocity * time) + (0.5 * acceleration * pow(time, 2)); /// movement duration with initial velocity duration(double velocity) => -velocity / acceleration; @@ -74,7 +71,7 @@ class NonUniformCircularMotion { } /// transforms pixels per second as used by Flutter to radians -/// this is a custom interpreation, it could be updated to adjust the velocity +/// this is a custom interpretation, it could be updated to adjust the velocity double pixelsPerSecondToRadians(double pps) { // 100 ppx will equal 2pi radians return (pps * 2 * pi) / 1000; diff --git a/pubspec.lock b/pubspec.lock index ec26bfa..7a7d0e5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,34 +1,54 @@ # Generated by pub -# See https://www.dartlang.org/tools/pub/glossary#lockfile +# See https://dart.dev/tools/pub/glossary#lockfile packages: async: dependency: transitive description: name: async - url: "https://pub.dartlang.org" + sha256: bfe67ef28df125b7dddcea62755991f807aa39a2492a23e1550161692950bbe0 + url: "https://pub.dev" source: hosted - version: "2.0.8" + version: "2.10.0" boolean_selector: dependency: transitive description: name: boolean_selector - url: "https://pub.dartlang.org" + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" source: hosted - version: "1.0.4" - charcode: + version: "2.1.1" + characters: dependency: transitive description: - name: charcode - url: "https://pub.dartlang.org" + name: characters + sha256: e6a326c8af69605aec75ed6c187d06b349707a27fbff8222ca9cc2cff167975c + url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.2.1" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" collection: dependency: transitive description: name: collection - url: "https://pub.dartlang.org" + sha256: cfc915e6923fe5ce6e153b0723c753045de46de1b4d63771530504004a45fae0 + url: "https://pub.dev" + source: hosted + version: "1.17.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" source: hosted - version: "1.14.11" + version: "1.3.1" flutter: dependency: "direct main" description: flutter @@ -39,41 +59,46 @@ packages: description: flutter source: sdk version: "0.0.0" - matcher: + js: dependency: transitive description: - name: matcher - url: "https://pub.dartlang.org" + name: js + sha256: "5528c2f391ededb7775ec1daa69e65a2d61276f7552de2b5f7b8d34ee9fd4ab7" + url: "https://pub.dev" source: hosted - version: "0.12.3+1" - meta: - dependency: "direct main" + version: "0.6.5" + matcher: + dependency: transitive description: - name: meta - url: "https://pub.dartlang.org" + name: matcher + sha256: "16db949ceee371e9b99d22f88fa3a73c4e59fd0afed0bd25fc336eb76c198b72" + url: "https://pub.dev" source: hosted - version: "1.1.6" - path: + version: "0.12.13" + material_color_utilities: dependency: transitive description: - name: path - url: "https://pub.dartlang.org" + name: material_color_utilities + sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 + url: "https://pub.dev" source: hosted - version: "1.6.2" - pedantic: + version: "0.2.0" + meta: dependency: transitive description: - name: pedantic - url: "https://pub.dartlang.org" + name: meta + sha256: "6c268b42ed578a53088d834796959e4a1814b5e9e164f147f580a386e5decf42" + url: "https://pub.dev" source: hosted - version: "1.4.0" - quiver: + version: "1.8.0" + path: dependency: transitive description: - name: quiver - url: "https://pub.dartlang.org" + name: path + sha256: db9d4f58c908a4ba5953fcee2ae317c94889433e5024c27ce74a37f94267945b + url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "1.8.2" sky_engine: dependency: transitive description: flutter @@ -83,57 +108,57 @@ packages: dependency: transitive description: name: source_span - url: "https://pub.dartlang.org" + sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + url: "https://pub.dev" source: hosted - version: "1.5.4" + version: "1.9.1" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.dartlang.org" + sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + url: "https://pub.dev" source: hosted - version: "1.9.3" + version: "1.11.0" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.dartlang.org" + sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + url: "https://pub.dev" source: hosted - version: "1.6.8" + version: "2.1.1" string_scanner: dependency: transitive description: name: string_scanner - url: "https://pub.dartlang.org" + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.2.0" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.dartlang.org" + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.2.1" test_api: dependency: transitive description: name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.2" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" + sha256: ad540f65f92caa91bf21dfc8ffb8c589d6e4dc0c2267818b4cc2792857706206 + url: "https://pub.dev" source: hosted - version: "1.1.6" + version: "0.4.16" vector_math: dependency: transitive description: name: vector_math - url: "https://pub.dartlang.org" + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" source: hosted - version: "2.0.8" + version: "2.1.4" sdks: - dart: ">=2.1.0 <3.0.0" + dart: ">=2.18.0 <3.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index dc19d3e..c4dd686 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,20 +1,17 @@ name: flutter_spinning_wheel description: A customizable widget to use as a spinning wheel in Flutter. version: 1.1.0 -author: David Anaya homepage: https://github.com/davidanaya/flutter-spinning-wheel repository: https://github.com/davidanaya/flutter-spinning-wheel issue_tracker: https://github.com/davidanaya/flutter-spinning-wheel/issues environment: - sdk: ">=2.1.0 <3.0.0" + sdk: ">=2.12.0 <3.0.0" dependencies: flutter: sdk: flutter - meta: ^1.1.6 - dev_dependencies: flutter_test: sdk: flutter