Skip to content

Instantly share code, notes, and snippets.

@makirby
Created February 14, 2020 11:07
Show Gist options
  • Save makirby/af261c45c4f98f860bd802095fd443e6 to your computer and use it in GitHub Desktop.
Save makirby/af261c45c4f98f860bd802095fd443e6 to your computer and use it in GitHub Desktop.
Interactable view in typescript with modifications
import React, { Component } from 'react'
import Animated from 'react-native-reanimated'
import { PanGestureHandler, State as GestureState } from 'react-native-gesture-handler'
import { StyleProp, ViewStyle, ViewProps, InteractionManager } from 'react-native'
import _ from 'lodash'
const {
add,
cond,
diff,
divide,
eq,
event,
exp,
lessThan,
and,
call,
block,
multiply,
pow,
set,
abs,
clockRunning,
greaterOrEq,
lessOrEq,
sqrt,
startClock,
stopClock,
sub,
Clock,
Value,
onChange,
} = Animated
const ANIMATOR_PAUSE_CONSECUTIVE_FRAMES = 10
const ANIMATOR_PAUSE_ZERO_VELOCITY = 1
const DEFAULT_SNAP_TENSION = 300
const DEFAULT_SNAP_DAMPING = 0.65
const DEFAULT_GRAVITY_STRENGTH = 400
const DEFAULT_GRAVITY_FALLOF = 40
function sq(x: Animated.Adaptable<number>) {
return multiply(x, x)
}
function influenceAreaWithRadius(radius: number, anchor: GravityPoint) {
return {
left: (anchor.x || 0) - radius,
right: (anchor.x || 0) + radius,
top: (anchor.y || 0) - radius,
bottom: (anchor.y || 0) + radius,
}
}
function snapTo(
target: TossedTarget,
snapPoints: SnapPoint[],
best: SnapAnchor,
clb?: OnSnap,
dragClb?: OnDrag
): Animated.Node<number>[] {
const dist = new Value(0)
const snap = (pt: SnapPoint) => [
set(best.tension, pt.tension || DEFAULT_SNAP_TENSION),
set(best.damping, pt.damping || DEFAULT_SNAP_DAMPING),
set(best.x, pt.x || 0),
set(best.y, pt.y || 0),
]
const snapDist = (pt: SnapPoint) =>
add(sq(sub(target.x, pt.x || 0)), sq(sub(target.y, pt.y || 0)))
const arr = [
set(dist, snapDist(snapPoints[0])),
...snap(snapPoints[0]),
...snapPoints.map(pt => {
const newDist = snapDist(pt)
return cond(lessThan(newDist, dist), [set(dist, newDist), ...snap(pt)])
}),
]
if (clb || dragClb) {
arr.push(call([best.x, best.y, target.x, target.y], ([bx, by, x, y]) => {
snapPoints.forEach((pt, index) => {
if (
(pt.x === undefined || pt.x === bx) &&
(pt.y === undefined || pt.y === by)
) {
clb && clb({ nativeEvent: { ...pt, index } })
dragClb &&
dragClb({
nativeEvent: { x, y, targetSnapPointId: pt.id, state: 'end' },
})
}
})
}))
}
return arr
}
function springBehavior(
dt: Animated.Node<number>,
target: AdaptableTarget,
obj: BounceObj,
anchor: AdaptableTarget,
tension: Animated.Adaptable<number> = 300,
): Behavior {
const dx = sub(target.x, anchor.x);
const ax = divide(multiply(-1, tension, dx), obj.mass);
const dy = sub(target.y, anchor.y);
const ay = divide(multiply(-1, tension, dy), obj.mass);
return {
x: set(obj.vx, add(obj.vx, multiply(dt, ax))),
y: set(obj.vy, add(obj.vy, multiply(dt, ay))),
};
}
function frictionBehavior(
dt: Animated.Node<number>,
target: AdaptableTarget,
obj: BounceObj,
damping: Animated.Adaptable<number> = 0.7,
): Behavior {
const friction = pow(damping, multiply(60, dt));
return {
x: set(obj.vx, multiply(obj.vx, friction)),
y: set(obj.vy, multiply(obj.vy, friction)),
};
}
function anchorBehavior(
dt: Animated.Node<number>,
target: AdaptableTarget,
obj: BounceObj,
anchor: AdaptableTarget,
): Behavior {
const dx = sub(anchor.x, target.x);
const dy = sub(anchor.y, target.y);
return {
x: set(obj.vx, divide(dx, dt)),
y: set(obj.vy, divide(dy, dt)),
};
}
function gravityBehavior(
dt: Animated.Node<number>,
target: AdaptableTarget,
obj: BounceObj,
anchor: AdaptableTarget,
strength = DEFAULT_GRAVITY_STRENGTH,
falloff = DEFAULT_GRAVITY_FALLOF
): Behavior {
const dx = sub(target.x, anchor.x);
const dy = sub(target.y, anchor.y);
const drsq = add(sq(dx), sq(dy));
const dr = sqrt(drsq);
const a = divide(
multiply(-1, strength, dr, exp(divide(multiply(-0.5, drsq), sq(falloff)))),
obj.mass
);
const div = divide(a, dr);
return {
x: cond(dr, set(obj.vx, add(obj.vx, multiply(dt, dx, div)))),
y: cond(dr, set(obj.vy, add(obj.vy, multiply(dt, dy, div)))),
};
}
function bounceBehavior(
dt: Animated.Node<number>,
target: AdaptableTarget,
obj: BounceObj,
area: Boundaries,
bounce = 0,
): BounceBehavior {
const xnodes: Animated.Node<number>[] = [];
const ynodes: Animated.Node<number>[] = [];
const flipx = set(obj.vx, multiply(-1, obj.vx, bounce));
const flipy = set(obj.vy, multiply(-1, obj.vy, bounce));
if (area.left !== undefined) {
xnodes.push(cond(and(eq(target.x, area.left), lessThan(obj.vx, 0)), flipx));
}
if (area.right !== undefined) {
xnodes.push(
cond(and(eq(target.x, area.right), lessThan(0, obj.vx)), flipx)
);
}
if (area.top !== undefined) {
xnodes.push(cond(and(eq(target.y, area.top), lessThan(obj.vy, 0)), flipy));
}
if (area.bottom !== undefined) {
xnodes.push(
cond(and(eq(target.y, area.bottom), lessThan(0, obj.vy)), flipy)
);
}
return {
x: xnodes,
y: ynodes,
};
}
export type Area = {
left?: Animated.Adaptable<number>;
right?: Animated.Adaptable<number>;
top?: Animated.Adaptable<number>;
bottom?: Animated.Adaptable<number>;
}
export type AdaptableTarget = {
x: Animated.Adaptable<number>;
y: Animated.Adaptable<number>;
};
export type AnimatedTarget = {
x: Animated.Value<number>;
y: Animated.Value<number>;
};
function withInfluence(
area: Area | undefined | null,
target: AdaptableTarget,
behavior: Behavior,
) {
if (!area) {
return behavior
}
const testLeft = area.left === undefined || lessOrEq(area.left, target.x)
const testRight = area.right === undefined || lessOrEq(target.x, area.right)
const testTop = area.top === undefined || lessOrEq(area.top, target.y);
const testBottom = area.bottom === undefined || lessOrEq(target.y, area.bottom)
const testNodes = [testLeft, testRight, testTop, testBottom].filter(
(t) => t !== true
)
// @ts-ignore there is always more than one node
const test = and(...testNodes)
return {
x: cond(test, behavior.x),
y: cond(test, behavior.y),
}
}
function withLimits(
value: Animated.Node<number>,
lowerBound: number | undefined,
upperBound: number | undefined
) {
let result = value
if (lowerBound !== undefined) {
result = cond(lessThan(value, lowerBound), lowerBound, result)
}
if (upperBound !== undefined) {
result = cond(lessThan(upperBound, value), upperBound, result)
}
return result
}
export type SnapAnchor = {
x: Animated.Value<number>;
y: Animated.Value<number>;
tension: Animated.Value<number>;
damping: Animated.Value<number>;
}
export type InfluenceArea = {
x?: Animated.Adaptable<number>;
y?: Animated.Adaptable<number>;
};
export type DragEvent = {
nativeEvent: {
x: number;
y: number;
targetSnapPointId?: string;
state: string;
};
};
type BounceBehavior = {
x: Animated.Node<number>[];
y: Animated.Node<number>[];
}
type Behavior = {
x: Animated.Node<number>;
y: Animated.Node<number>;
}
export type SnapEvent = { nativeEvent: SnapPoint & { index: number; id: string } };
export type Position = { x?: number; y?: number };
export type SnapPoint = {
id: string;
x?: number;
y?: number;
tension?: number;
damping?: number;
/** Used only by the first snap point. Ignored if set on others. */
overdragMaxDistance?: number;
};
export type Boundaries = {
left?: number;
right?: number;
top?: number;
bottom?: number;
bounce?: number;
};
export type BounceObj = {
vx: Animated.Value<number>;
vy: Animated.Value<number>;
mass: number;
};
export type TossedTarget = { x: Animated.Node<number>; y: Animated.Node<number> };
export type OnDrag = (event: DragEvent) => void;
export type OnSnap = (event: SnapEvent) => void;
export type StopEvent = { nativeEvent: { x: number | undefined; y: number | undefined } }
export type OnStop = (event: StopEvent) => void;
export type GravityPoint = {
x: number;
y: number;
strength: number;
tension: number;
falloff: number;
influenceArea: Area;
damping: number;
}
export type FrictionArea = {
damping?: number;
influenceArea?: Area;
}
export type SpringPoint = {
x: number;
y: number;
tension: number;
influenceArea: Area;
damping: number;
}
export type Buckets = [(Behavior | BounceBehavior)[], Behavior[], Behavior[]]
type Props = {
dragToss: number;
dragEnabled?: boolean;
initialPosition: { x: number; y: number };
animatedValueX?: Animated.Value<number>;
animatedValueY?: Animated.Value<number>;
horizontalOnly?: boolean;
verticalOnly?: boolean;
snapPoints: SnapPoint[];
dragWithSpring?: { tension: number; damping: number };
onSnap?: OnSnap;
onDrag?: OnDrag;
// Remove for perf/not used currently
// onStop?: OnStop;
springPoints?: SpringPoint[];
gravityPoints?: GravityPoint[];
frictionAreas?: FrictionArea[];
boundaries?: Boundaries;
style?: StyleProp<ViewStyle>;
onSnapPointsChange?: (ref: Interactable) => void;
} & ViewProps
type State = {
snapPoints: SnapPoint[];
_transY: Animated.Node<number>;
}
class Interactable extends Component<Props, State> {
static defaultProps = {
dragToss: 0,
dragEnabled: true,
initialPosition: { x: 0, y: 0 },
};
_snapAnchor: SnapAnchor;
_dragging: AnimatedTarget;
_position: AnimatedTarget;
_velocity: AnimatedTarget;
_transX: Animated.Node<number>;
_transFunc: (
axis: string,
vaxis: string,
lowerBound: keyof Boundaries,
upperBound: keyof Boundaries,
updatedSnapTo: Animated.Node<number>[],
snapPoints: SnapPoint[],
boundaries: Boundaries | undefined,
) => Animated.Node<number>;
_onGestureEvent: () => void;
_updateSnapToFunc: (snapPoints: SnapPoint[]) => Animated.Node<number>[];
constructor (props: Props) {
super(props)
const gesture = { x: new Value(0), y: new Value(0) };
const state = new Value(-1);
this._onGestureEvent = event([
{
nativeEvent: {
translationX: gesture.x,
translationY: gesture.y,
state: state,
},
},
])
const target = {
x: new Value(props.initialPosition.x || 0),
y: new Value(props.initialPosition.y || 0),
}
const update = {
x: props.animatedValueX,
y: props.animatedValueY,
}
const clock = new Clock()
const dt = divide(diff(clock), 1000)
const obj = {
vx: new Value(0),
vy: new Value(0),
mass: 1,
}
const tossedTarget = {
x: add(target.x, multiply(props.dragToss, obj.vx)),
y: add(target.y, multiply(props.dragToss, obj.vy)),
}
const permBuckets: Buckets = [[], [], []]
const addSpring = (
anchor: AdaptableTarget,
tension: Animated.Adaptable<number>,
influence: Area | undefined | null,
buckets = permBuckets
) => {
buckets[0].push(
withInfluence(
influence,
target,
springBehavior(dt, target, obj, anchor, tension)
)
)
}
const addFriction = (
damping: Animated.Adaptable<number> | undefined,
influence: Area | undefined | null,
buckets = permBuckets,
) => {
buckets[1].push(
withInfluence(
influence,
target,
frictionBehavior(dt, target, obj, damping)
)
)
}
const addGravity = (
anchor: AdaptableTarget,
strength: number,
falloff: number,
influence: Area,
buckets = permBuckets
) => {
buckets[0].push(
withInfluence(
influence,
target,
gravityBehavior(dt, target, obj, anchor, strength, falloff)
)
)
}
const addOverdragResistance = (
snapPoints: SnapPoint[],
_target: AdaptableTarget,
) => {
const firstSnapPoint = snapPoints[0]
if (firstSnapPoint.overdragMaxDistance) {
const firstSnapPointY = firstSnapPoint.y as number
const firstSnapPointDamping = firstSnapPoint.damping || DEFAULT_SNAP_DAMPING
const overdragDistance = abs(sub(firstSnapPointY, _target.y))
const overdragMaxDistance = abs(firstSnapPoint.overdragMaxDistance)
const overdragInterpolation = divide(sub(overdragMaxDistance, overdragDistance), overdragMaxDistance)
const overdragDecay = cond(
greaterOrEq(overdragInterpolation, 0),
pow(overdragInterpolation, 3),
0,
)
const resistance = cond(
lessThan(obj.vy, 0), // if is dragging up
multiply(firstSnapPointDamping, overdragDecay), // apply overdrag decay
firstSnapPointDamping // else, is dragging down, so don't apply overdrag decay
)
dragBuckets[1][0] = (
withInfluence(
{ bottom: firstSnapPointY },
_target,
frictionBehavior(dt, _target, obj, resistance)
)
)
} else {
// make sure the behavior is removed
dragBuckets[1] = []
}
}
const dragAnchor = { x: new Value(0), y: new Value(0) }
const dragBuckets: Buckets = [[], [], []]
if (props.dragWithSpring) {
const { tension, damping } = props.dragWithSpring
addSpring(dragAnchor, tension, null, dragBuckets)
addFriction(damping, null, dragBuckets)
} else {
dragBuckets[0].push(anchorBehavior(dt, target, obj, dragAnchor))
}
const handleStartDrag =
props.onDrag &&
call([target.x, target.y], ([x, y]) => {
// @ts-ignore this is already checked one line above
props.onDrag({ nativeEvent: { x, y, state: 'start' } })
})
const snapBuckets: Buckets = [[], [], []];
const snapAnchor = {
x: new Value(props.initialPosition.x || 0),
y: new Value(props.initialPosition.y || 0),
tension: new Value(DEFAULT_SNAP_TENSION),
damping: new Value(DEFAULT_SNAP_DAMPING),
}
const updateSnapToFunc = (newSnapPoints: SnapPoint[]) => snapTo(
tossedTarget,
newSnapPoints,
snapAnchor,
props.onSnap,
props.onDrag
)
this._updateSnapToFunc = updateSnapToFunc
const updateSnapTo = this._updateSnapToFunc(props.snapPoints)
addSpring(snapAnchor, snapAnchor.tension, null, snapBuckets);
addFriction(snapAnchor.damping, null, snapBuckets);
if (props.springPoints) {
props.springPoints.forEach(pt => {
addSpring(pt, pt.tension, pt.influenceArea);
if (pt.damping) {
addFriction(pt.damping, pt.influenceArea);
}
})
}
if (props.gravityPoints) {
props.gravityPoints.forEach(pt => {
const falloff = pt.falloff || DEFAULT_GRAVITY_FALLOF;
addGravity(pt, pt.strength, falloff, pt.influenceArea);
if (pt.damping) {
const influenceArea =
pt.influenceArea || influenceAreaWithRadius(1.4 * falloff, pt);
addFriction(pt.damping, influenceArea);
}
})
}
if (props.frictionAreas) {
props.frictionAreas.forEach(pt => {
addFriction(pt.damping, pt.influenceArea);
})
}
if (props.boundaries) {
snapBuckets[0].push(
bounceBehavior(
dt,
target,
obj,
props.boundaries,
props.boundaries.bounce
)
);
}
// behaviors can go under one of three buckets depending on their priority
// we append to each bucket but in Interactable behaviors get added to the
// front, so we join in reverse order and then reverse the array.
const sortBuckets = (specialBuckets: Buckets) => ({
x: specialBuckets
.map((b, idx) => [...permBuckets[idx], ...b].reverse().map(b => b.x))
.reduce((acc, b) => acc.concat(b), []),
y: specialBuckets
.map((b, idx) => [...permBuckets[idx], ...b].reverse().map(b => b.y))
.reduce((acc, b) => acc.concat(b), []),
})
// dragBehaviors is sorted inside transFunc because it needs to be
// called after addOverdragResistance to use updated snap points
const snapBehaviors = sortBuckets(snapBuckets)
const noMovementFrames = {
x: new Value(
props.verticalOnly ? ANIMATOR_PAUSE_CONSECUTIVE_FRAMES + 1 : 0
),
y: new Value(
props.horizontalOnly ? ANIMATOR_PAUSE_CONSECUTIVE_FRAMES + 1 : 0
),
};
const stopWhenNeeded = cond(
and(
greaterOrEq(noMovementFrames.x, ANIMATOR_PAUSE_CONSECUTIVE_FRAMES),
greaterOrEq(noMovementFrames.y, ANIMATOR_PAUSE_CONSECUTIVE_FRAMES)
),
[
// TODO: onStop implementation
// props.onStop && cond(
// clockRunning(clock),
// call([target.x, target.y], ([x, y]) => {
// props.onStop && props.onStop({ nativeEvent: { x, y } })
// })
// ),
stopClock(clock),
],
startClock(clock)
)
const transFunc = (
axis: string,
vaxis: string,
lowerBound: keyof Boundaries,
upperBound: keyof Boundaries,
updatedSnapTo: Animated.Node<number>[],
snapPoints: SnapPoint[],
boundaries: Boundaries | undefined,
) => {
const dragging = new Value(0)
const start = new Value(0)
const x = target[axis]
const vx = obj[vaxis]
const anchor = dragAnchor[axis]
const drag = gesture[axis]
let advance = cond(
lessThan(abs(vx), ANIMATOR_PAUSE_ZERO_VELOCITY),
x,
add(x, multiply(vx, dt))
);
if (boundaries) {
advance = withLimits(
advance,
boundaries[lowerBound],
boundaries[upperBound]
)
}
const last = new Value(Number.MAX_SAFE_INTEGER)
const noMoveFrameCount = noMovementFrames[axis]
// Fix for snapTo not firing https://github.com/kmagiera/react-native-reanimated/issues/182#issuecomment-519632308
const testMovementFrames = block([
onChange(snapAnchor.x, set(last, Number.MAX_SAFE_INTEGER)),
onChange(snapAnchor.y, set(last, Number.MAX_SAFE_INTEGER)),
cond(
eq(advance, last),
set(noMoveFrameCount, add(noMoveFrameCount, 1)),
[set(last, advance), set(noMoveFrameCount, 0)]
),
])
addOverdragResistance(snapPoints, target)
const dragBehaviors = sortBuckets(dragBuckets)
const step = cond(
eq(state, GestureState.ACTIVE),
[
cond(dragging,
0,
// @ts-ignore Undefined is handled ok but not typed
[
handleStartDrag,
startClock(clock),
set(dragging, 1),
set(start, x),
]
),
set(anchor, add(start, drag)),
cond(dt, dragBehaviors[axis]),
],
[
cond(clockRunning(clock), 0, startClock(clock)),
cond(dragging, [updatedSnapTo, set(dragging, 0)]),
cond(dt, snapBehaviors[axis]),
testMovementFrames,
stopWhenNeeded,
]
)
const wrapStep = props.dragEnabled
// @ts-ignore cond type says it cant operate on bool although it can
? cond(props.dragEnabled, step, [set(dragging, 1), stopClock(clock)])
: step
// export some values to be available for imperative commands
this._dragging[axis] = dragging
this._velocity[axis] = vx
// update animatedValueX/animatedValueY
const doUpdateAnReturn = update[axis] ? set(update[axis], x) : x
return block([wrapStep, set(x, advance), doUpdateAnReturn])
}
// save ouput of func
this._transFunc = transFunc
// variables to be used to access reanimated values from imperative commands
this._dragging = {} as AnimatedTarget;
this._velocity = {} as AnimatedTarget;
this._position = target;
this._snapAnchor = snapAnchor
this._transX = this._transFunc('x', 'vx', 'left', 'right', updateSnapTo, props.snapPoints, props.boundaries)
this.state = {
_transY: this._transFunc('y', 'vy', 'top', 'bottom', updateSnapTo, props.snapPoints, props.boundaries),
snapPoints: props.snapPoints,
}
}
componentDidUpdate() {
if (!_.isEqual(this.props.snapPoints, this.state.snapPoints)) {
const updateSnapTo = this._updateSnapToFunc(this.props.snapPoints)
// Update vertical translation
this.setState({
_transY: this._transFunc('y', 'vy', 'top', 'bottom', updateSnapTo, this.props.snapPoints, this.props.boundaries),
snapPoints: this.props.snapPoints
}, () => {
this.props.onSnapPointsChange && this.props.onSnapPointsChange(this)
})
}
}
render() {
const { children, style, horizontalOnly, verticalOnly } = this.props
return (
<PanGestureHandler
minDist={10}
maxPointers={1}
enabled={this.props.dragEnabled}
onGestureEvent={this._onGestureEvent}
onHandlerStateChange={this._onGestureEvent}>
<Animated.View
style={[
style,
{
transform: [
{
translateX: verticalOnly ? 0 : this._transX,
translateY: horizontalOnly ? 0 : this.state._transY,
},
],
},
]}>
{children}
</Animated.View>
</PanGestureHandler>
);
}
// imperative commands
setVelocity({ x, y }: Position) {
if (x !== undefined) {
this._dragging.x.setValue(1);
this._velocity.x.setValue(x);
}
if (y !== undefined) {
this._dragging.y.setValue(1);
this._velocity.y.setValue(y);
}
}
snapTo({ index }: { index: number }) {
const snapPoint = this.state.snapPoints[index];
this._snapAnchor.tension.setValue(
snapPoint.tension || DEFAULT_SNAP_TENSION
);
this._snapAnchor.damping.setValue(
snapPoint.damping || DEFAULT_SNAP_DAMPING
);
this._snapAnchor.x.setValue(snapPoint.x || 0);
this._snapAnchor.y.setValue(snapPoint.y || 0);
InteractionManager.runAfterInteractions(() => {
this.props.onSnap &&
this.props.onSnap({ nativeEvent: { ...snapPoint, index } });
})
}
changePosition({ x, y }: Position) {
if (x !== undefined) {
this._dragging.x.setValue(1);
this._position.x.setValue(x);
}
if (y !== undefined) {
this._dragging.x.setValue(1);
this._position.y.setValue(y);
}
}
}
export default Interactable;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment