diff --git a/.gitignore b/.gitignore index 1808a01..a6e11de 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ Packages/ roblox.yml wally.lock -sourcemap.json \ No newline at end of file +sourcemap.json +*-sourcemap.json \ No newline at end of file diff --git a/.luaurc b/.luaurc new file mode 100644 index 0000000..3316b60 --- /dev/null +++ b/.luaurc @@ -0,0 +1,6 @@ +{ + "languageMode": "nocheck", + "aliases": { + "lune": "~/.lune/.typedefs/0.10.4/" + } +} diff --git a/.lune/wallyInstall.luau b/.lune/wallyInstall.luau new file mode 100644 index 0000000..3ea938c --- /dev/null +++ b/.lune/wallyInstall.luau @@ -0,0 +1,61 @@ +local process = require("@lune/process") +local task = require("@lune/task") +local stdio = require("@lune/stdio") +local fs = require("@lune/fs") + +type ChildProcess = process.ChildProcess +type ChildProcessReader = process.ChildProcessReader +type ChildProcessStatus = { ok: boolean, code: number } + +local PROJECT_NAME = "model.project.json" +local PACKAGE_DIRECTORIES = { "Packages" } + +local function pipe(child: ChildProcess): ChildProcessStatus + local function readStream(stream: ChildProcessReader, output: (string) -> ()) + return task.spawn(function() + while true do + local chunk = stream:read() + if not chunk then + break + end + + output(chunk) + end + end) + end + + readStream(child.stdout, stdio.write) + readStream(child.stderr, stdio.write) + + return child:status() +end + +local function exec(cmdline: string) + local pieces = {} + for piece in string.gmatch(" " .. cmdline, "%s+([^%s]+)") do + table.insert(pieces, piece) + end + + local program = table.remove(pieces, 1) + assert(program ~= nil, "cmdline must include a program") + + local child = process.create(program, pieces) + local status = pipe(child) + + if not status.ok then + process.exit(status.code) + end +end + +exec("wally install") +exec(`rojo sourcemap -o {PROJECT_NAME}-sourcemap.json ./{PROJECT_NAME}`) + +for _, packageDir in PACKAGE_DIRECTORIES do + if fs.isDir(`./{packageDir}`) then + exec(`wally-package-types --sourcemap {PROJECT_NAME}-sourcemap.json ./{packageDir}/`) + else + stdio.write( + `{stdio.color("yellow")}WARNING: Package directory "{packageDir}" does not exist!{stdio.color("reset")}\n` + ) + end +end diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ec9ea42 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "luau-lsp.sourcemap.enabled": true, + "luau-lsp.sourcemap.autogenerate": true, + "luau-lsp.sourcemap.rojoProjectFile": "default.project.json", + "luau-lsp.completion.imports.stringRequires.enabled": true, + "stylua.targetReleaseVersion": "latest", + "luau-lsp.fflags.enableNewSolver": true +} diff --git a/LICENSE.md b/LICENSE.md index a443be7..896ac3e 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Jack Fox +Copyright (c) 2026 Jack Fox Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md index 2de7b1e..c3cea8c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,10 @@ - [Hooks](#-hooks) - [useSpring](#usespring) - [useTween](#usetween) + - [useSpringValue](#usespringvalue) + - [useTweenValue](#usetweenvalue) - [useGroupAnimation](#usegroupanimation) + - [useTransparencyModifier](#usetransparencymodifier) - [Supported Value Types](#-supported-value-types) - [Showcase](#-showcase) - [Contribution](#-contribution) @@ -57,7 +60,7 @@ Add React-Flow to your `wally.toml` file: ```toml [dependencies] -ReactFlow = "outofbears/react-flow@0.4.0" +ReactFlow = "outofbears/react-flow@0.5.0" ``` Then install with: @@ -169,6 +172,82 @@ return createElement("Frame", { --- +### `useSpringValue` + +Declarative variant of [`useSpring`](#usespring). Instead of returning an update function, the hook watches the `target`, `speed`, and `damper` props on each render and re-targets the spring automatically when they change. Use this when your spring goal is a direct function of React state and you don't need imperative control. + +The `start` prop seeds the initial value only — it is **not** re-applied on subsequent renders, so the spring smoothly retargets from its current value rather than snapping back to the start. + +**Arguments:** +- **config:** A configuration table with the following properties: + - **start:** Initial value of the animation (used only on mount) + - **target:** Current goal — changes between renders are followed automatically + - **speed:** Spring stiffness (live-updatable) + - **damper:** Damping ratio (live-updatable) + +**Returns:** +A binding that updates as the animation progresses. No update or stop function — control is purely via props. + +**Example:** +```lua +local useSpringValue = ReactFlow.useSpringValue + +-- Inside your component: +local hovered, setHovered = React.useState(false) + +local color = useSpringValue({ + start = Color3.fromRGB(150, 150, 150), + target = if hovered then Color3.fromRGB(255, 255, 255) else Color3.fromRGB(150, 150, 150), + + speed = 20, + damper = 0.8, +}) + +return createElement("TextButton", { + BackgroundColor3 = color, + [React.Event.MouseEnter] = function() setHovered(true) end, + [React.Event.MouseLeave] = function() setHovered(false) end, +}) +``` + +--- + +### `useTweenValue` + +Declarative variant of [`useTween`](#usetween). The hook watches the `target` prop on each render and replays the tween from the binding's current value to the new target. Use this when your tween goal is driven by React state. + +As with `useSpringValue`, the `start` prop is only used on mount — re-renders tween from the current animated value, not from `start`. + +**Arguments:** +- **config:** A configuration table with the following properties: + - **start:** Initial value of the animation (used only on mount) + - **target:** Current goal — changes between renders trigger a replay + - **info:** `TweenInfo` instance + - **delay:** Optional delay before the tween starts + +**Returns:** +A binding that updates as the animation progresses. + +**Example:** +```lua +local useTweenValue = ReactFlow.useTweenValue + +-- Inside your component: +local visible, setVisible = React.useState(false) + +local transparency = useTweenValue({ + start = 1, + target = if visible then 0 else 1, + info = TweenInfo.new(0.3, Enum.EasingStyle.Quad, Enum.EasingDirection.Out), +}) + +return createElement("Frame", { + BackgroundTransparency = transparency, +}) +``` + +--- + ### `useGroupAnimation` Creates a group of animations that are managed together as a single entity. With `useGroupAnimation`, you can define multiple animation states by combining the following animation primitives: `useAnimation`, `useSpringAnimation`, `useSequenceAnimation`, and `useTweenAnimation`. This allows you to define complex animation states and switch between them seamlessly at runtime, providing an elegant way to handle UI state transitions. @@ -231,6 +310,56 @@ return createElement("Frame", { --- +### `useTransparencyModifier` + +Composes a transparency binding with a modifier so transparency values can be uniformly faded toward fully transparent. Useful for hover, disabled, or fade-in/out states that need to dim a whole element (or subtree) without rewriting every individual transparency value. + +The modifier is applied multiplicatively against visibility: a modifier of `0` leaves transparency untouched, a modifier of `1` makes the element fully transparent, and values in between blend smoothly. Both plain `number` transparencies and `NumberSequence` transparencies (e.g. for `UIGradient.Transparency`) are supported, as are static values and bindings. + +**Arguments:** +- **modifier:** A binding holding a `number` between `0` and `1` representing the additional transparency to apply. A value of `0` is a no-op; `1` fully hides the target. + +**Returns:** +A function that takes a transparency value and returns a binding with the modifier applied. The returned function can be called repeatedly for each property you want to modify, and accepts: +- A `number` (e.g. `BackgroundTransparency = 0.2`) +- A `NumberSequence` (e.g. for `UIGradient.Transparency`) +- A `Binding` of either of the above (animated transparency continues to update with the modifier applied) +- `nil` (treated as `0`) + +**Example:** +```lua +local useTransparencyModifier = ReactFlow.useTransparencyModifier +local useSpring = ReactFlow.useSpring + +-- Inside your component: +local fade = useSpring({ + start = 1, -- Start fully hidden + target = visible and 0 or 1,-- 0 = fully visible, 1 = fully hidden + + speed = 18, + damper = 1, +}) + +local modifyTransparency = useTransparencyModifier(fade) + +return createElement("Frame", { + BackgroundTransparency = modifyTransparency(0.2), +}, { + label = createElement("TextLabel", { + BackgroundTransparency = 1, + TextTransparency = modifyTransparency(0), + }), + gradient = createElement("UIGradient", { + Transparency = modifyTransparency(NumberSequence.new({ + NumberSequenceKeypoint.new(0, 0), + NumberSequenceKeypoint.new(1, 0.5), + })), + }), +}) +``` + +--- + ### `TransitionFragment` `TransitionFragment` is a component that allows elements to be animated on transition in and out by preserving their presence in a cached fragment during enter and leave operations. When children are added or removed, the component maintains them in the DOM while injecting transition state props, enabling easy enter and exit animations. diff --git a/aftman.toml b/rokit.toml similarity index 50% rename from aftman.toml rename to rokit.toml index 5a02102..79cee3e 100644 --- a/aftman.toml +++ b/rokit.toml @@ -3,6 +3,8 @@ # To add a new tool, add an entry to this table. [tools] -rojo = "rojo-rbx/rojo@7.4.1" -wally = "upliftgames/wally@0.3.2" -selene = "kampfkarren/selene@0.27.1" +lune = "filiptibell/lune@0.10.4" +rojo = "rojo-rbx/rojo@7.6.1" +wally = "UpliftGames/wally@0.3.2" +wally-package-types = "JohnnyMorganz/wally-package-types@1.4.2" +selene = "Kampfkarren/selene@0.31.0" diff --git a/src/Animations/Base.lua b/src/Animations/Base.lua deleted file mode 100644 index 43e06be..0000000 --- a/src/Animations/Base.lua +++ /dev/null @@ -1,14 +0,0 @@ -local BaseAnimation = {} - -function BaseAnimation.new() - local self = {} - - self.listener = nil - self.SetListener = function(_, listener: (any) -> ()) - self.listener = listener - end - - return self -end - -return BaseAnimation diff --git a/src/Animations/Base.luau b/src/Animations/Base.luau new file mode 100644 index 0000000..cfda93b --- /dev/null +++ b/src/Animations/Base.luau @@ -0,0 +1,21 @@ +export type BaseAnimation = { + listener: ((any) -> ())?, + setListener: (BaseAnimation, (any) -> ()?) -> (), +} + +local function setListener(self: BaseAnimation, callback: ((any) -> ())?) + self.listener = callback +end + +local function BaseAnimation() + local object = {} + + object.listener = nil + object.setListener = setListener + + return object +end + +return { + new = BaseAnimation, +} diff --git a/src/Animations/Types/Spring.lua b/src/Animations/Types/Spring.lua deleted file mode 100644 index 3b22650..0000000 --- a/src/Animations/Types/Spring.lua +++ /dev/null @@ -1,95 +0,0 @@ -local BaseAnimation = require(script.Parent.Parent.Base) -local Promise = require(script.Parent.Parent.Parent.Promise) -local SpringValue = require(script.Parent.Parent.Parent.Utility.SpringValue) -local Symbols = require(script.Parent.Parent.Symbols) - -local Spring = {} -Spring.__index = Spring - -export type Spring = typeof(Spring.new()) -export type SpringProperties = { - damper: number?, - speed: number?, - start: any?, - target: any?, - force: any?, - delay: number?, -} - -function Spring.definition(props: SpringProperties) - return { - [1] = Symbols.Spring, - [2] = props, - } -end - -function Spring.new(props: SpringProperties) - local self = setmetatable(BaseAnimation.new(), Spring) :: Spring - - self.props = props - self.player = nil - - return self -end - -function Spring:Play(from: any?, immediate: boolean?) - if self.playing then - self:Stop() - end - - local baseFromValue = self.props.start or from :: any - local baseToValue = self.props.target :: any - local force = self.props.force :: any - - assert(baseFromValue, "No start value provided") - assert(baseToValue, "No target value provided") - - if baseFromValue == baseToValue and not force then - return Promise.resolve() - end - - local newSpring = SpringValue.new(baseFromValue, self.props.speed, self.props.damper) - newSpring:SetImmediate(immediate) - newSpring:SetGoal(baseToValue) - newSpring:SetDelay(self.props.delay) - - local oldVelocity = self._oldSpring and self._oldSpring:GetVelocity() - if oldVelocity then - newSpring:Impulse(oldVelocity) - end - - if force then - newSpring:Impulse(force) - end - - if self._oldSpring then - self._oldSpring:Destroy() - end - - local animation = newSpring:Run(function() - if self.listener then - self.listener(newSpring:GetValue()) - end - end) - - self.playing = true - self.player = animation - self._oldSpring = newSpring - - return animation -end - -function Spring:Stop() - if not self.playing then - return - end - - if self.player then - self.player:cancel() - self.player = nil - end - - self.playing = false -end - -return Spring diff --git a/src/Animations/Types/Spring.luau b/src/Animations/Types/Spring.luau new file mode 100644 index 0000000..e04b7f6 --- /dev/null +++ b/src/Animations/Types/Spring.luau @@ -0,0 +1,100 @@ +local BaseAnimation = require("../Base") +local Promise = require("../../Promise") +local SpringValue = require("../../Utility/SpringValue") +local Symbols = require("../Symbols") +local Types = require("../../Types") + +export type Spring = BaseAnimation.BaseAnimation & { + _oldSpring: any?, + + props: Types.SpringProperties, + player: any?, + playing: boolean?, + + play: (self: Spring, from: T?, immediate: boolean?) -> any, + stop: (self: Spring) -> (), +} + +local function definition(props: Types.SpringProperties) + return { + [1] = Symbols.Spring, + [2] = props, + } +end + +local function play(self: Spring, from: T?, immediate: boolean?) + if self.playing then + self:stop() + end + + local baseFromValue = self.props.start or from :: T + local baseToValue = self.props.target :: T + local force = self.props.force :: T? + + assert(baseFromValue, "No start value provided") + assert(baseToValue, "No target value provided") + + if baseFromValue == baseToValue and not force then + return Promise.resolve() + end + + local newSpring = SpringValue(baseFromValue, self.props.speed, self.props.damper) + newSpring:setImmediate(immediate) + newSpring:setGoal(baseToValue) + newSpring:setDelay(self.props.delay) + + local oldVelocity = self._oldSpring and self._oldSpring:getVelocity() + if oldVelocity then + newSpring:impulse(oldVelocity) + end + + if force then + newSpring:impulse(force) + end + + if self._oldSpring then + self._oldSpring:destroy() + end + + local animation = newSpring:run(function() + if self.listener then + self.listener(newSpring:getValue()) + end + end) + + self.playing = true + self.player = animation + self._oldSpring = newSpring + + return animation +end + +local function stop(self: Spring) + if not self.playing then + return + end + + if self.player then + self.player:cancel() + self.player = nil + end + + self.playing = false +end + +local function Spring(props: Types.SpringProperties): Spring + local object = BaseAnimation.new() :: Spring + + object.props = props + object.player = nil + + object.play = play + object.stop = stop + + return object +end + +return { + new = Spring, + definition = definition, +} diff --git a/src/Animations/Types/Tween.lua b/src/Animations/Types/Tween.luau similarity index 50% rename from src/Animations/Types/Tween.lua rename to src/Animations/Types/Tween.luau index f8d56fb..5ffe746 100644 --- a/src/Animations/Types/Tween.lua +++ b/src/Animations/Types/Tween.luau @@ -1,83 +1,30 @@ local RunService = game:GetService("RunService") local TweenService = game:GetService("TweenService") -local BaseAnimation = require(script.Parent.Parent.Base) -local Promise = require(script.Parent.Parent.Parent.Promise) -local LinearValue = require(script.Parent.Parent.Parent.Utility.LinearValue) -local Symbols = require(script.Parent.Parent.Symbols) - -local Tween = {} -Tween.__index = Tween - -type Callback = (T) -> () - -export type Tween = typeof(Tween.new()) -export type TweenProperties = { - info: TweenInfo, - startImmediate: T?, - start: T?, - target: T?, - delay: number?, +local BaseAnimation = require("../Base") +local Promise = require("../../Promise") +local LinearValue = require("../../Utility/LinearValue") +local PooledConnection = require("../../Utility/PooledConnection") +local Symbols = require("../Symbols") +local Types = require("../../Types") + +export type Tween = BaseAnimation.BaseAnimation & { + props: Types.TweenProperties, + player: any?, + playing: boolean?, + + play: (self: Tween, from: T?, immediate: boolean?) -> any, + stop: (self: Tween) -> (), } -local callbacks = {} -local pooledUpdateConnection: RBXScriptConnection? = nil - -local function pooledUpdate(callback: Callback): () -> () - callbacks[callback] = true - - if not pooledUpdateConnection then - pooledUpdateConnection = RunService.RenderStepped:Connect(function(dt) - local ran = false - - for nextCallback in callbacks do - ran = true - nextCallback(dt) - end - - if not ran and pooledUpdateConnection then - pooledUpdateConnection:Disconnect() - pooledUpdateConnection = nil - end - end) - end - - return function() - callbacks[callback] = nil - if next(callbacks) == nil and pooledUpdateConnection then - pooledUpdateConnection:Disconnect() - pooledUpdateConnection = nil - end - end -end - -local function playTween(tweenInfo, callback: (number) -> nil, completed: () -> nil) - local numberValue = Instance.new("NumberValue") - numberValue.Value = 0 - numberValue:GetPropertyChangedSignal("Value"):Connect(function() - callback(numberValue.Value) - end) - - local tween = TweenService:Create(numberValue, tweenInfo, { - Value = 1, - }) - - tween.Completed:Once(function() - numberValue:Destroy() - completed() - end) +local renderSteppedPool = PooledConnection(RunService.RenderStepped) - return function() - callback(0) - tween:Play() - end, function() - numberValue:Destroy() - tween:Cancel() - end -end - -local function playTween2(tweenInfo: TweenInfo, callback: Callback, completed: Callback) - local disconnect +local function playTween( + tweenInfo: TweenInfo, + callback: Types.TweenCallback, + completed: Types.TweenCallback +) + local connection: PooledConnection.PooledConnection? local repeats = 0 local elapsed = 0 @@ -94,9 +41,9 @@ local function playTween2(tweenInfo: TweenInfo, callback: Callback, comp assert(tweenReverses == false, "Tween reverses is not supported") local function stop() - if disconnect then - disconnect() - disconnect = nil + if connection then + connection:Disconnect() + connection = nil end end @@ -108,8 +55,8 @@ local function playTween2(tweenInfo: TweenInfo, callback: Callback, comp elapsed = -tweenDelay end - if not disconnect then - disconnect = pooledUpdate(function(dt) + if not connection then + connection = renderSteppedPool:Connect(function(dt) elapsed += dt local alpha = math.clamp(elapsed / tweenTime, 0, 1) @@ -134,25 +81,16 @@ local function playTween2(tweenInfo: TweenInfo, callback: Callback, comp return play, stop end -function Tween.definition(props: TweenProperties) +local function definition(props: Types.TweenProperties) return { [1] = Symbols.Tween, [2] = props, } end -function Tween.new(props: TweenProperties) - local self = setmetatable(BaseAnimation.new(), Tween) :: Tween - - self.props = props - self.player = nil - - return self -end - -function Tween:Play(from: any?, immediate: boolean?) +local function play(self: Tween, from: any?, immediate: boolean?) if self.playing then - self:Stop() + self:stop() end local tweenInfo = self.props.info :: TweenInfo @@ -183,8 +121,8 @@ function Tween:Play(from: any?, immediate: boolean?) return Promise.resolve() end - local fromValue = LinearValue.fromValue(baseFromValue) - local toValue = LinearValue.fromValue(baseToValue) + local fromValue = LinearValue(baseFromValue) + local toValue = LinearValue(baseToValue) if immediate then self.listener(baseToValue) @@ -196,8 +134,8 @@ function Tween:Play(from: any?, immediate: boolean?) end local animation = Promise.new(function(resolve, _, onCancel) - local play, cancel = playTween2(tweenInfo, function(value) - local newValue = fromValue:Lerp(toValue, value):ToValue() + local playTweenAnimation, cancel = playTween(tweenInfo, function(value) + local newValue = fromValue:lerp(toValue, value):toValue() self.listener(newValue) end, function() self.playing = false @@ -208,14 +146,14 @@ function Tween:Play(from: any?, immediate: boolean?) onCancel(cancel) if not delayTime then - play() + playTweenAnimation() else if startImmediately then self.listener(baseFromValue) end task.wait(delayTime) - play() + playTweenAnimation() end end) @@ -225,7 +163,7 @@ function Tween:Play(from: any?, immediate: boolean?) return animation end -function Tween:Stop() +local function stop(self: Tween) if not self.playing then return end @@ -238,4 +176,19 @@ function Tween:Stop() self.playing = false end -return Tween +local function Tween(props: Types.TweenProperties): Tween + local object = BaseAnimation.new() :: Tween + + object.props = props + object.player = nil + + object.play = play + object.stop = stop + + return object +end + +return { + new = Tween, + definition = definition, +} diff --git a/src/Animations/init.lua b/src/Animations/init.luau similarity index 53% rename from src/Animations/init.lua rename to src/Animations/init.luau index d89ee7c..db48e7e 100644 --- a/src/Animations/init.lua +++ b/src/Animations/init.luau @@ -5,7 +5,15 @@ local Animations = { Tween = require(script.Types.Tween), } -local function fromDefinition(definitions) +export type AnimationFromDefinition = { + listener: ((...any) -> ())?, + setListener: (AnimationFromDefinition, ((...any) -> ())?) -> (), + + play: (AnimationFromDefinition, from: T?, immediate: boolean?) -> (), + stop: (AnimationFromDefinition) -> (), +} + +local function fromDefinition(definitions): AnimationFromDefinition local animationSymbol, animationProps = definitions[1], definitions[2] local animationType = Symbol[animationSymbol] diff --git a/src/Components/DynamicList.lua b/src/Components/DynamicList.luau similarity index 93% rename from src/Components/DynamicList.lua rename to src/Components/DynamicList.luau index 49cc37e..c5e5e6d 100644 --- a/src/Components/DynamicList.lua +++ b/src/Components/DynamicList.luau @@ -1,11 +1,11 @@ -local React = require(script.Parent.Parent.React) -local ReactUtil = require(script.Parent.Parent.Utility.ReactUtil) +local React = require("../React") +local ReactUtil = require("../Utility/ReactUtil") +local memo = React.memo +local cloneElement = React.cloneElement local createElement = React.createElement local useState = React.useState local useEffect = React.useEffect -local memo = React.memo -local cloneElement = React.cloneElement -- DynamicList reconciles a dictionary of keyed children. -- Fixes: previous code wrapped ALL children with removal props each render causing churn & potential loops. diff --git a/src/Components/TransitionFragment.lua b/src/Components/TransitionFragment.luau similarity index 95% rename from src/Components/TransitionFragment.lua rename to src/Components/TransitionFragment.luau index 357bbdc..9dfc19d 100644 --- a/src/Components/TransitionFragment.lua +++ b/src/Components/TransitionFragment.luau @@ -1,5 +1,5 @@ -local React = require(script.Parent.Parent.React) -local ReactUtil = require(script.Parent.Parent.Utility.ReactUtil) +local React = require("../React") +local ReactUtil = require("../Utility/ReactUtil") local createElement = React.createElement local useState = React.useState diff --git a/src/Hooks/useAnimation.lua b/src/Hooks/useAnimation.lua deleted file mode 100644 index 67f6d08..0000000 --- a/src/Hooks/useAnimation.lua +++ /dev/null @@ -1,90 +0,0 @@ -local Promise = require(script.Parent.Parent.Promise) -local Animations = require(script.Parent.Parent.Animations) - -local React = require(script.Parent.Parent.React) -local useMemo = React.useMemo - -export type AnimationProps = { - [string]: any, -} - -local Animation = {} -Animation.__index = Animation - -function Animation.new(props: AnimationProps) - local self = setmetatable({}, Animation) - local animations = {} - - for name, animation in props do - animations[name] = Animations.fromDefinition(animation) - end - - self.playing = false - self.listener = nil - self.animation = animations - - for name, animation in animations do - animation:SetListener(function(value) - if self.listener then - self.listener(name, value) - end - end) - end - - return self -end - -function Animation:SetListener(listener: (string, any) -> ()) - self.listener = listener -end - -function Animation:Play(fromProps: AnimationProps, immediate: boolean?) - if self.playing then - self:Stop() - end - - local animation = Promise.new(function(resolve, _, onCancel) - local promises = {} - - for name, animatable in self.animation do - local animationPromise = animatable:Play(fromProps[name], immediate) - table.insert(promises, animationPromise) - end - - local awaiter = Promise.all(promises):andThen(resolve) - - onCancel(function() - awaiter:cancel() - - for _, promise in promises do - promise:cancel() - end - end) - end) - - self.playing = true - self.player = animation - - return animation -end - -function Animation:Stop() - if not self.playing then - return - end - - if self.player then - self.player:cancel() - self.player = nil - end - - self.playing = false -end - -local function useAnimation(props: AnimationProps) - return useMemo(function() - return Animation.new(props) - end, {}) -end - -return useAnimation diff --git a/src/Hooks/useAnimation.luau b/src/Hooks/useAnimation.luau new file mode 100644 index 0000000..0f12fdd --- /dev/null +++ b/src/Hooks/useAnimation.luau @@ -0,0 +1,97 @@ +local React = require("../React") +local Promise = require("../Promise") +local Animations = require("../Animations") +local Types = require("../Types") + +local useMemo = React.useMemo + +export type SingleAnimation = { + playing: boolean, + listener: Types.Listener?, + animations: { [string]: Animations.AnimationFromDefinition }, + setListener: (SingleAnimation, Types.Listener?) -> (), + play: (SingleAnimation, Types.AnimationProps, boolean?) -> (), + stop: (SingleAnimation) -> (), +} + +local function setListener(self: SingleAnimation, listener: Types.Listener?) + self.listener = listener +end + +local function play(self: SingleAnimation, fromProps: Types.AnimationProps, immediate: boolean?) + if self.playing then + self:stop() + end + + local animation = Promise.new(function(resolve, _, onCancel) + local promises = {} + + for name, animatable in self.animation do + local animationPromise = animatable:play(fromProps[name], immediate) + table.insert(promises, animationPromise) + end + + local awaiter = Promise.all(promises):andThen(resolve) + + onCancel(function() + awaiter:cancel() + + for _, promise in promises do + promise:cancel() + end + end) + end) + + self.playing = true + self.player = animation + + return animation +end + +local function stop(self: SingleAnimation) + if not self.playing then + return + end + + if self.player then + self.player:cancel() + self.player = nil + end + + self.playing = false +end + +local function Animation(props: Types.AnimationProps) + local object = {} :: SingleAnimation + local animations = {} :: { [string]: Animations.AnimationFromDefinition } + + object.playing = false + object.listener = nil + object.animation = animations + + object.play = play + object.stop = stop + object.setListener = setListener + + for name, animation in props do + animations[name] = Animations.fromDefinition(animation) + end + + for name, animation in animations do + animation:setListener(function(value) + if object.listener then + object.listener(name, value) + end + end) + end + + return object +end + +local function useAnimation(props: Types.AnimationProps) + return useMemo(function() + return Animation(props) + end, {}) :: SingleAnimation +end + +return useAnimation diff --git a/src/Hooks/useBindings.lua b/src/Hooks/useBindings.luau similarity index 95% rename from src/Hooks/useBindings.lua rename to src/Hooks/useBindings.luau index e71e80f..9e687d4 100644 --- a/src/Hooks/useBindings.lua +++ b/src/Hooks/useBindings.luau @@ -1,5 +1,7 @@ -local React = require(script.Parent.Parent.React) +local React = require("../React") + local useEffect = React.useEffect + -- TODO: Remove this when we have a better way to subscribe to bindings local subscribeToBinding = React.__subscribeToBinding diff --git a/src/Hooks/useGroupAnimation.lua b/src/Hooks/useGroupAnimation.lua deleted file mode 100644 index f70a040..0000000 --- a/src/Hooks/useGroupAnimation.lua +++ /dev/null @@ -1,117 +0,0 @@ -local React = require(script.Parent.Parent.React) -local useBinding = React.useBinding -local useMemo = React.useMemo - -local GroupAnimationController = {} -GroupAnimationController.__index = GroupAnimationController - -type StateSetters = { - [string]: (any) -> nil, -} - -export type Animation = { - Play: (DefaultProperties, boolean?) -> Animation, - Stop: () -> nil, -} - -export type GroupAnimation = { - [string]: Animation, -} - -export type DefaultProperties = { - [string]: any, -} - -function GroupAnimationController.new(props: GroupAnimation, default: DefaultProperties, setters: StateSetters) - local self = setmetatable({}, GroupAnimationController) - - self.currentState = "Default" - self.animations = props - - self.state = default - self.setters = setters - - for state, animation in props do - animation:SetListener(function(name, value) - if self.currentState ~= state then - return - end - - self.state[name] = value - self.setters[name](value) - end) - end - - return self -end - -function GroupAnimationController:Play(newState: string, immediate: boolean?) - local animation = self.animations[newState] - assert(animation, `No animation found for state {newState}`) - - self.currentState = newState - - if self.currentAnimation then - self.currentAnimation:Stop() - end - - self.currentAnimation = animation - animation:Play(self.state, immediate) -end - -function GroupAnimationController:Stop() - if self.currentAnimation then - self.currentAnimation:Stop() - self.currentAnimation = nil - end -end - -function GroupAnimationController:UpdateSetters(setters: StateSetters) - self.setters = setters -end - -local function getStateContainer(defaults: DefaultProperties) - local setters = {} - local values = {} - - for name, value in defaults do - local binding, updateBinding = useBinding(value) - - setters[name] = updateBinding - values[name] = binding - end - - return setters, values -end - -local function useGroupAnimation(props: GroupAnimation, default: DefaultProperties) - local defaults = useMemo(function() - return default - end, {}) - - local setters, values = getStateContainer(defaults) - local controller = useMemo(function() - local newController = GroupAnimationController.new(props, defaults, setters) - - return { - updateSetters = function(newSetters: StateSetters) - newController:UpdateSetters(newSetters) - end, - - play = function(newState: string, immediate: boolean?) - assert(typeof(newState) == "string", "useGroupAnimation expects a string 'state'") - newController:Play(newState, immediate) - end, - - stop = function() - newController:Stop() - end, - } - end, {}) - - controller.updateSetters(setters) - - return values, controller.play, controller.stop -end - -return useGroupAnimation diff --git a/src/Hooks/useGroupAnimation.luau b/src/Hooks/useGroupAnimation.luau new file mode 100644 index 0000000..89a24e3 --- /dev/null +++ b/src/Hooks/useGroupAnimation.luau @@ -0,0 +1,122 @@ +local React = require("../React") +local Types = require("../Types") + +local useBinding = React.useBinding +local useMemo = React.useMemo + +export type Animation = { + play: (Types.DefaultProperties, boolean?) -> Animation, + stop: () -> nil, +} +export type GroupAnimation = { [string]: Animation } +export type GroupAnimationController = { + currentstate: string, + animations: GroupAnimation, + state: Types.DefaultProperties, + setters: Types.StateSetters, + play: (GroupAnimationController, string, boolean?) -> (), + stop: (GroupAnimationController) -> (), + updateSetters: (GroupAnimationController, Types.StateSetters) -> (), +} + +local function getStateContainer(defaults: Types.DefaultProperties) + local setters = {} + local values = {} :: { [string]: unknown } + + for name, value in defaults do + local binding, updateBinding = useBinding(value) + + setters[name] = updateBinding + values[name] = binding + end + + return setters, values +end + +local function updateSetters(self: GroupAnimationController, setters: Types.StateSetters) + self.setters = setters +end + +local function play(self: GroupAnimationController, newState: string, immediate: boolean?) + local animation = self.animations[newState] + assert(animation, `No animation found for state {newState}`) + + self.currentState = newState + + if self.currentAnimation then + self.currentAnimation:stop() + end + + self.currentAnimation = animation + animation:play(self.state, immediate) +end + +local function stop(self: GroupAnimationController) + if self.currentAnimation then + self.currentAnimation:stop() + self.currentAnimation = nil + end +end + +local function GroupAnimationController(props: GroupAnimation, default: Types.DefaultProperties, setters: Types.StateSetters) + local object = {} :: GroupAnimationController + + object.currentState = "Default" + object.animations = props + + object.state = default + object.setters = setters + + object.play = play + object.stop = stop + object.updateSetters = updateSetters + + for state, animation in props do + animation:setListener(function(name, value) + if object.currentState ~= state then + return + end + + object.state[name] = value + object.setters[name](value) + end) + end + + return object +end + +local function useGroupAnimation(props: GroupAnimation, default: Types.DefaultProperties) + local defaults = useMemo(function() + return default + end, {}) + + local setters, values = getStateContainer(defaults) + local controller = useMemo(function() + local newController = GroupAnimationController(props, defaults, setters) + + return { + updateSetters = function(newSetters: Types.StateSetters) + newController:updateSetters(newSetters) + end, + + play = function(newState: string, immediate: boolean?) + assert(typeof(newState) == "string", "useGroupAnimation expects a string 'state'") + newController:play(newState, immediate) + end, + + stop = function() + newController:stop() + end, + } + end, {}) :: { + updateSetters: (setters: Types.StateSetters) -> (), + play: (state: string, immediate: boolean?) -> (), + stop: () -> (), + } + + controller.updateSetters(setters) + + return values, controller.play, controller.stop +end + +return useGroupAnimation diff --git a/src/Hooks/useSequenceAnimation.lua b/src/Hooks/useSequenceAnimation.luau similarity index 56% rename from src/Hooks/useSequenceAnimation.lua rename to src/Hooks/useSequenceAnimation.luau index 21e6bcd..093088f 100644 --- a/src/Hooks/useSequenceAnimation.lua +++ b/src/Hooks/useSequenceAnimation.luau @@ -1,80 +1,36 @@ -local Promise = require(script.Parent.Parent.Promise) -local Animations = require(script.Parent.Parent.Animations) +local React = require("../React") +local Promise = require("../Promise") +local Animations = require("../Animations") +local Types = require("../Types") -local React = require(script.Parent.Parent.React) local useMemo = React.useMemo -export type SequenceProps = { { timestamp: number } | any } - -local Sequence = {} -Sequence.__index = Sequence - -function Sequence.new(props: SequenceProps) - local self = setmetatable({}, Sequence) - local animations = {} - - for i, animation in props do - animations[i] = { - timestamp = animation.timestamp, - } - - for name, animatable in animation do - if name == "timestamp" then - continue - end - - animations[i][name] = Animations.fromDefinition(animatable) - end - end - - self.playing = false - self.listener = nil - self.animation = animations - - table.sort(animations, function(a, b) - return a.timestamp < b.timestamp - end) - - local lastTimestamp - - for _, animation in animations do - local timestamp = animation.timestamp - if lastTimestamp == timestamp then - error("Duplicate timestamp found in sequence") - end - - lastTimestamp = timestamp - - for name, animatable in animation do - if name == "timestamp" then - continue - end - - animatable:SetListener(function(value) - if self.listener then - self.listener(name, value) - end - end) - end - end - - return self -end - -function Sequence:SetListener(listener: (string, any) -> ()) +export type SequencedAnimations = { + [string]: { [string]: Animations.AnimationFromDefinition }, +} +export type Sequence = { + animations: { SequencedAnimations & { timestamp: number } }, + playing: boolean, + listener: Types.Listener?, + setListener: (Sequence, Types.Listener?) -> (), + play: (Sequence, Types.SequenceProps, boolean) -> (), + stop: (Sequence) -> (), +} + +local function setListener(self: Sequence, listener: Types.Listener?) self.listener = listener end -function Sequence:Play(fromProps: SequenceProps, immediate: boolean?) +local function play(self: Sequence, fromProps: Types.SequenceProps, immediate: boolean?) if self.playing then - self:Stop() + self:stop() end local animation = Promise.new(function(resolve, _, onCancel) local promises = {} local playing = {} - for _, sequenced in self.animation do + for _, sequenced in self.animations do local animationPromise = Promise.delay(if immediate then 0 else sequenced.timestamp):andThen(function() local allAnimatables = {} @@ -87,7 +43,7 @@ function Sequence:Play(fromProps: SequenceProps, immediate: boolean?) playing[name]:cancel() end - local promise = animatable:Play(fromProps[name], immediate) + local promise = animatable:play(fromProps[name], immediate) playing[name] = promise table.insert(allAnimatables, promise) @@ -116,7 +72,7 @@ function Sequence:Play(fromProps: SequenceProps, immediate: boolean?) return animation end -function Sequence:Stop() +local function stop(self: Sequence) if not self.playing then return end @@ -129,9 +85,65 @@ function Sequence:Stop() self.playing = false end -local function useSequenceAnimation(props: SequenceProps) +local function Sequence(props: Types.SequenceProps) + local object = {} :: Sequence + local animations = {} :: SequencedAnimations + + object.animations = animations + object.playing = false + object.listener = nil + + object.stop = stop + object.play = play + object.setListener = setListener + + for i, animation in props do + animations[i] = { + timestamp = animation.timestamp, + } + + for name, animatable: any in animation do + if name == "timestamp" then + continue + end + + animations[i][name] = Animations.fromDefinition(animatable) + end + end + + table.sort(animations, function(a, b) + return a.timestamp < b.timestamp + end) + + local lastTimestamp + + for _, animation in animations do + local timestamp = animation.timestamp + if lastTimestamp == timestamp then + error("Duplicate timestamp found in sequence") + end + + lastTimestamp = timestamp + + for name, animatable in animation do + if name == "timestamp" then + continue + end + + animatable:setListener(function(value) + if object.listener then + object.listener(name, value) + end + end) + end + end + + return object +end + +local function useSequenceAnimation(props: Types.SequenceProps) return useMemo(function() - return Sequence.new(props) + return Sequence(props) end, {}) end diff --git a/src/Hooks/useSpring.lua b/src/Hooks/useSpring.luau similarity index 50% rename from src/Hooks/useSpring.lua rename to src/Hooks/useSpring.luau index e9c4b1b..c5b5369 100644 --- a/src/Hooks/useSpring.lua +++ b/src/Hooks/useSpring.luau @@ -1,58 +1,58 @@ -local SpringValue = require(script.Parent.Parent.Utility.SpringValue) -local Spring = require(script.Parent.Parent.Animations.Types.Spring) +local React = require("../../React") +local SpringValue = require("../Utility/SpringValue") +local Types = require("../Types") -local React = require(script.Parent.Parent.React) local useBinding = React.useBinding local useMemo = React.useMemo local useEffect = React.useEffect -local function useSpring(props: Spring.SpringProperties) +local function useSpring(props: Types.SpringProperties): (React.Binding, Types.SpringStart, Types.SpringStop) local binding, update = useBinding(props.start) local controller = useMemo(function() - local spring = SpringValue.new(props.start, props.speed, props.damper) + local spring = SpringValue(props.start, props.speed, props.damper) return { spring = spring, - start = function(subProps: Spring.SpringProperties, immediate: boolean?) + start = function(subProps: Types.SpringProperties, immediate: boolean?) assert(typeof(subProps) == "table", "useSpring expects a table of properties") - spring:SetImmediate(immediate) + spring:setImmediate(immediate) if subProps.delay then - spring:SetDelay(subProps.delay) + spring:setDelay(subProps.delay) end if subProps.target then - spring:SetGoal(subProps.target) + spring:setGoal(subProps.target) end if subProps.start then - spring:SetValue(subProps.start) + spring:setValue(subProps.start) end if subProps.force then - spring:Impulse(subProps.force) + spring:impulse(subProps.force) end if subProps.damper then - spring:SetDamper(subProps.damper) + spring:setDamper(subProps.damper) end if subProps.speed then - spring:SetSpeed(subProps.speed) + spring:setSpeed(subProps.speed) end if subProps.target or subProps.start or subProps.force then - if not spring:Playing() then - spring:Run() + if not spring:playing() then + spring:run() end end end, stop = function() - if spring:Playing() then - spring:Stop() + if spring:playing() then + spring:stop() end end, } @@ -62,11 +62,11 @@ local function useSpring(props: Spring.SpringProperties) local spring = controller.spring return function() - spring:Stop() + spring:stop() end end, {}) - controller.spring:SetUpdater(update) + controller.spring:setUpdater(update) return binding, controller.start, controller.stop end diff --git a/src/Hooks/useSpringValue.luau b/src/Hooks/useSpringValue.luau new file mode 100644 index 0000000..6a45c1f --- /dev/null +++ b/src/Hooks/useSpringValue.luau @@ -0,0 +1,50 @@ +local React = require("../../React") +local SpringValue = require("../Utility/SpringValue") +local Types = require("../Types") + +local useBinding = React.useBinding +local useMemo = React.useMemo +local useEffect = React.useEffect + +local function useSpringValue(props: Types.SpringProperties): React.Binding + local initial = props.start or props.target + local binding, update = useBinding(initial) + + local spring = useMemo(function() + return SpringValue(initial, props.speed, props.damper) + end, {}) + + spring:setUpdater(update) + + useEffect(function() + return function() + spring:stop() + end + end, {}) + + useEffect(function() + if props.speed then + spring:setSpeed(props.speed) + end + + if props.damper then + spring:setDamper(props.damper) + end + end, { props.speed, props.damper } :: { any }) + + useEffect(function() + if props.target == nil then + return + end + + spring:setGoal(props.target) + + if not spring:playing() then + spring:run() + end + end, { props.target } :: { any }) + + return binding +end + +return useSpringValue diff --git a/src/Hooks/useTransparencyModifier.luau b/src/Hooks/useTransparencyModifier.luau new file mode 100644 index 0000000..4da4d42 --- /dev/null +++ b/src/Hooks/useTransparencyModifier.luau @@ -0,0 +1,63 @@ +local EPSILON = 1e-2 + +local React = require("../React") +local Types = require("../Types") + +local useBindings = require("./useBindings") +local useBinding = React.useBinding + +local function modifyNumberTransparency(modifier: number, transparency: number): number + local value = 1 - ((1 - transparency) * (1 - modifier)) + + -- clamp and round to 0 or 1 + if value < EPSILON then + return 0 + elseif value > 1 - EPSILON then + return 1 + end + + return value +end + +local function modifyNumberSequenceTransparency(modifier: number, sequence: NumberSequence): NumberSequence + local newKeypoints = table.create(#sequence.Keypoints) + + for _, keypoint in pairs(sequence.Keypoints) do + local time = keypoint.Time + local envelope = keypoint.Envelope + local value = modifyNumberTransparency(modifier, keypoint.Value) + + table.insert(newKeypoints, NumberSequenceKeypoint.new(time, value, envelope)) + end + + return NumberSequence.new(newKeypoints) +end + +local function useTransparencyModifier(modifier: Types.Binding): (Types.TransparencyValue) -> Types.TransparencyValue + return function(transparency: Types.TransparencyValue): Types.Binding + local isTransparencyBinding = type(transparency) == "table" and transparency.getValue + local baseTransparency = isTransparencyBinding and (transparency :: Types.Binding):getValue() or transparency or 0 + + local emptyBinding = useBinding(nil) + local modifiedTransparency, updateModifiedTransparency = useBinding(baseTransparency) + + useBindings(function(modifierValue, transparencyValue) + transparencyValue = transparencyValue or baseTransparency + + if modifierValue then + local modifierFunction = typeof(transparencyValue) == "number" and modifyNumberTransparency + or modifyNumberSequenceTransparency + + updateModifiedTransparency(modifierFunction(modifierValue, transparencyValue)) + else + updateModifiedTransparency(transparencyValue) + end + end, { modifier or emptyBinding, isTransparencyBinding and transparency or emptyBinding }, { + baseTransparency, + }) + + return modifiedTransparency + end +end + +return useTransparencyModifier diff --git a/src/Hooks/useTween.lua b/src/Hooks/useTween.luau similarity index 64% rename from src/Hooks/useTween.lua rename to src/Hooks/useTween.luau index 7a05d42..9ebb838 100644 --- a/src/Hooks/useTween.lua +++ b/src/Hooks/useTween.luau @@ -1,11 +1,12 @@ -local Tween = require(script.Parent.Parent.Animations.Types.Tween) -local React = require(script.Parent.Parent.React) +local React = require("../../React") +local Tween = require("../Animations/Types/Tween") +local Types = require("../Types") local useMemo = React.useMemo local useEffect = React.useEffect local useBinding = React.useBinding -local function useTween(props: Tween.TweenProperties) +local function useTween(props: Types.TweenProperties): (React.Binding, Types.TweenStart, Types.TweenStop) local binding, update = useBinding(props.start) local controller = useMemo(function() @@ -14,7 +15,7 @@ local function useTween(props: Tween.TweenProperties) return { tween = tween, - start = function(subProps: Tween.TweenProperties, immediate: boolean?) + start = function(subProps: Types.TweenProperties, immediate: boolean?) assert(typeof(subProps) == "table", "useTween expects a table of properties") tween.props.info = subProps.info or tween.props.info @@ -22,12 +23,12 @@ local function useTween(props: Tween.TweenProperties) tween.props.target = subProps.target or tween.props.target tween.props.startImmediate = subProps.startImmediate or tween.props.startImmediate tween.props.delay = subProps.delay or tween.props.delay - tween:Play(subProps.start or binding:getValue(), immediate) - end, + tween:play(subProps.start or binding:getValue(), immediate) + end :: Types.TweenStart, stop = function() - tween:Stop() - end, + tween:stop() + end :: Types.TweenStop, } end, {}) @@ -37,7 +38,7 @@ local function useTween(props: Tween.TweenProperties) end end, {}) - controller.tween:SetListener(update) + controller.tween:setListener(update) return binding, controller.start, controller.stop end diff --git a/src/Hooks/useTweenValue.luau b/src/Hooks/useTweenValue.luau new file mode 100644 index 0000000..cdbd1fc --- /dev/null +++ b/src/Hooks/useTweenValue.luau @@ -0,0 +1,46 @@ +local React = require("../../React") +local Tween = require("../Animations/Types/Tween") +local Types = require("../Types") + +local useBinding = React.useBinding +local useMemo = React.useMemo +local useEffect = React.useEffect + +local function useTweenValue(props: Types.TweenProperties): React.Binding + local initial = props.start or props.target + local binding, update = useBinding(initial) + + local tween = useMemo(function() + return Tween.new({ + info = props.info, + start = initial, + target = props.target, + delay = props.delay, + }) + end, {}) + + tween:setListener(update) + + useEffect(function() + return function() + tween:stop() + end + end, {}) + + useEffect(function() + if props.target == nil then + return + end + + tween.props.info = props.info or tween.props.info + tween.props.target = props.target + tween.props.delay = props.delay + tween.props.start = binding:getValue() + + tween:play(binding:getValue()) + end, { props.target } :: { any }) + + return binding +end + +return useTweenValue diff --git a/src/Promise.lua b/src/Promise.luau similarity index 100% rename from src/Promise.lua rename to src/Promise.luau diff --git a/src/React.lua b/src/React.luau similarity index 100% rename from src/React.lua rename to src/React.luau diff --git a/src/Types.luau b/src/Types.luau new file mode 100644 index 0000000..2deb70d --- /dev/null +++ b/src/Types.luau @@ -0,0 +1,58 @@ +local React = require("./React") + +export type ValueConstructor = (...any) -> T + +export type LinearValueType = + number + | UDim2 + | UDim + | Vector2 + | Vector3 + | Color3 + | ColorSequenceKeypoint + | NumberSequenceKeypoint + | NumberRange + | PhysicalProperties + | BrickColor + | CFrame + +export type SpringProperties = { + damper: number?, + speed: number?, + start: T?, + target: T?, + force: T?, + delay: number?, +} + +export type TweenCallback = (T) -> () + +export type TweenProperties = { + info: TweenInfo?, + startImmediate: T?, + start: T?, + target: T?, + delay: number?, +} + +export type Listener = (...any) -> () + +export type AnimationProps = { + [string]: any, +} + +export type StateSetters = { [string]: (any) -> nil } +export type DefaultProperties = { [string]: any } + +export type SequenceProps = { { timestamp: number, [string]: any } } + +export type SpringStart = (subProps: SpringProperties, immediate: boolean?) -> () +export type SpringStop = () -> () + +export type TweenStart = (subProps: TweenProperties, immediate: boolean?) -> () +export type TweenStop = () -> () + +export type Binding = React.Binding +export type TransparencyValue = number | NumberSequence | Binding | nil + +return {} diff --git a/src/Utility/LinearValue.lua b/src/Utility/LinearValue.lua deleted file mode 100644 index 7103153..0000000 --- a/src/Utility/LinearValue.lua +++ /dev/null @@ -1,82 +0,0 @@ -local LinearValue = {} - -export type LinearValueType = - number - | UDim2 - | UDim - | Vector2 - | Vector3 - | Color3 - | ColorSequenceKeypoint - | NumberSequenceKeypoint - | Rect - | NumberRange - | PhysicalProperties - | BrickColor - | CFrame - | Ray - | Region3 - | Region3int16 - -function LinearValue.fromValue(value: any) - local typeString = typeof(value) - - if typeString == "number" then - return LinearValue.new(nil, value) - elseif typeString == "UDim2" then - return LinearValue.new(UDim2.new, value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset) - elseif typeString == "UDim" then - return LinearValue.new(UDim.new, value.Scale, value.Offset) - elseif typeString == "Vector2" then - return LinearValue.new(Vector2.new, value.X, value.Y) - elseif typeString == "Vector3" then - return LinearValue.new(Vector3.new, value.X, value.Y, value.Z) - elseif typeString == "Color3" then - return LinearValue.new(Color3.new, value.R, value.G, value.B) - elseif typeString == "ColorSequenceKeypoint" then - return LinearValue.new(ColorSequenceKeypoint.new, value.Time, value.Value) - elseif typeString == "NumberSequenceKeypoint" then - return LinearValue.new(NumberSequenceKeypoint.new, value.Time, value.Value, value.Envelope) - elseif typeString == "NumberRange" then - return LinearValue.new(NumberRange.new, value.Min, value.Max) - elseif typeString == "PhysicalProperties" then - return LinearValue.new(PhysicalProperties.new, value.Density, value.Friction, value.Elasticity) - elseif typeString == "BrickColor" then - return LinearValue.new(Color3.new, value.Color.R, value.Color.G, value.Color.B) - elseif typeString == "CFrame" then - local x, y, z, r00, r01, r02, r10, r11, r12, r20, r21, r22 = value:components() - return LinearValue.new(CFrame.new, x, y, z, r00, r01, r02, r10, r11, r12, r20, r21, r22) - end - - assert(false, "Unsupported type: " .. typeString) -end - -function LinearValue.new(constructor, ...) - return table.freeze({ - _ccstr = constructor, - _value = { ... }, - - ToValue = LinearValue.ToValue, - Lerp = LinearValue.Lerp, - }) -end - -function LinearValue:ToValue() - if self._ccstr then - return self._ccstr(unpack(self._value)) - else - return unpack(self._value) - end -end - -function LinearValue:Lerp(other, alpha) - local newValues = {} - - for i = 1, #self._value do - newValues[i] = self._value[i] + (other._value[i] - self._value[i]) * alpha - end - - return LinearValue.new(self._ccstr, unpack(newValues)) -end - -return LinearValue diff --git a/src/Utility/LinearValue.luau b/src/Utility/LinearValue.luau new file mode 100644 index 0000000..8fb307d --- /dev/null +++ b/src/Utility/LinearValue.luau @@ -0,0 +1,111 @@ +local Quaternion = require("./Quaternion") +local Types = require("../Types") + +export type LinearValue = { + ccstr: Types.ValueConstructor, + values: { number }, + toValue: (LinearValue) -> T, + lerp: (LinearValue, LinearValue, number) -> LinearValue, +} + +local VALUE_CONSTRUCTORS: (...any) -> any = { + UDim2 = UDim2.new, + UDim = UDim.new, + Vector2 = Vector2.new, + Vector3 = Vector3.new, + Color3 = Color3.new, + ColorSequenceKeypoint = ColorSequenceKeypoint.new, + NumberSequenceKeypoint = NumberSequenceKeypoint.new, + NumberRange = NumberRange.new, + PhysicalProperties = PhysicalProperties.new, + BrickColor = BrickColor.new, + CFrame = CFrame.new, +} + +local VALUE_DECONSTRUCTORS = { + number = function(value: number) + return value + end, + UDim2 = function(value: UDim2) + return value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset + end, + UDim = function(value: UDim) + return value.Scale, value.Offset + end, + Vector2 = function(value: Vector2) + return value.X, value.Y + end, + Vector3 = function(value: Vector3) + return value.X, value.Y, value.Z + end, + Color3 = function(value: Color3) + return value.R, value.G, value.B + end, + ColorSequenceKeypoint = function(value: ColorSequenceKeypoint) + return value.Time, value.Value + end, + NumberSequenceKeypoint = function(value: NumberSequenceKeypoint) + return value.Time, value.Value, value.Envelope + end, + NumberRange = function(value: NumberRange) + return value.Min, value.Max + end, + PhysicalProperties = function(value: PhysicalProperties) + return value.Density, value.Friction, value.Elasticity + end, + BrickColor = function(value: BrickColor) + return value.Color.R, value.Color.G, value.Color.B + end, + CFrame = function(value: CFrame) + local x, y, z, m00, m01, m02, m10, m11, m12, m20, m21, m22 = value:GetComponents() + local qx, qy, qz, qw = Quaternion.canonicalize(Quaternion.fromMatrix(m00, m01, m02, m10, m11, m12, m20, m21, m22)) + return x, y, z, qx, qy, qz, qw + end, +} + +local LinearValue + +local function resolveFromLinearValue(ccstr: Types.ValueConstructor?, values: { number }): T + if ccstr then + return ccstr(unpack(values)) + end + + return unpack(values) +end + +local function toValue(self: LinearValue) + return resolveFromLinearValue(self.ccstr, self.values) +end + +local function lerp(self: LinearValue, other: LinearValue, alpha: number) + local values = {} + + for i = 1, #self.values do + values[i] = math.lerp(self.values[i], other.values[i], alpha) + end + + return LinearValue(resolveFromLinearValue(self.ccstr, values)) +end + +function LinearValue(value: T): LinearValue + local object: LinearValue = {} + + local valueType = typeof(value) + local deconstructor = VALUE_DECONSTRUCTORS[valueType] + + object.values = deconstructor and { deconstructor(value) } + object.ccstr = VALUE_CONSTRUCTORS[valueType] + + if not object.values then + error(`Unsupported type: {valueType}`, 2) + end + + object.toValue = toValue + object.lerp = lerp + + table.freeze(object) + + return object +end + +return LinearValue diff --git a/src/Utility/PooledConnection.luau b/src/Utility/PooledConnection.luau new file mode 100644 index 0000000..fcf3f5d --- /dev/null +++ b/src/Utility/PooledConnection.luau @@ -0,0 +1,78 @@ +export type ConnectionCallback = (...any) -> () + +export type PooledConnection = { + Connected: boolean, + Disconnect: (self: PooledConnection) -> (), +} + +export type ConnectionPool = { + Connect: (self: ConnectionPool, callback: ConnectionCallback) -> PooledConnection, + DisconnectAll: (self: ConnectionPool) -> (), +} + +local connectionPools = {} :: { [RBXScriptConnection]: ConnectionPool } + +local function PooledConnection(connection: RBXScriptConnection) + if connectionPools[connection] then + return connectionPools[connection] + end + + local object = {} :: ConnectionPool + + local callbacks = {} :: { [ConnectionCallback]: PooledConnection } + local globalConnection: RBXScriptConnection? + + function object:DisconnectAll() + if globalConnection then + globalConnection:Disconnect() + globalConnection = nil + end + + for _, callback in callbacks do + callback.Connected = false + end + + callbacks = {} + end + + function object:Connect(callback: ConnectionCallback) + local existing = callbacks[callback] + if existing then + return existing + end + + if not globalConnection then + globalConnection = connection:Connect(function(...) + for currentCallback in callbacks do + currentCallback(...) + end + end) + end + + local connected = { + Connected = true, + Disconnect = function(thisConnection: PooledConnection) + if not thisConnection.Connected then + return + end + + thisConnection.Connected = false + callbacks[callback] = nil + + if next(callbacks) == nil and globalConnection then + globalConnection:Disconnect() + globalConnection = nil + end + end, + } + + callbacks[callback] = connected + return connected + end + + connectionPools[connection] = object + + return object +end + +return PooledConnection diff --git a/src/Utility/Quaternion.luau b/src/Utility/Quaternion.luau new file mode 100644 index 0000000..86b3c70 --- /dev/null +++ b/src/Utility/Quaternion.luau @@ -0,0 +1,34 @@ +function fromMatrix( + m00: number, m01: number, m02: number, + m10: number, m11: number, m12: number, + m20: number, m21: number, m22: number +): (number, number, number, number) + local trace = m00 + m11 + m22 + + if trace > 0 then + local s = math.sqrt(trace + 1) * 2 + return (m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, 0.25 * s + elseif m00 > m11 and m00 > m22 then + local s = math.sqrt(1 + m00 - m11 - m22) * 2 + return 0.25 * s, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s + elseif m11 > m22 then + local s = math.sqrt(1 + m11 - m00 - m22) * 2 + return (m01 + m10) / s, 0.25 * s, (m12 + m21) / s, (m02 - m20) / s + else + local s = math.sqrt(1 + m22 - m00 - m11) * 2 + return (m02 + m20) / s, (m12 + m21) / s, 0.25 * s, (m10 - m01) / s + end +end + +local function canonicalize(qx: number, qy: number, qz: number, qw: number): (number, number, number, number) + if qw < 0 then + return -qx, -qy, -qz, -qw + end + + return qx, qy, qz, qw +end + +return { + fromMatrix = fromMatrix, + canonicalize = canonicalize, +} diff --git a/src/Utility/ReactUtil.lua b/src/Utility/ReactUtil.luau similarity index 100% rename from src/Utility/ReactUtil.lua rename to src/Utility/ReactUtil.luau diff --git a/src/Utility/SpringValue.lua b/src/Utility/SpringValue.lua deleted file mode 100644 index b462c15..0000000 --- a/src/Utility/SpringValue.lua +++ /dev/null @@ -1,224 +0,0 @@ -local RunService = game:GetService("RunService") - -local LinearValue = require(script.Parent.LinearValue) -local Promise = require(script.Parent.Parent.Promise) - -local SpringValue = {} -local SpringValues = {} -SpringValue.__index = SpringValue - -local EPSILON = 1e-2 - -function SpringValue.new(initial: LinearValue.LinearValueType, speed: number?, damper: number?) - local target = LinearValue.fromValue(initial) - local velocity = {} - - for i = 1, #target._value do - velocity[i] = 0 - end - - return setmetatable({ - _current = target, - _goal = target, - _velocities = velocity, - _speed = speed or 1, - _damper = damper or 1, - _immediate = false, - _updater = nil, - _delay = nil :: number?, - }, SpringValue) -end - -function SpringValue:Destroy() - SpringValues[self] = nil - setmetatable(self, nil) -end - -function SpringValue:Impulse(impulse: LinearValue.LinearValueType) - local impulseValues = LinearValue.fromValue(impulse)._value - for i = 1, #impulseValues do - self._velocities[i] = (self._velocities[i] or 0) + impulseValues[i] - end -end - -function SpringValue:GetVelocity() - return LinearValue.new(self._current._ccstr, unpack(self._velocities)):ToValue() -end - -function SpringValue:SetGoal(goal: LinearValue.LinearValueType) - self._goal = LinearValue.fromValue(goal) -end - -function SpringValue:SetSpeed(speed: number) - self._speed = speed -end - -function SpringValue:SetDamper(damper: number) - self._damper = damper -end - -function SpringValue:SetImmediate(immediate: boolean) - self._immediate = immediate -end - -function SpringValue:SetDelay(delay: number?) - if delay then - assert(delay >= 0, "Delay must be a non-negative number") - end - - self._delay = delay -end - -function SpringValue:SetUpdater(updater: (any) -> ()) - self._updater = updater - - if self:Playing() and updater then - updater(self:GetValue()) - end -end - -function SpringValue:GetGoal() - return self._goal:ToValue() -end - -function SpringValue:SetValue(value: LinearValue.LinearValueType) - self._current = LinearValue.fromValue(value) -end - -function SpringValue:GetValue() - return self._current:ToValue() -end - -function SpringValue:Update(dt: number) - local currentValues = self._current._value - local goalValues = self._goal._value - local velocities = self._velocities - - local newValues = {} - local updated = false - - for i = 1, #currentValues do - local goalValue = goalValues[i] - local baseValue, baseVelocity = currentValues[i], velocities[i] or 0 - local position, newVelocity = self:getPositionVelocity(dt, baseValue, baseVelocity, goalValue) - - newValues[i] = position - velocities[i] = newVelocity - - if math.abs(position - goalValue) > EPSILON or math.abs(newVelocity) > EPSILON then - updated = true - end - end - - self._current = LinearValue.new(self._current._ccstr, unpack(newValues)) - - return updated -end - -function SpringValue:Playing() - return SpringValues[self] ~= nil -end - -function SpringValue:Stop() - local value = SpringValues[self] - if value then - SpringValues[self] = nil - value() - end -end - -function SpringValue:Run(update: () -> ()?) - if update then - self._updater = update - end - - if self._immediate then - self._current = self._goal - - if self._updater then - self._updater(self:GetValue()) - end - - return Promise.resolve() - end - - return Promise.new(function(resolve, _, onCancel) - local cancelled = false - onCancel(function() - cancelled = true - self:Stop() - end) - - if self._delay then - task.wait(self._delay) - - if cancelled then - return - end - end - - if update then - update(self:GetValue()) - end - - SpringValues[self] = resolve - end) -end - --- credit to @Quenty --- https://github.com/Quenty/NevermoreEngine/blob/main/src/spring/src/Shared/Spring.lua -function SpringValue:getPositionVelocity(dt: number, current: number, velocity: number, target: number) - local p0 = current - local v0 = velocity - local p1 = target - local d = self._damper - local s = self._speed - - local t = s * dt - local d2 = d * d - - local h, si, co - if d2 < 1 then - h = math.sqrt(1 - d2) - local ep = math.exp(-d * t) / h - co, si = ep * math.cos(h * t), ep * math.sin(h * t) - elseif d2 == 1 then - h = 1 - local ep = math.exp(-d * t) / h - co, si = ep, ep * t - else - h = math.sqrt(d2 - 1) - local u = math.exp((-d + h) * t) / (2 * h) - local v = math.exp((-d - h) * t) / (2 * h) - co, si = u + v, u - v - end - - local a0 = h * co + d * si - local a1 = 1 - (h * co + d * si) - local a2 = si / s - - local b0 = -s * si - local b1 = s * si - local b2 = h * co - d * si - - return a0 * p0 + a1 * p1 + a2 * v0, b0 * p0 + b1 * p1 + b2 * v0 -end - -RunService:UnbindFromRenderStep("UPDATE_SPRING_VALUES") -RunService:BindToRenderStep("UPDATE_SPRING_VALUES", Enum.RenderPriority.First.Value, function(dt: number) - for spring, resolve in pairs(SpringValues) do - local didUpdate = spring:Update(dt) - local value = spring:GetValue() - - if spring._updater then - spring._updater(value) - end - - if not didUpdate then - SpringValues[spring] = nil - resolve() - end - end -end) - -return SpringValue diff --git a/src/Utility/SpringValue.luau b/src/Utility/SpringValue.luau new file mode 100644 index 0000000..afb7289 --- /dev/null +++ b/src/Utility/SpringValue.luau @@ -0,0 +1,311 @@ +local RunService = game:GetService("RunService") + +local LinearValue = require("./LinearValue") +local PooledConnection = require("./PooledConnection") +local Promise = require("../Promise") +local Types = require("../Types") + +export type SpringValue = { + current: LinearValue.LinearValue, + goal: LinearValue.LinearValue, + velocities: { number }, + speed: number, + damper: number, + immediate: boolean, + updater: ((any) -> ())?, + delay: number?, + + destroy: (SpringValue) -> (), + impulse: (SpringValue, Types.LinearValueType) -> (), + getVelocity: (SpringValue) -> T, + setGoal: (SpringValue, Types.LinearValueType) -> (), + setSpeed: (SpringValue, number) -> (), + setDamper: (SpringValue, number) -> (), + setImmediate: (SpringValue, boolean) -> (), + setDelay: (SpringValue, number?) -> (), + setUpdater: (SpringValue, (any) -> ()) -> (), + getGoal: (SpringValue) -> T, + setValue: (SpringValue, Types.LinearValueType) -> (), + getValue: (SpringValue) -> T, + update: (SpringValue, number) -> boolean, + playing: (SpringValue) -> boolean, + stop: (SpringValue) -> (), + run: (SpringValue, ((any) -> ())?) -> any, +} + +local EPSILON = 1e-2 + +local activeSprings = {} +local renderSteppedPool = PooledConnection(RunService.RenderStepped) +local tickConnection: PooledConnection.PooledConnection? = nil + +local SpringValue + +local function tick(dt: number) + local empty = true + + for spring, resolve in activeSprings do + empty = false + + local didUpdate = spring:update(dt) + local value = spring:getValue() + + if spring.updater then + spring.updater(value) + end + + if not didUpdate then + activeSprings[spring] = nil + resolve() + end + end + + if empty and tickConnection then + tickConnection:Disconnect() + tickConnection = nil + end +end + +local function ensurePooledUpdate() + if tickConnection then + return + end + + tickConnection = renderSteppedPool:Connect(tick) +end + +-- credit to @Quenty +-- https://github.com/Quenty/NevermoreEngine/blob/main/src/spring/src/Shared/Spring.lua +local function computeCoefficients(damper: number, speed: number, dt: number) + local d = damper + local s = speed + local t = s * dt + local d2 = d * d + + local h, si, co + if d2 < 1 then + h = math.sqrt(1 - d2) + local ep = math.exp(-d * t) / h + co, si = ep * math.cos(h * t), ep * math.sin(h * t) + elseif d2 == 1 then + h = 1 + local ep = math.exp(-d * t) + co, si = ep, ep * t + else + h = math.sqrt(d2 - 1) + local u = math.exp((-d + h) * t) / (2 * h) + local v = math.exp((-d - h) * t) / (2 * h) + co, si = u + v, u - v + end + + local a0 = h * co + d * si + local a1 = 1 - a0 + local a2 = si / s + local b0 = -s * si + local b1 = s * si + local b2 = h * co - d * si + + return a0, a1, a2, b0, b1, b2 +end + +local function destroy(self: SpringValue) + activeSprings[self] = nil +end + +local function impulse(self: SpringValue, value: Types.LinearValueType) + local impulseValues = LinearValue(value).values + for i = 1, #impulseValues do + self.velocities[i] += impulseValues[i] + end +end + +local function getVelocity(self: SpringValue) + if self.current.ccstr then + return self.current.ccstr(unpack(self.velocities)) + end + + return unpack(self.velocities) +end + +local function alignQuaternionSign(self: SpringValue) + if self.goal.ccstr ~= CFrame.new then + return + end + + local currentValues = self.current.values + local goalValues = self.goal.values + + local dot = currentValues[4] * goalValues[4] + + currentValues[5] * goalValues[5] + + currentValues[6] * goalValues[6] + + currentValues[7] * goalValues[7] + + if dot < 0 then + goalValues[4] = -goalValues[4] + goalValues[5] = -goalValues[5] + goalValues[6] = -goalValues[6] + goalValues[7] = -goalValues[7] + end +end + +local function setGoal(self: SpringValue, goal: Types.LinearValueType) + self.goal = LinearValue(goal) + alignQuaternionSign(self) +end + +local function setSpeed(self: SpringValue, speed: number) + self.speed = speed +end + +local function setDamper(self: SpringValue, damper: number) + self.damper = damper +end + +local function setImmediate(self: SpringValue, immediate: boolean) + self.immediate = immediate +end + +local function setDelay(self: SpringValue, delay: number?) + if delay then + assert(delay >= 0, "Delay must be a non-negative number") + end + + self.delay = delay +end + +local function playing(self: SpringValue) + return activeSprings[self] ~= nil +end + +local function getValue(self: SpringValue) + return self.current:toValue() +end + +local function setUpdater(self: SpringValue, updater: (any) -> ()) + self.updater = updater + + if playing(self) and updater then + updater(getValue(self)) + end +end + +local function getGoal(self: SpringValue) + return self.goal:toValue() +end + +local function setValue(self: SpringValue, value: Types.LinearValueType) + self.current = LinearValue(value) + alignQuaternionSign(self) +end + +local function update(self: SpringValue, dt: number) + local currentValues = self.current.values + local goalValues = self.goal.values + local velocities = self.velocities + local updated = false + + local a0, a1, a2, b0, b1, b2 = computeCoefficients(self.damper, self.speed, dt) + + for i = 1, #currentValues do + local goalValue = goalValues[i] + local current = currentValues[i] + local velocity = velocities[i] + + local position = a0 * current + a1 * goalValue + a2 * velocity + local newVelocity = b0 * current + b1 * goalValue + b2 * velocity + + currentValues[i] = position + velocities[i] = newVelocity + + if math.abs(position - goalValue) > EPSILON or math.abs(newVelocity) > EPSILON then + updated = true + end + end + + return updated +end + +local function stop(self: SpringValue) + local resolve = activeSprings[self] + if resolve then + activeSprings[self] = nil + resolve() + end +end + +local function run(self: SpringValue, updater: ((any) -> ())?) + if updater then + self.updater = updater + end + + if self.immediate then + local current = self.current.values + local goal = self.goal.values + for i = 1, #current do + current[i] = goal[i] + end + + if self.updater then + self.updater(getValue(self)) + end + + return Promise.resolve() + end + + return Promise.new(function(resolve, _, onCancel) + local cancelled = false + onCancel(function() + cancelled = true + stop(self) + end) + + if self.delay then + task.wait(self.delay) + + if cancelled then + return + end + end + + if updater then + updater(getValue(self)) + end + + activeSprings[self] = resolve + ensurePooledUpdate() + end) +end + +function SpringValue(initial: Types.LinearValueType, speed: number?, damper: number?): SpringValue + local object: SpringValue = {} :: any + + object.current = LinearValue(initial) + object.goal = LinearValue(initial) + object.velocities = table.create(#object.current.values, 0) + object.speed = speed or 1 + object.damper = damper or 1 + object.immediate = false + object.updater = nil + object.delay = nil + + object.destroy = destroy + object.impulse = impulse + object.getVelocity = getVelocity + object.setGoal = setGoal + object.setSpeed = setSpeed + object.setDamper = setDamper + object.setImmediate = setImmediate + object.setDelay = setDelay + object.setUpdater = setUpdater + object.getGoal = getGoal + object.setValue = setValue + object.getValue = getValue + object.update = update + object.playing = playing + object.stop = stop + object.run = run + + return object +end + +return SpringValue diff --git a/src/init.lua b/src/init.luau similarity index 76% rename from src/init.lua rename to src/init.luau index bc563e2..aa70d9f 100644 --- a/src/init.lua +++ b/src/init.luau @@ -11,7 +11,11 @@ return { useSpring = require(script.Hooks.useSpring), useTween = require(script.Hooks.useTween), + useSpringValue = require(script.Hooks.useSpringValue), + useTweenValue = require(script.Hooks.useTweenValue), + useBindings = require(script.Hooks.useBindings), + useTransparencyModifier = require(script.Hooks.useTransparencyModifier), TransitionFragment = require(script.Components.TransitionFragment), DynamicList = require(script.Components.DynamicList), diff --git a/test/CFrameValues.story.luau b/test/CFrameValues.story.luau new file mode 100644 index 0000000..61253a7 --- /dev/null +++ b/test/CFrameValues.story.luau @@ -0,0 +1,122 @@ +local ReplicatedStorage = game:GetService("ReplicatedStorage") +local RunService = game:GetService("RunService") + +local packages = ReplicatedStorage.Packages + +local ORIGIN = CFrame.new(0, 10, 0) +local LANE_SPACING = 6 +local TOGGLE_INTERVAL = 2 +local SPRING_CONFIGS = { + { name = "Underdamped", color = Color3.fromRGB(255, 120, 120), speed = 6, damper = 0.3, lane = -1 }, + { name = "CriticallyDamped", color = Color3.fromRGB(120, 255, 160), speed = 6, damper = 1.0, lane = 0 }, + { name = "Overdamped", color = Color3.fromRGB(120, 180, 255), speed = 6, damper = 1.6, lane = 1 }, +} + +local function createPart(name: string, color: Color3, cframe: CFrame): Part + local part = Instance.new("Part") + part.Name = name + part.Anchored = true + + part.CanCollide = false + part.Size = Vector3.new(3, 3, 3) + + part.Color = color + part.Material = Enum.Material.Neon + + part.CFrame = cframe + + return part +end + +return function(_container) + local ReactAnimation = ReplicatedStorage.ReactAnimation + ReactAnimation.Parent = packages + + local SpringValue = require(packages.ReactAnimation.Utility.SpringValue) + local LinearValue = require(packages.ReactAnimation.Utility.LinearValue) + + local folder = Instance.new("Folder") + folder.Name = "CFrameValueStory" + folder.Parent = workspace + + local connections: { RBXScriptConnection } = {} + local threads: { thread } = {} + local springs: { any } = {} + + local function laneOrigin(lane: number) + return ORIGIN * CFrame.new(lane * LANE_SPACING, 0, 0) + end + + local targetOffsets = { + CFrame.new(0, 0, 0), + CFrame.new(0, 6, 0) * CFrame.Angles(0, math.pi / 2, 0), + CFrame.new(0, 4, 6) * CFrame.Angles(math.pi, 0, math.pi / 3), + CFrame.new(0, 2, -6) * CFrame.Angles(0, -math.pi, math.pi / 2), + } + + for _, cfg in SPRING_CONFIGS do + local origin = laneOrigin(cfg.lane) + + local part = createPart(cfg.name, cfg.color, origin) + part.Parent = folder + + local spring = SpringValue(part.CFrame, cfg.speed, cfg.damper) + spring:setUpdater(function(cframe: CFrame) + part.CFrame = cframe + end) + + spring:run() + + table.insert(springs, spring) + table.insert( + threads, + task.spawn(function() + local index = 1 + + while true do + spring:setGoal(origin * targetOffsets[index]) + + if not spring:playing() then + spring:run() + end + + index = (index % #targetOffsets) + 1 + task.wait(TOGGLE_INTERVAL) + end + end) + ) + end + + local linearOrigin = laneOrigin(2.5) + local linearPart = createPart("LinearLerp", Color3.fromRGB(255, 220, 120), linearOrigin) + + local linearStart = LinearValue(linearOrigin) + local linearGoal = LinearValue(linearOrigin * CFrame.new(0, 6, 0) * CFrame.Angles(math.pi, math.pi / 2, 0)) + + local clock = 0 + local lerpConnection = RunService.Heartbeat:Connect(function(dt: number) + clock += dt + + local alpha = (math.sin(clock * 1.5) + 1) / 2 + linearPart.CFrame = linearStart:lerp(linearGoal, alpha):toValue() + end) + + table.insert(connections, lerpConnection) + + return function() + for _, conn in connections do + conn:Disconnect() + end + + for _, thread in threads do + task.cancel(thread) + end + + for _, spring in springs do + spring:destroy() + end + + folder:Destroy() + ReactAnimation.Parent = ReplicatedStorage + end +end diff --git a/test/Test.lua b/test/Test.luau similarity index 70% rename from test/Test.lua rename to test/Test.luau index 7791381..e012293 100644 --- a/test/Test.lua +++ b/test/Test.luau @@ -3,13 +3,17 @@ local Packages = ReplicatedStorage:WaitForChild("Packages") local React = require(Packages.React) local useEffect = React.useEffect +local useState = React.useState local createElement = React.createElement local ReactAnimation = require(Packages.ReactAnimation) +local useTransparencyModifier = ReactAnimation.useTransparencyModifier local useGroupAnimation = ReactAnimation.useGroupAnimation local useSequenceAnimation = ReactAnimation.useSequenceAnimation local useSpring = ReactAnimation.useSpring local useTween = ReactAnimation.useTween +local useSpringValue = ReactAnimation.useSpringValue +local useTweenValue = ReactAnimation.useTweenValue local useBindingEffect = ReactAnimation.useBindings local useAnimation = ReactAnimation.useAnimation local Spring = ReactAnimation.Spring @@ -212,7 +216,7 @@ local function TestTween() useBindingEffect(function(v2) if v2.R == 0 then - update2({ start = Color3.new(0, 1, 9), target = Color3.new(1, 0, 0) }) + update2({ start = Color3.new(0, 1, 9 :: any), target = Color3.new(1, 0, 0) }) elseif v2.R == 1 then update2({ target = Color3.new(0, 1, 0) }) end @@ -261,21 +265,138 @@ local function TestSpring() }) end +-- -- @TestSpringValue (declarative) +local function TestSpringValue() + local toggled, setToggled = useState(false) + + local value = useSpringValue({ + start = UDim2.fromScale(0, 0), + target = if toggled then UDim2.fromScale(1, 0) else UDim2.fromScale(-1, 0), + speed = 5, + damper = 0.7, + }) + + useEffect(function() + local running = true + local thread = task.spawn(function() + while running do + task.wait(3) + if not running then + break + end + setToggled(function(v) + return not v + end) + end + end) + + return function() + running = false + task.cancel(thread) + end + end, {}) + + return createFrame({ + Name = "Spring Value", + Position = value, + }) +end + +-- -- @TestTweenValue (declarative) +local function TestTweenValue() + local toggled, setToggled = useState(false) + + local value = useTweenValue({ + info = TweenInfo.new(1), + start = UDim2.fromScale(0, 0), + target = if toggled then UDim2.fromScale(1, 0) else UDim2.fromScale(0, 0), + }) + + useEffect(function() + local running = true + local thread = task.spawn(function() + while running do + task.wait(2) + if not running then + break + end + setToggled(function(v) + return not v + end) + end + end) + + return function() + running = false + task.cancel(thread) + end + end, {}) + + return createFrame({ + Name = "Tween Value", + Position = value, + }) +end + +-- -- @TestTransparency +local function TestTransparency() + local value, update = useTween({ + start = 0, + target = 0, + info = TweenInfo.new(1, Enum.EasingStyle.Linear), + }) + local transparency = useTransparencyModifier(value) + + useEffect(function() + local running = true + local thread = task.spawn(function() + while running do + update({ target = 1 }) + task.wait(1) + + if not running then + break + end + + update({ target = 0 }) + task.wait(1) + end + end) + + return function() + running = false + task.cancel(thread) + end + end, {}) + + return createFrame({ + Name = "Transparency", + BackgroundTransparency = transparency(0), + Size = UDim2.fromScale(1.4, 1), + Position = value:map(function(position: number) + return UDim2.fromScale(position, 0) + end), + }) +end + -- @entry local function Test() return createElement("Frame", { - Size = UDim2.fromScale(1, 1), + Size = UDim2.fromScale(0.5, 0.5), BackgroundTransparency = 1, }, { uiListLayout = createElement("UIListLayout", { HorizontalAlignment = Enum.HorizontalAlignment.Center, - VerticalAlignment = Enum.VerticalAlignment.Center, + VerticalAlignment = Enum.VerticalAlignment.Top, }), tween = createElement(TestTween), spring = createElement(TestSpring), + springValue = createElement(TestSpringValue), + tweenValue = createElement(TestTweenValue), sequence = createElement(TestSequence), animation = createElement(TestAnimation), + transaprency = createElement(TestTransparency), }) end diff --git a/test/init.client.lua b/test/init.client.luau similarity index 100% rename from test/init.client.lua rename to test/init.client.luau diff --git a/test/init.story.lua b/test/init.story.luau similarity index 100% rename from test/init.story.lua rename to test/init.story.luau diff --git a/wally.toml b/wally.toml index 18f5aa3..587a9ed 100644 --- a/wally.toml +++ b/wally.toml @@ -1,7 +1,7 @@ [package] name = "outofbears/react-flow" description = "React Animation Library for Roblox" -version = "0.4.0" +version = "0.5.0" authors = ["Jack Fox "] homepage = "https://github.com/outofbears/react-flow" registry = "https://github.com/UpliftGames/wally-index"