Using Otter with React¶
ReactOtter¶
React and Otter can be used together easily with the ReactOtter
package exported from this repo. Install it instead of (or in addition to) the base Otter package:
[dependencies]
ReactOtter = "github.com/Roblox/otter@1.0"
# You'll only need this if you use Otter directly
Otter = "github.com/Roblox/otter@1.0"
useAnimatedBinding¶
ReactOtter provides a React hook called useAnimatedBinding
, a simple and expressive mechanism to drive Otter animations within React function components.
It accepts two arguments:
initialValue: number | { [string]: number }
- The starting value for the binding (similar to the value you'd pass toReact.useBinding
or the value you'd pass toOtter.createSingleMotor
/Otter.createGroupMotor
)- This can be either a single
number
value or a table that maps string keys to number values;useAnimatedBinding
will automatically use the right representation under the hood
- This can be either a single
onComplete: nil | (number | { [string]: number }) -> ()
- An optional parameter that will be called each time an animated transition completes
The hook returns two values to use in your component:
value: React.Binding<number | { [string]: number }>
- aReact.Binding
object that will be updated with the motor's progress value at each step while it's runningsetGoal: (ReactOtter.Goal) -> ()
- a function that can be used to provide new target values calledgoal
s to the animated binding, akin to Otter'sMotor:setGoal(goal)
Provide the binding to a property that you'd like to animate (or use Binding:map
to transform its value) and use the setGoal
function to trigger the animation.
Example: Simple Usage¶
The useAnimatedBinding
hook should be sufficient for most use cases of Otter in React components. In a simple case, it can be used to animate from one value to another. A silly example might look like:
local function ToggleTextSize()
local toggled, setToggled = React.useState(false)
local value, setGoal = ReactOtter.useAnimatedBinding(8)
React.useEffect(function()
setGoal(ReactOtter.spring(if toggled then 24 else 8))
end, { toggled })
return React.createElement("TextButton", {
Text = "Hello",
TextSize = value,
Size = UDim2.new(0, 200, 0, 50),
[React.Event.Activated] = function()
setToggled(not toggled)
end,
})
end
Example: Mapped Values¶
Most of the time, you won't want to use your animation value directly. Often you'll want to animate between two position or size values. Instead of needing our animation value to be the exact derived value we configure our elements with, we can use any arbitrary animation progress values (0
and 1
is nice and simple) and use the map
capability of bindings:
local function ExpandableFrame(props: Props)
local expanded, setExpanded = React.useState(false)
local height, setGoal = useAnimatedBinding(0)
React.useEffect(function()
setGoal(if expanded then 1 else 0)
end, { expanded })
return React.createElement(
"Frame",
{ Size = props.size },
React.createElement("TextButton", {
Text = if expanded then "Collapse" else "Expand"
Size = UDim2.new(1, 0, 0, 40),
}),
React.createElement("Frame", {
Position = UDim2.new(0, 0, 0, 40),
-- Derive the UDim2 size value from the animation progress value
Size = height:map(function(value)
return UDim2.new(1, 0, value, -40 * value),
end),
}, props.content),
)
end
Example: Side Effects On Completion¶
In some cases, you may want to delay other side effects until an animation completes. In these scenarios, you can provide an onComplete
callback as a second argument to useAnimatedBinding
. Use this callback to update state/bindings, run passed-in callbacks, or trigger other side effects.
local function DisabledWhileAnimating()
local enabled, setEnabled = React.useState(false)
local translated, setTranslated = React.useState(false)
local x, setGoal = ReactOtter.useAnimatedBinding(0, function()
setEnabled(true)
end)
React.useEffect(function()
setGoal(ReactOtter.spring(if translated then 1 else 0))
setEnabled(false)
end, { translated })
return React.createElement(
"Frame",
{ Size = props.size },
React.createElement("TextButton", {
Active = enabled,
Text = if enabled then "Click me" else "Don't click",
Size = UDim2.new(0, 150, 0, 50),
Position = x:map(function(value)
return UDim2.new(value, -150 * value, 0.5, 0)
end),
[React.Event.Activated] = function()
setTranslated(not translated)
end,
})
)
end
Example: Multiple Values¶
Sometimes, it may be preferable to animate several values at once rather than a single value. While you can typically map everything back to a single animation progress value (as shown in the Mapped Values example), a table of separate values might be clearer.
In the example below, we want to animate the transparency of the panel that's fading in with a different spring configuration than what we're using to animate its scale:
local TRANSPARENCY_CONFIG = {
dampingRatio = 1,
frequency = 3,
}
local function FadeAndGrowIn(props)
local visible, setVisible = React.useState(false)
local animationState, setGoal = ReactOtter.useAnimatedBinding({
transparency = 1,
scale = 0.8,
})
React.useEffect(function()
setGoal({
transparency = ReactOtter.spring(
if visible then 0 else 1,
TRANSPARENCY_CONFIG
),
-- default spring config
scale = ReactOtter.spring(if visible then 1 else 0.8),
})
end, { visible })
return React.createElement(
"Frame",
{ Size = props.size },
React.createElement("TextButton", {
Text = if visible then "Hide" else "Show",
Size = UDim2.new(0, 200, 0, 50),
[React.Event.Activated] = function()
setVisible(not visible)
end,
}),
React.createElement("TextLabel", {
Text = "Content",
BackgroundTransparency = animationState:map(function(state)
return state.transparency
end),
Size = animationState:map(function(state)
return UDim2.new(1 * state.scale, 0, 0, 200 * state.scale)
end),
AnchorPoint = Vector2.new(0.5, 0.5),
Position = UDim2.new(0.5, 0, 0.5, 25),
})
)
end
useMotor¶
In rare cases, you may need your React component to update values on non-React Instances in the DataModel. To do this, you can use a slightly more lower-level hook called useMotor
.
Warning
In most cases, it's preferable to use the binding semantics afforded by useAnimatedBinding
to reduce boilerplate and keep your use cases simple and idiomatic. Only reach for useMotor
if you need to animate something that can't accept a binding.
The useMotor
hook accepts three arguments:
initialValue: number | { [string]: number }
- The starting value for the motor. This works exactly like theinitialValue
argument touseAnimatedBinding
.onStep: (number | { [string]: number }) -> ()
- A callback that fires on each step. The current value of the motor will be passed in, honoring the type of yourinitialValue
just asuseAnimatedBinding
does.onComplete: nil | (number | { [string]: number }) -> ()
- An optional parameter that will be called each time an animated transition completes. This works exactly like theonComplete
argument touseAnimatedBinding
.
It returns only a setGoal
function, equivalent to the second return value from useAnimatedBinding
.