diff --git a/packages/flutter/lib/src/physics/spring_simulation.dart b/packages/flutter/lib/src/physics/spring_simulation.dart index 4bfc4b3adb..3f0bc3a519 100644 --- a/packages/flutter/lib/src/physics/spring_simulation.dart +++ b/packages/flutter/lib/src/physics/spring_simulation.dart @@ -43,6 +43,44 @@ class SpringDescription { double ratio = 1.0, }) : damping = ratio * 2.0 * math.sqrt(mass * stiffness); + /// Creates a [SpringDescription] based on a desired animation duration and + /// bounce. + /// + /// This provides an intuitive way to define a spring based on its visual + /// properties, [duration] and [bounce]. Check the properties' documentation + /// for their definition. + /// + /// This constructor produces the same result as SwiftUI's + /// `spring(duration:bounce:blendDuration:)` animation. + /// + /// {@tool snippet} + /// ```dart + /// final SpringDescription spring = SpringDescription.withDurationAndBounce( + /// duration: const Duration(milliseconds: 300), + /// bounce: 0.3, + /// ); + /// ``` + /// {@end-tool} + /// + /// See also: + /// * [SpringDescription], which creates a spring by explicitly providing + /// physical parameters. + /// * [SpringDescription.withDampingRatio], which creates a spring with a + /// damping ratio and other physical parameters. + factory SpringDescription.withDurationAndBounce({ + Duration duration = const Duration(milliseconds: 500), + double bounce = 0.0, + }) { + assert(duration.inMilliseconds > 0, 'Duration must be positive'); + final double durationInSeconds = duration.inMilliseconds / Duration.millisecondsPerSecond; + const double mass = 1.0; + final double stiffness = (4 * math.pi * math.pi * mass) / math.pow(durationInSeconds, 2); + final double dampingRatio = bounce > 0 ? (1.0 - bounce) : (1 / (bounce + 1)); + final double damping = dampingRatio * 2.0 * math.sqrt(mass * stiffness); + + return SpringDescription(mass: mass, stiffness: stiffness, damping: damping); + } + /// The mass of the spring (m). /// /// The units are arbitrary, but all springs within a system should use @@ -78,6 +116,43 @@ class SpringDescription { /// driving the [SpringSimulation]. final double damping; + /// The duration parameter used in [SpringDescription.withDurationAndBounce]. + /// + /// This value defines the perceptual duration of the spring, controlling + /// its overall pace. It is approximately equal to the time it takes for + /// the spring to settle, but for highly bouncy springs, it instead + /// corresponds to the oscillation period. + /// + /// This duration does not represent the exact time for the spring to stop + /// moving. For example, when [bounce] is 1, the spring oscillates + /// indefinitely, even though [duration] has a finite value. To determine + /// when the motion has effectively stopped within a certain tolerance, + /// use [SpringSimulation.isDone]. + /// + /// Defaults to 0.5 seconds. + Duration get duration { + final double durationInSeconds = math.sqrt((4 * math.pi * math.pi * mass) / stiffness); + final int milliseconds = (durationInSeconds * Duration.millisecondsPerSecond).round(); + return Duration(milliseconds: milliseconds); + } + + /// The bounce parameter used in [SpringDescription.withDurationAndBounce]. + /// + /// This value controls how bouncy the spring is: + /// + /// * A value of 0 results in a critically damped spring with no oscillation. + /// * Values between 0 and 1 produce underdamping, where the spring oscillates a few times + /// before settling. A value of 1 represents an undamped spring that + /// oscillates indefinitely. + /// * Negative values indicate overdamping, where the motion is slow and + /// resistive, like moving through a thick fluid. + /// + /// Defaults to 0. + double get bounce { + final double dampingRatio = damping / (2.0 * math.sqrt(mass * stiffness)); + return dampingRatio < 1.0 ? (1.0 - dampingRatio) : ((1 / dampingRatio) - 1); + } + @override String toString() => '${objectRuntimeType(this, 'SpringDescription')}(mass: ${mass.toStringAsFixed(1)}, stiffness: ${stiffness.toStringAsFixed(1)}, damping: ${damping.toStringAsFixed(1)})'; diff --git a/packages/flutter/test/physics/spring_simulation_test.dart b/packages/flutter/test/physics/spring_simulation_test.dart index b75d0f4924..5e27ed6720 100644 --- a/packages/flutter/test/physics/spring_simulation_test.dart +++ b/packages/flutter/test/physics/spring_simulation_test.dart @@ -35,4 +35,69 @@ void main() { expect(snappingSimulation.x(time), 1); expect(snappingSimulation.dx(time), 0); }); + + group('SpringDescription.withDurationAndBounce', () { + test('creates spring with expected results', () { + final SpringDescription spring = SpringDescription.withDurationAndBounce(bounce: 0.3); + + expect(spring.mass, equals(1.0)); + expect(spring.stiffness, moreOrLessEquals(157.91, epsilon: 0.01)); + expect(spring.damping, moreOrLessEquals(17.59, epsilon: 0.01)); + + // Verify that getters recalculate correctly + expect(spring.bounce, moreOrLessEquals(0.3, epsilon: 0.0001)); + expect(spring.duration.inMilliseconds, equals(500)); + }); + + test('creates spring with negative bounce', () { + final SpringDescription spring = SpringDescription.withDurationAndBounce(bounce: -0.3); + + expect(spring.mass, equals(1.0)); + expect(spring.stiffness, moreOrLessEquals(157.91, epsilon: 0.01)); + expect(spring.damping, moreOrLessEquals(35.90, epsilon: 0.01)); + + // Verify that getters recalculate correctly + expect(spring.bounce, moreOrLessEquals(-0.3, epsilon: 0.0001)); + expect(spring.duration.inMilliseconds, equals(500)); + }); + + test('get duration and bounce based on mass and stiffness', () { + const SpringDescription spring = SpringDescription( + mass: 1.0, + stiffness: 157.91, + damping: 17.59, + ); + + expect(spring.bounce, moreOrLessEquals(0.3, epsilon: 0.001)); + expect(spring.duration.inMilliseconds, equals(500)); + }); + + test('custom duration', () { + final SpringDescription spring = SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 100), + ); + + expect(spring.mass, equals(1.0)); + expect(spring.stiffness, moreOrLessEquals(3947.84, epsilon: 0.01)); + expect(spring.damping, moreOrLessEquals(125.66, epsilon: 0.01)); + + expect(spring.bounce, moreOrLessEquals(0, epsilon: 0.001)); + expect(spring.duration.inMilliseconds, equals(100)); + }); + + test('duration <= 0 should fail', () { + expect( + () => SpringDescription.withDurationAndBounce( + duration: const Duration(seconds: -1), + bounce: 0.3, + ), + throwsA(isAssertionError), + ); + + expect( + () => SpringDescription.withDurationAndBounce(duration: Duration.zero, bounce: 0.3), + throwsA(isAssertionError), + ); + }); + }); }