diff --git a/BUILD.gn b/BUILD.gn index d1b7a573af375..0b7cca847deb4 100644 --- a/BUILD.gn +++ b/BUILD.gn @@ -183,6 +183,9 @@ group("unittests") { if (is_mac) { public_deps += [ "//flutter/shell/platform/darwin:flutter_channels_unittests" ] + public_deps += [ + "//flutter/third_party/spring_animation:spring_animation_unittests", + ] } if (!is_win && !is_fuchsia) { diff --git a/ci/licenses_golden/licenses_flutter b/ci/licenses_golden/licenses_flutter index 360e4054a7fa1..37c4d1e69d260 100644 --- a/ci/licenses_golden/licenses_flutter +++ b/ci/licenses_golden/licenses_flutter @@ -714,9 +714,11 @@ SOFTWARE. ==================================================================================================== LIBRARY: spring_animation -ORIGIN: ../../../flutter/third_party/spring_animation/Libraries/Animated/animations/SpringAnimation.js + ../../../flutter/third_party/spring_animation/LICENSE +ORIGIN: ../../../flutter/third_party/spring_animation/spring_animation.h + ../../../flutter/third_party/spring_animation/LICENSE +ORIGIN: ../../../flutter/third_party/spring_animation/spring_animation.mm + ../../../flutter/third_party/spring_animation/LICENSE TYPE: LicenseType.mit -FILE: ../../../flutter/third_party/spring_animation/Libraries/Animated/animations/SpringAnimation.js +FILE: ../../../flutter/third_party/spring_animation/spring_animation.h +FILE: ../../../flutter/third_party/spring_animation/spring_animation.mm ---------------------------------------------------------------------------------------------------- Copyright (c) Meta Platforms, Inc. and affiliates. @@ -791,6 +793,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ==================================================================================================== LIBRARY: accessibility LIBRARY: engine +LIBRARY: spring_animation LIBRARY: tonic LIBRARY: txt LIBRARY: web_unicode @@ -3164,6 +3167,7 @@ ORIGIN: ../../../flutter/third_party/accessibility/base/string_utils.cc + ../../ ORIGIN: ../../../flutter/third_party/accessibility/base/string_utils.h + ../../../flutter/LICENSE ORIGIN: ../../../flutter/third_party/accessibility/gfx/transform.cc + ../../../flutter/LICENSE ORIGIN: ../../../flutter/third_party/accessibility/gfx/transform.h + ../../../flutter/LICENSE +ORIGIN: ../../../flutter/third_party/spring_animation/SpringAnimationTest.mm + ../../../flutter/LICENSE ORIGIN: ../../../flutter/third_party/tonic/common/build_config.h + ../../../flutter/third_party/tonic/LICENSE ORIGIN: ../../../flutter/third_party/tonic/common/log.cc + ../../../flutter/third_party/tonic/LICENSE ORIGIN: ../../../flutter/third_party/tonic/common/log.h + ../../../flutter/third_party/tonic/LICENSE @@ -5668,6 +5672,7 @@ FILE: ../../../flutter/third_party/accessibility/base/string_utils.cc FILE: ../../../flutter/third_party/accessibility/base/string_utils.h FILE: ../../../flutter/third_party/accessibility/gfx/transform.cc FILE: ../../../flutter/third_party/accessibility/gfx/transform.h +FILE: ../../../flutter/third_party/spring_animation/SpringAnimationTest.mm FILE: ../../../flutter/third_party/tonic/common/build_config.h FILE: ../../../flutter/third_party/tonic/common/log.cc FILE: ../../../flutter/third_party/tonic/common/log.h diff --git a/testing/run_tests.py b/testing/run_tests.py index 9e79e4e21e77a..c198d2f69879a 100755 --- a/testing/run_tests.py +++ b/testing/run_tests.py @@ -394,6 +394,7 @@ def make_test(name, flags=None, extra_env=None): # The accessibility library only supports Mac and Windows. make_test('accessibility_unittests'), make_test('flutter_channels_unittests'), + make_test('spring_animation_unittests'), ] if is_linux(): diff --git a/third_party/spring_animation/BUILD.gn b/third_party/spring_animation/BUILD.gn new file mode 100644 index 0000000000000..32a6a128b5b16 --- /dev/null +++ b/third_party/spring_animation/BUILD.gn @@ -0,0 +1,33 @@ +# Copyright 2013 The Flutter Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +if (is_ios || is_mac) { + import("//flutter/common/config.gni") + import("//flutter/testing/testing.gni") + + source_set("spring_animation") { + sources = [ + "spring_animation.h", + "spring_animation.mm", + ] + public_configs = [ "//flutter:config" ] + } + + if (enable_unittests) { + test_fixtures("spring_animation_fixtures") { + fixtures = [] + } + + executable("spring_animation_unittests") { + testonly = true + sources = [ "SpringAnimationTest.mm" ] + deps = [ + ":spring_animation", + ":spring_animation_fixtures", + "//flutter/testing", + ] + public_configs = [ "//flutter:config" ] + } + } +} diff --git a/third_party/spring_animation/Libraries/Animated/animations/SpringAnimation.js b/third_party/spring_animation/Libraries/Animated/animations/SpringAnimation.js deleted file mode 100644 index 69101dab030e8..0000000000000 --- a/third_party/spring_animation/Libraries/Animated/animations/SpringAnimation.js +++ /dev/null @@ -1,370 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - * @format - */ - -'use strict'; - -import type {PlatformConfig} from '../AnimatedPlatformConfig'; -import type AnimatedInterpolation from '../nodes/AnimatedInterpolation'; -import type AnimatedValue from '../nodes/AnimatedValue'; -import type AnimatedValueXY from '../nodes/AnimatedValueXY'; -import type {AnimationConfig, EndCallback} from './Animation'; - -import NativeAnimatedHelper from '../NativeAnimatedHelper'; -import AnimatedColor from '../nodes/AnimatedColor'; -import * as SpringConfig from '../SpringConfig'; -import Animation from './Animation'; -import invariant from 'invariant'; - -export type SpringAnimationConfig = { - ...AnimationConfig, - toValue: - | number - | AnimatedValue - | { - x: number, - y: number, - ... - } - | AnimatedValueXY - | { - r: number, - g: number, - b: number, - a: number, - ... - } - | AnimatedColor - | AnimatedInterpolation, - overshootClamping?: boolean, - restDisplacementThreshold?: number, - restSpeedThreshold?: number, - velocity?: - | number - | { - x: number, - y: number, - ... - }, - bounciness?: number, - speed?: number, - tension?: number, - friction?: number, - stiffness?: number, - damping?: number, - mass?: number, - delay?: number, -}; - -export type SpringAnimationConfigSingle = { - ...AnimationConfig, - toValue: number, - overshootClamping?: boolean, - restDisplacementThreshold?: number, - restSpeedThreshold?: number, - velocity?: number, - bounciness?: number, - speed?: number, - tension?: number, - friction?: number, - stiffness?: number, - damping?: number, - mass?: number, - delay?: number, -}; - -export default class SpringAnimation extends Animation { - _overshootClamping: boolean; - _restDisplacementThreshold: number; - _restSpeedThreshold: number; - _lastVelocity: number; - _startPosition: number; - _lastPosition: number; - _fromValue: number; - _toValue: number; - _stiffness: number; - _damping: number; - _mass: number; - _initialVelocity: number; - _delay: number; - _timeout: any; - _startTime: number; - _lastTime: number; - _frameTime: number; - _onUpdate: (value: number) => void; - _animationFrame: any; - _useNativeDriver: boolean; - _platformConfig: ?PlatformConfig; - - constructor(config: SpringAnimationConfigSingle) { - super(); - - this._overshootClamping = config.overshootClamping ?? false; - this._restDisplacementThreshold = config.restDisplacementThreshold ?? 0.001; - this._restSpeedThreshold = config.restSpeedThreshold ?? 0.001; - this._initialVelocity = config.velocity ?? 0; - this._lastVelocity = config.velocity ?? 0; - this._toValue = config.toValue; - this._delay = config.delay ?? 0; - this._useNativeDriver = NativeAnimatedHelper.shouldUseNativeDriver(config); - this._platformConfig = config.platformConfig; - this.__isInteraction = config.isInteraction ?? !this._useNativeDriver; - this.__iterations = config.iterations ?? 1; - - if ( - config.stiffness !== undefined || - config.damping !== undefined || - config.mass !== undefined - ) { - invariant( - config.bounciness === undefined && - config.speed === undefined && - config.tension === undefined && - config.friction === undefined, - 'You can define one of bounciness/speed, tension/friction, or stiffness/damping/mass, but not more than one', - ); - this._stiffness = config.stiffness ?? 100; - this._damping = config.damping ?? 10; - this._mass = config.mass ?? 1; - } else if (config.bounciness !== undefined || config.speed !== undefined) { - // Convert the origami bounciness/speed values to stiffness/damping - // We assume mass is 1. - invariant( - config.tension === undefined && - config.friction === undefined && - config.stiffness === undefined && - config.damping === undefined && - config.mass === undefined, - 'You can define one of bounciness/speed, tension/friction, or stiffness/damping/mass, but not more than one', - ); - const springConfig = SpringConfig.fromBouncinessAndSpeed( - config.bounciness ?? 8, - config.speed ?? 12, - ); - this._stiffness = springConfig.stiffness; - this._damping = springConfig.damping; - this._mass = 1; - } else { - // Convert the origami tension/friction values to stiffness/damping - // We assume mass is 1. - const springConfig = SpringConfig.fromOrigamiTensionAndFriction( - config.tension ?? 40, - config.friction ?? 7, - ); - this._stiffness = springConfig.stiffness; - this._damping = springConfig.damping; - this._mass = 1; - } - - invariant(this._stiffness > 0, 'Stiffness value must be greater than 0'); - invariant(this._damping > 0, 'Damping value must be greater than 0'); - invariant(this._mass > 0, 'Mass value must be greater than 0'); - } - - __getNativeAnimationConfig(): {| - damping: number, - initialVelocity: number, - iterations: number, - mass: number, - platformConfig: ?PlatformConfig, - overshootClamping: boolean, - restDisplacementThreshold: number, - restSpeedThreshold: number, - stiffness: number, - toValue: any, - type: $TEMPORARY$string<'spring'>, - |} { - return { - type: 'spring', - overshootClamping: this._overshootClamping, - restDisplacementThreshold: this._restDisplacementThreshold, - restSpeedThreshold: this._restSpeedThreshold, - stiffness: this._stiffness, - damping: this._damping, - mass: this._mass, - initialVelocity: this._initialVelocity ?? this._lastVelocity, - toValue: this._toValue, - iterations: this.__iterations, - platformConfig: this._platformConfig, - }; - } - - start( - fromValue: number, - onUpdate: (value: number) => void, - onEnd: ?EndCallback, - previousAnimation: ?Animation, - animatedValue: AnimatedValue, - ): void { - this.__active = true; - this._startPosition = fromValue; - this._lastPosition = this._startPosition; - - this._onUpdate = onUpdate; - this.__onEnd = onEnd; - this._lastTime = Date.now(); - this._frameTime = 0.0; - - if (previousAnimation instanceof SpringAnimation) { - const internalState = previousAnimation.getInternalState(); - this._lastPosition = internalState.lastPosition; - this._lastVelocity = internalState.lastVelocity; - // Set the initial velocity to the last velocity - this._initialVelocity = this._lastVelocity; - this._lastTime = internalState.lastTime; - } - - const start = () => { - if (this._useNativeDriver) { - this.__startNativeAnimation(animatedValue); - } else { - this.onUpdate(); - } - }; - - // If this._delay is more than 0, we start after the timeout. - if (this._delay) { - this._timeout = setTimeout(start, this._delay); - } else { - start(); - } - } - - getInternalState(): Object { - return { - lastPosition: this._lastPosition, - lastVelocity: this._lastVelocity, - lastTime: this._lastTime, - }; - } - - /** - * This spring model is based off of a damped harmonic oscillator - * (https://en.wikipedia.org/wiki/Harmonic_oscillator#Damped_harmonic_oscillator). - * - * We use the closed form of the second order differential equation: - * - * x'' + (2ζ⍵_0)x' + ⍵^2x = 0 - * - * where - * ⍵_0 = √(k / m) (undamped angular frequency of the oscillator), - * ζ = c / 2√mk (damping ratio), - * c = damping constant - * k = stiffness - * m = mass - * - * The derivation of the closed form is described in detail here: - * http://planetmath.org/sites/default/files/texpdf/39745.pdf - * - * This algorithm happens to match the algorithm used by CASpringAnimation, - * a QuartzCore (iOS) API that creates spring animations. - */ - onUpdate(): void { - // If for some reason we lost a lot of frames (e.g. process large payload or - // stopped in the debugger), we only advance by 4 frames worth of - // computation and will continue on the next frame. It's better to have it - // running at faster speed than jumping to the end. - const MAX_STEPS = 64; - let now = Date.now(); - if (now > this._lastTime + MAX_STEPS) { - now = this._lastTime + MAX_STEPS; - } - - const deltaTime = (now - this._lastTime) / 1000; - this._frameTime += deltaTime; - - const c: number = this._damping; - const m: number = this._mass; - const k: number = this._stiffness; - const v0: number = -this._initialVelocity; - - const zeta = c / (2 * Math.sqrt(k * m)); // damping ratio - const omega0 = Math.sqrt(k / m); // undamped angular frequency of the oscillator (rad/ms) - const omega1 = omega0 * Math.sqrt(1.0 - zeta * zeta); // exponential decay - const x0 = this._toValue - this._startPosition; // calculate the oscillation from x0 = 1 to x = 0 - - let position = 0.0; - let velocity = 0.0; - const t = this._frameTime; - if (zeta < 1) { - // Under damped - const envelope = Math.exp(-zeta * omega0 * t); - position = - this._toValue - - envelope * - (((v0 + zeta * omega0 * x0) / omega1) * Math.sin(omega1 * t) + - x0 * Math.cos(omega1 * t)); - // This looks crazy -- it's actually just the derivative of the - // oscillation function - velocity = - zeta * - omega0 * - envelope * - ((Math.sin(omega1 * t) * (v0 + zeta * omega0 * x0)) / omega1 + - x0 * Math.cos(omega1 * t)) - - envelope * - (Math.cos(omega1 * t) * (v0 + zeta * omega0 * x0) - - omega1 * x0 * Math.sin(omega1 * t)); - } else { - // Critically damped - const envelope = Math.exp(-omega0 * t); - position = this._toValue - envelope * (x0 + (v0 + omega0 * x0) * t); - velocity = - envelope * (v0 * (t * omega0 - 1) + t * x0 * (omega0 * omega0)); - } - - this._lastTime = now; - this._lastPosition = position; - this._lastVelocity = velocity; - - this._onUpdate(position); - if (!this.__active) { - // a listener might have stopped us in _onUpdate - return; - } - - // Conditions for stopping the spring animation - let isOvershooting = false; - if (this._overshootClamping && this._stiffness !== 0) { - if (this._startPosition < this._toValue) { - isOvershooting = position > this._toValue; - } else { - isOvershooting = position < this._toValue; - } - } - const isVelocity = Math.abs(velocity) <= this._restSpeedThreshold; - let isDisplacement = true; - if (this._stiffness !== 0) { - isDisplacement = - Math.abs(this._toValue - position) <= this._restDisplacementThreshold; - } - - if (isOvershooting || (isVelocity && isDisplacement)) { - if (this._stiffness !== 0) { - // Ensure that we end up with a round value - this._lastPosition = this._toValue; - this._lastVelocity = 0; - this._onUpdate(this._toValue); - } - - this.__debouncedOnEnd({finished: true}); - return; - } - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - this._animationFrame = requestAnimationFrame(this.onUpdate.bind(this)); - } - - stop(): void { - super.stop(); - this.__active = false; - clearTimeout(this._timeout); - global.cancelAnimationFrame(this._animationFrame); - this.__debouncedOnEnd({finished: false}); - } -} diff --git a/third_party/spring_animation/SpringAnimationTest.mm b/third_party/spring_animation/SpringAnimationTest.mm new file mode 100644 index 0000000000000..5e73ab02f4fdd --- /dev/null +++ b/third_party/spring_animation/SpringAnimationTest.mm @@ -0,0 +1,40 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include +#import "flutter/third_party/spring_animation/spring_animation.h" +#include "gtest/gtest.h" + +TEST(SpringAnimationTest, CanInitializeCorrectly) { + SpringAnimation* animation = [[SpringAnimation alloc] initWithStiffness:1000 + damping:500 + mass:3 + initialVelocity:0 + fromValue:0 + toValue:1000]; + ASSERT_TRUE(animation.stiffness == 1000); + ASSERT_TRUE(animation.damping == 500); + ASSERT_TRUE(animation.mass == 3); + ASSERT_TRUE(animation.initialVelocity == 0); + ASSERT_TRUE(animation.fromValue == 0); + ASSERT_TRUE(animation.toValue == 1000); +} + +TEST(SpringAnimationTest, CurveFunctionCanWorkCorrectly) { + // Here is the keyboard curve config on iOS platform. + SpringAnimation* animation = [[SpringAnimation alloc] initWithStiffness:1000 + damping:500 + mass:3 + initialVelocity:0 + fromValue:0 + toValue:1000]; + const double accuracy = 1.0; + const double startTime = 0; + const double endTime = 0.6; + + const double startValue = [animation curveFunction:startTime]; + ASSERT_TRUE(fabs(startValue - animation.fromValue) < accuracy); + const double toValue = [animation curveFunction:endTime]; + ASSERT_TRUE(fabs(toValue - animation.toValue) < accuracy); +} diff --git a/third_party/spring_animation/spring_animation.h b/third_party/spring_animation/spring_animation.h new file mode 100644 index 0000000000000..2ebcc62e518dc --- /dev/null +++ b/third_party/spring_animation/spring_animation.h @@ -0,0 +1,43 @@ +// +// Copyright (c) Meta Platforms, Inc. and affiliates. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +// +// @flow +// @format +// + +#ifndef FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_SPRING_ANIMATION_H_ +#define FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_SPRING_ANIMATION_H_ + +#include + +// This simplified spring model is based off of a damped harmonic oscillator. +// See: +// https://en.wikipedia.org/wiki/Harmonic_oscillator#Damped_harmonic_oscillator +// +// This models the closed form of the second order differential equation which +// happens to match the algorithm used by CASpringAnimation, a QuartzCore (iOS) +// API that creates spring animations. +@interface SpringAnimation : NSObject + +- (instancetype)initWithStiffness:(double)stiffness + damping:(double)damping + mass:(double)mass + initialVelocity:(double)initialVelocity + fromValue:(double)fromValue + toValue:(double)toValue; + +- (double)curveFunction:(double)t; + +@property(nonatomic, assign, readonly) double stiffness; +@property(nonatomic, assign, readonly) double damping; +@property(nonatomic, assign, readonly) double mass; +@property(nonatomic, assign, readonly) double initialVelocity; +@property(nonatomic, assign, readonly) double fromValue; +@property(nonatomic, assign, readonly) double toValue; + +@end + +#endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_SPRING_ANIMATION_H_ diff --git a/third_party/spring_animation/spring_animation.mm b/third_party/spring_animation/spring_animation.mm new file mode 100644 index 0000000000000..6232afff162b3 --- /dev/null +++ b/third_party/spring_animation/spring_animation.mm @@ -0,0 +1,67 @@ +// +// Copyright (c) Meta Platforms, Inc. and affiliates. +// +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. +// +// @flow +// @format +// + +#import "spring_animation.h" + +#include + +@interface SpringAnimation () + +@property(nonatomic, assign) double zeta; +@property(nonatomic, assign) double omega0; +@property(nonatomic, assign) double omega1; +@property(nonatomic, assign) double v0; +@property(nonatomic, assign) double x0; + +@end + +// Spring code adapted from React Native's Animation Library, see: +// https://github.com/facebook/react-native/blob/main/Libraries/Animated/animations/SpringAnimation.js +@implementation SpringAnimation +- (instancetype)initWithStiffness:(double)stiffness + damping:(double)damping + mass:(double)mass + initialVelocity:(double)initialVelocity + fromValue:(double)fromValue + toValue:(double)toValue { + self = [super init]; + if (self) { + _stiffness = stiffness; + _damping = damping; + _mass = mass; + _initialVelocity = initialVelocity; + _fromValue = fromValue; + _toValue = toValue; + + _zeta = _damping / (2 * sqrt(_stiffness * _mass)); // Damping ratio. + _omega0 = sqrt(_stiffness / _mass); // Undamped angular frequency of the oscillator. + _omega1 = _omega0 * sqrt(1.0 - _zeta * _zeta); // Exponential decay. + _v0 = -_initialVelocity; + _x0 = _toValue - _fromValue; + } + return self; +} + +- (double)curveFunction:(double)t { + double y; + if (_zeta < 1) { + // Under damped. + const double envelope = exp(-_zeta * _omega0 * t); + y = _toValue - envelope * (((_v0 + _zeta * _omega0 * _x0) / _omega1) * sin(_omega1 * t) + + _x0 * cos(_omega1 * t)); + } else { + // Critically damped. + const double envelope = exp(-_omega0 * t); + y = _toValue - envelope * (_x0 + (_v0 + _omega0 * _x0) * t); + } + + return y; +} +@end