diff --git a/CHANGELOG.md b/CHANGELOG.md index e2c4acbf..dc511e6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,16 @@ # Changelog + +## 0.14.3 +* Added support for customizable target radius in `DescribedFeatureOverlay` via the `targetRadius` property. +* Improved pulse animation for smoother visuals. + ## 0.14.2 * Updated All Dependencies versions * Updated To Flutter version 3.24.3 -# Changelog + ## 0.14.1 * Updated Provider version -# Changelog ## 0.14.0 * Migrated to null safety diff --git a/README.md b/README.md index 014770b1..7923b7e9 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,26 @@ This is `OverflowMode.ignore` by default, which will simply render the content y * `OverflowMode.wrapBackground` will expand the background circle if necessary, but also shrink it if the content is smaller than the default background size. +#### `targetRadius` + +The `targetRadius` property allows you to customize the radius of the tap target circle. If you don't provide a value, it defaults to `44.0`. + +**Example:** + +```dart +DescribedFeatureOverlay( + featureId: 'custom-target-radius-feature', + tapTarget: const Icon(Icons.add), + targetRadius: 60.0, // Custom radius for the target + title: const Text('Custom Target Radius'), + description: const Text('This feature overlay has a larger target radius.'), + child: FloatingActionButton( + onPressed: () {}, + child: const Icon(Icons.add), + ), +) +``` + ### `FeatureDiscovery.discoverFeatures` diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 4b1fef6c..a1650872 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -25,6 +25,7 @@ if (flutterVersionName == null) { android { + namespace "com.example.example" compileSdk 34 lintOptions { diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index ae04661e..8357d848 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/example/android/settings.gradle b/example/android/settings.gradle index 38fca8a1..9a641152 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -18,8 +18,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.1" apply false - id "org.jetbrains.kotlin.android" version "1.7.10" apply false + id "com.android.application" version "8.7.3" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false } include ":app" \ No newline at end of file diff --git a/example/ios/.gitignore b/example/ios/.gitignore new file mode 100644 index 00000000..7a7f9873 --- /dev/null +++ b/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/lib/src/rendering/custom_layout.dart b/lib/src/rendering/custom_layout.dart index d797c005..d0a0e4ed 100644 --- a/lib/src/rendering/custom_layout.dart +++ b/lib/src/rendering/custom_layout.dart @@ -38,6 +38,8 @@ class BackgroundContentLayoutDelegate extends MultiChildLayoutDelegate { final FeatureOverlayState state; final double? transitionProgress; + final double targetRadius; + BackgroundContentLayoutDelegate({ required this.overflowMode, required this.contentPosition, @@ -47,6 +49,7 @@ class BackgroundContentLayoutDelegate extends MultiChildLayoutDelegate { required this.contentOffsetMultiplier, required this.state, required this.transitionProgress, + required this.targetRadius, }); @override @@ -74,9 +77,10 @@ class BackgroundContentLayoutDelegate extends MultiChildLayoutDelegate { overflowMode == OverflowMode.clipContent) matchedRadius = backgroundRadius; else { - // 75 is the radius of the pulse when fully expanded. + // targetRadius * 2 is the radius of the pulse when fully expanded. // Calculating the distance here is easy because the pulse is a circle. - final distanceToOuterPulse = anchorPoint.distanceTo(backgroundPoint) + 75; + final distanceToOuterPulse = + anchorPoint.distanceTo(backgroundPoint) + targetRadius * 2; // Calculate distance to the furthest point of the content. final contentArea = Rect.fromLTWH(contentPoint.x, contentPoint.y, diff --git a/lib/src/widgets/overlay.dart b/lib/src/widgets/overlay.dart index f6778cc2..3ac7379a 100644 --- a/lib/src/widgets/overlay.dart +++ b/lib/src/widgets/overlay.dart @@ -6,6 +6,8 @@ import 'package:feature_discovery/src/rendering.dart'; import 'package:feature_discovery/src/widgets.dart'; import 'package:flutter/material.dart'; +const double kDefaultTargetRadius = 44; + class DescribedFeatureOverlay extends StatefulWidget { static const double kDefaultBackgroundOpacity = 0.96; @@ -137,6 +139,11 @@ class DescribedFeatureOverlay extends StatefulWidget { /// all of the current steps are dismissed. final Future Function()? onBackgroundTap; + /// Radius of the target circle. + /// + /// The default value for [targetRadius] is `44`. + final double? targetRadius; + const DescribedFeatureOverlay({ Key? key, required this.featureId, @@ -162,6 +169,7 @@ class DescribedFeatureOverlay extends StatefulWidget { this.barrierDismissible = true, this.backgroundDismissible = false, this.onBackgroundTap, + this.targetRadius, }) : assert( barrierDismissible == true || onDismiss == null, 'Cannot provide both a barrierDismissible and onDismiss function\n' @@ -187,6 +195,7 @@ class _DescribedFeatureOverlayState extends State /// The usual order is open, complete, then dismiss across the project, /// but pulse does not exist for most other occurrences. late AnimationController _pulseController; + late CurvedAnimation _pulseAnimation; late AnimationController _completeController; late AnimationController _dismissController; @@ -254,6 +263,7 @@ class _DescribedFeatureOverlayState extends State @override void dispose() { _openController.dispose(); + _pulseAnimation.dispose(); _pulseController.dispose(); _completeController.dispose(); _dismissController.dispose(); @@ -310,17 +320,20 @@ class _DescribedFeatureOverlayState extends State ..addListener( () => setState(() => _transitionProgress = _openController.value)); - _pulseController = AnimationController( - vsync: this, duration: widget.pulseDuration) + _pulseController = + AnimationController(vsync: this, duration: widget.pulseDuration) + ..addStatusListener( + (AnimationStatus status) { + if (status == AnimationStatus.completed) { + _pulseController.forward(from: 0); + } + }, + ); + + _pulseAnimation = CurvedAnimation( + parent: _pulseController, curve: Curves.easeInOut) ..addListener( - () => setState(() => _transitionProgress = _pulseController.value)) - ..addStatusListener( - (AnimationStatus status) { - if (status == AnimationStatus.completed) { - _pulseController.forward(from: 0); - } - }, - ); + () => setState(() => _transitionProgress = _pulseAnimation.value)); _completeController = AnimationController(vsync: this, duration: widget.completeDuration) @@ -461,20 +474,32 @@ class _DescribedFeatureOverlayState extends State final startingBackgroundPosition = anchor; Offset? endingBackgroundPosition; + final double left = min( + 20, anchor.dx - (widget.targetRadius ?? kDefaultTargetRadius) * 2); + final double right = max(width - 20, + anchor.dx + (widget.targetRadius ?? kDefaultTargetRadius) * 2); + switch (contentLocation) { case ContentLocation.above: - endingBackgroundPosition = Offset( - anchor.dx - - width / 2.0 + - (_isOnLeftHalfOfScreen(anchor) ? -20.0 : 20.0), - anchor.dy - (width / 2.0) + 80.0); + // endingBackgroundPosition = Offset( + // anchor.dx - + // width / 2.0 + + // (_isOnLeftHalfOfScreen(anchor) ? -20.0 : 20.0), + // anchor.dy - (width / 2.0) + 80.0); + + endingBackgroundPosition = + Offset((left + right) / 2, anchor.dy - (width / 2.0) + 80.0); break; case ContentLocation.below: - endingBackgroundPosition = Offset( - anchor.dx - - width / 2.0 + - (_isOnLeftHalfOfScreen(anchor) ? -20.0 : 20.0), - anchor.dy + (width / 2.0) - 80.0); + + // endingBackgroundPosition = Offset( + // anchor.dx - + // width / 2.0 + + // (_isOnLeftHalfOfScreen(anchor) ? -20.0 : 20.0), + // anchor.dy + (width / 2.0) - 80.0); + endingBackgroundPosition = + Offset((left + right) / 2, anchor.dy + (width / 2.0) - 80.0); + break; case ContentLocation.trivial: throw ArgumentError.value(contentLocation); @@ -579,10 +604,13 @@ class _DescribedFeatureOverlayState extends State final contentWidth = min(_screenSize.width, _screenSize.height); final dx = contentCenterPosition.dx - contentWidth; + final contentPosition = Offset( (dx.isNegative) ? 0.0 : dx, anchor.dy + - contentOffsetMultiplier * (44 + 20), // 44 is the tap target's radius. + contentOffsetMultiplier * + ((widget.targetRadius ?? kDefaultTargetRadius) * 1.1 + + 20), // 44 is the default tap target's radius. ); Widget background = Container( @@ -633,6 +661,7 @@ class _DescribedFeatureOverlayState extends State contentOffsetMultiplier: contentOffsetMultiplier, state: _state!, transitionProgress: _transitionProgress, + targetRadius: widget.targetRadius ?? kDefaultTargetRadius, ), children: [ LayoutId( @@ -669,6 +698,14 @@ class _DescribedFeatureOverlayState extends State transitionProgress: _transitionProgress!, anchor: anchor, color: widget.targetColor, + targetRadius: widget.targetRadius, + ), + _TapTargetPulse( + state: _state!, + transitionProgress: _transitionProgress!, + anchor: anchor, + color: widget.targetColor, + targetRadius: widget.targetRadius, ), _TapTarget( state: _state!, @@ -677,6 +714,7 @@ class _DescribedFeatureOverlayState extends State color: widget.targetColor, onPressed: tryCompleteThis, child: widget.tapTarget, + targetRadius: widget.targetRadius, ), ], ); @@ -783,6 +821,7 @@ class _Pulse extends StatelessWidget { final double transitionProgress; final Offset anchor; final Color color; + final double? targetRadius; const _Pulse({ Key? key, @@ -790,18 +829,15 @@ class _Pulse extends StatelessWidget { required this.transitionProgress, required this.anchor, required this.color, + this.targetRadius, }) : super(key: key); double get radius { + final targetRadius = this.targetRadius ?? kDefaultTargetRadius; switch (state) { case FeatureOverlayState.opened: - double expandedPercent; - if (transitionProgress >= 0.3 && transitionProgress <= 0.8) { - expandedPercent = (transitionProgress - 0.3) / 0.5; - } else { - expandedPercent = 0.0; - } - return 44.0 + (35.0 * expandedPercent); + final double expandedPercent = delayedLerp(transitionProgress, 0.5); + return (1.0 + expandedPercent) * targetRadius; case FeatureOverlayState.dismissing: case FeatureOverlayState.completing: return 0; //(44.0 + 35.0) * (1.0 - transitionProgress); @@ -814,9 +850,7 @@ class _Pulse extends StatelessWidget { double get opacity { switch (state) { case FeatureOverlayState.opened: - final percentOpaque = - 1 - ((transitionProgress.clamp(0.3, 0.8) - 0.3) / 0.5); - return (percentOpaque * 0.75).clamp(0, 1); + return (1.0 - delayedLerp(transitionProgress, 0.5)); case FeatureOverlayState.completing: case FeatureOverlayState.dismissing: return 0; //((1.0 - transitionProgress) * 0.5).clamp(0.0, 1.0); @@ -826,6 +860,14 @@ class _Pulse extends StatelessWidget { } } + double delayedLerp(double lerp, double threshold) { + if (lerp < threshold) { + return 0.0; + } + + return (lerp - threshold) / (1.0 - threshold); + } + @override Widget build(BuildContext context) => state == FeatureOverlayState.closed ? Container() @@ -849,22 +891,23 @@ class _TapTarget extends StatelessWidget { final Widget child; final Color color; final VoidCallback onPressed; - - const _TapTarget({ - Key? key, - required this.anchor, - required this.child, - required this.onPressed, - required this.color, - required this.state, - required this.transitionProgress, - }) : super(key: key); + final double? targetRadius; + + const _TapTarget( + {Key? key, + required this.anchor, + required this.child, + required this.onPressed, + required this.color, + required this.state, + required this.transitionProgress, + this.targetRadius}) + : super(key: key); double get opacity { switch (state) { case FeatureOverlayState.opening: - return const Interval(0, 0.3, curve: Curves.easeOut) - .transform(transitionProgress); + return 1; case FeatureOverlayState.completing: case FeatureOverlayState.dismissing: return 1 - @@ -878,37 +921,30 @@ class _TapTarget extends StatelessWidget { } double get radius { + final targetRadius = this.targetRadius ?? kDefaultTargetRadius; + return targetRadius; switch (state) { case FeatureOverlayState.closed: return 0; case FeatureOverlayState.opening: - return 20 + 24 * transitionProgress; + return 20 + (targetRadius - 20) * transitionProgress; case FeatureOverlayState.opened: - double expandedPercent; - if (transitionProgress < 0.3) { - expandedPercent = transitionProgress / 0.3; - } else if (transitionProgress < 0.6) { - expandedPercent = 1 - ((transitionProgress - 0.3) / 0.3); - } else { - expandedPercent = 0; - } - return 44 + (20 * expandedPercent); + return targetRadius; case FeatureOverlayState.completing: case FeatureOverlayState.dismissing: - return 20 + 24 * (1 - transitionProgress); + return 20 + (targetRadius - 20) * (1 - transitionProgress); } } @override Widget build(BuildContext context) => CenterAbout( position: anchor, - child: Container( + child: SizedBox( height: 2 * radius, width: 2 * radius, child: Opacity( opacity: opacity, child: RawMaterialButton( - fillColor: color, shape: const CircleBorder(), child: child, onPressed: onPressed, @@ -918,6 +954,78 @@ class _TapTarget extends StatelessWidget { ); } +class _TapTargetPulse extends StatelessWidget { + final FeatureOverlayState state; + final double transitionProgress; + final Offset anchor; + final Color color; + final double? targetRadius; + + const _TapTargetPulse( + {Key? key, + required this.anchor, + required this.color, + required this.state, + required this.transitionProgress, + this.targetRadius}) + : super(key: key); + + double get opacity { + switch (state) { + case FeatureOverlayState.opening: + return 1; + case FeatureOverlayState.completing: + case FeatureOverlayState.dismissing: + return 1 - + const Interval(0.7, 1, curve: Curves.easeOut) + .transform(transitionProgress); + case FeatureOverlayState.closed: + return 0; + case FeatureOverlayState.opened: + return 1; + } + } + + static const double targetPulseRadiusRatio = 0.1; + + double get radius { + final targetRadius = this.targetRadius ?? kDefaultTargetRadius; + final targetPulseRadius = targetPulseRadiusRatio * targetRadius; + switch (state) { + case FeatureOverlayState.closed: + return 0; + case FeatureOverlayState.opening: + return 20 + (targetRadius - 20) * transitionProgress; + case FeatureOverlayState.opened: + return targetRadius + + halfwayLerp(transitionProgress) * targetPulseRadius; + case FeatureOverlayState.completing: + case FeatureOverlayState.dismissing: + return 20 + (targetRadius - 20) * (1 - transitionProgress); + } + } + + double halfwayLerp(double lerp) { + if (lerp < 0.5) { + return lerp / 0.5; + } + + return (1.0 - lerp) / 0.5; + } + + @override + Widget build(BuildContext context) => CenterAbout( + position: anchor, + child: Container( + height: 2 * radius, + width: 2 * radius, + decoration: BoxDecoration( + color: color.withValues(alpha: opacity), shape: BoxShape.circle), + child: const SizedBox.expand(), + ), + ); +} + /// Controls how content that overflows the background should be handled. /// /// The default for [DescribedFeatureOverlay] is [ignore]. diff --git a/pubspec.yaml b/pubspec.yaml index 34a039e9..eb6d22b7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: >- A Flutter package that implements Material Design Feature discovery to show a description of specific features to new users. See https://tinyurl.com/FeatureDiscovery -version: 0.14.2 +version: 0.14.3 homepage: https://github.com/ayalma/feature_discovery environment: