Skip to content

Instantly share code, notes, and snippets.

@likern
Created August 28, 2020 15:49
Show Gist options
  • Save likern/2d5c36d9859f2e68f65c6cd538498178 to your computer and use it in GitHub Desktop.
Save likern/2d5c36d9859f2e68f65c6cd538498178 to your computer and use it in GitHub Desktop.
import React, { useCallback, useState } from 'react';
import Animated, {
measure,
cancelAnimation,
useAnimatedRef,
useSharedValue,
useDerivedValue,
withTiming,
Easing,
useAnimatedStyle,
interpolate,
Extrapolate,
useAnimatedGestureHandler
} from 'react-native-reanimated';
import { PanGestureHandler } from 'react-native-gesture-handler';
import { ViewStyleProp } from '../../../../types';
import { PropsWithChildren } from 'react';
import { StyleSheet } from 'react-native';
import { Icon } from '../..';
const DEFAULTS = Object.freeze({
SWIPE_THRESHOLD: 0.5,
ACTIVATION_THRESHOLD: 8,
ACTIVATION_OFFSET: 8,
ANIMATION_DURATION: 0.05,
BORDER_RADIUS: 16,
LEFT_ICON_COLOR: 'rgba(255, 255, 255, 1)',
RIGHT_ICON_COLOR: 'rgba(255, 255, 255, 1)'
});
type Callback = () => void;
export type SwipableRowProps = {
threshold?: number;
onSwipeLeft?: Callback;
onSwipeRight?: Callback;
} & ViewStyleProp;
export const SwipableRow = ({
threshold = DEFAULTS.SWIPE_THRESHOLD,
children,
style,
onSwipeLeft: _onSwipeLeft,
onSwipeRight: _onSwipeRight
}: PropsWithChildren<SwipableRowProps>) => {
const viewRef = useAnimatedRef();
const rowHeight = useSharedValue<undefined | number>(undefined);
const positionX = useSharedValue(0);
const swipeDirection = useSharedValue<'left' | 'right' | undefined>(
undefined
);
const _setInternalState = useState({})[1];
const forceRerender = useCallback(() => {
_setInternalState({});
}, [_setInternalState]);
const callbackWrapper = useCallback(
(callback: Callback) => {
forceRerender();
callback();
},
[forceRerender]
);
const onSwipeLeft = useCallback(() => {
_onSwipeLeft && callbackWrapper(_onSwipeLeft);
}, [_onSwipeLeft, callbackWrapper]);
const onSwipeRight = useCallback(() => {
_onSwipeRight && callbackWrapper(_onSwipeRight);
}, [_onSwipeRight, callbackWrapper]);
const leftIconOpacity = useDerivedValue(() => {
return positionX.value > 0 ? 1 : 0;
});
const rightIconOpacity = useDerivedValue(() => {
return positionX.value < 0 ? 1 : 0;
});
const leftBorderRadius = useDerivedValue(() => {
return interpolate(
positionX.value,
[0, DEFAULTS.ACTIVATION_THRESHOLD],
[0, DEFAULTS.BORDER_RADIUS],
Extrapolate.CLAMP
);
});
const rightBorderRadius = useDerivedValue(() => {
return interpolate(
positionX.value,
[-DEFAULTS.ACTIVATION_THRESHOLD, 0],
[DEFAULTS.BORDER_RADIUS, 0],
Extrapolate.CLAMP
);
});
const gestureHandler = useAnimatedGestureHandler({
onFinish: (_, ctx) => {
console.log(
`[SwipableRow:onFinish] ctx.swipeStarted: ${JSON.stringify(
ctx.swipeStarted
)}`
);
},
onCancel: (_, ctx) => {
console.log(
`[SwipableRow:onCancel] ctx.swipeStarted: ${JSON.stringify(
ctx.swipeStarted
)}`
);
},
onFail: (_, ctx) => {
console.log(
`[SwipableRow:onFail] ctx.swipeStarted: ${JSON.stringify(
ctx.swipeStarted
)}`
);
},
onStart: (_, ctx) => {
console.log(
`[SwipableRow:onStart] ctx.swipeStarted: ${JSON.stringify(
ctx.swipeStarted
)}`
);
cancelAnimation(positionX);
ctx.position = positionX.value;
},
onActive: (event, ctx) => {
let translation = event.translationX;
// console.log(`[SwipableRow:onActive] translationX: ${translation}`);
const useThreshold =
Math.abs(ctx.position + translation) < DEFAULTS.ACTIVATION_THRESHOLD;
if (!ctx.swipeStarted) {
if (useThreshold) {
translation = 0;
} else {
ctx.swipeStarted = true;
}
}
positionX.value = ctx.position + translation;
},
onEnd: (event, ctx) => {
console.log(
`[SwipableRow:onEnd] ctx.swipeStarted: ${JSON.stringify(
ctx.swipeStarted
)}`
);
const { width, height } = measure(viewRef);
rowHeight.value = height;
const moveDirection = Math.sign(event.velocityX);
const impulse = event.velocityX * DEFAULTS.ANIMATION_DURATION;
const possibleProgress = interpolate(
positionX.value + impulse,
[-width, width],
[-1, 1],
Extrapolate.CLAMP
);
const swipeThresholdPassed = Math.abs(possibleProgress) > threshold;
let toValue = 0;
if (swipeThresholdPassed) {
if (moveDirection === 0) {
toValue = Math.sign(possibleProgress) * width;
} else {
if (positionX.value > 0 && moveDirection > 0) {
toValue = moveDirection * width;
} else if (positionX.value < 0 && moveDirection < 0) {
toValue = moveDirection * width;
}
}
}
const isRunAnimation = positionX.value !== toValue;
positionX.value = withTiming(
toValue,
{
duration: isRunAnimation ? 250 : 0,
easing: Easing.out(Easing.ease)
},
(isFinished) => {
if (isFinished) {
ctx.swipeStarted = false;
if (Math.abs(toValue) === width) {
rowHeight.value = 0;
}
const sign = Math.sign(toValue);
if (sign === 1) {
swipeDirection.value = 'right';
} else if (sign === -1) {
swipeDirection.value = 'left';
}
}
}
);
}
});
const foregroundStyle = useAnimatedStyle(() => {
return {
borderTopLeftRadius: leftBorderRadius.value,
borderBottomLeftRadius: leftBorderRadius.value,
borderTopRightRadius: rightBorderRadius.value,
borderBottomRightRadius: rightBorderRadius.value,
transform: [{ translateX: positionX.value }]
};
});
const backgroundStyle = useAnimatedStyle(() => {
return {
backgroundColor:
positionX.value === 0
? 'transparent'
: positionX.value > 0
? 'rgba(45, 154, 252, 0.5)'
: 'rgba(252, 119, 110, 1)'
};
});
const containerStyle = useAnimatedStyle(() => {
return {
height:
rowHeight.value !== undefined
? withTiming(
rowHeight.value,
{
duration: 250,
easing: Easing.out(Easing.ease)
},
(isFinished) => {
if (isFinished) {
if (swipeDirection.value === 'left') {
onSwipeLeft();
} else if (swipeDirection.value === 'right') {
onSwipeRight();
}
}
}
)
: undefined
};
});
const leftIconStyle = useAnimatedStyle(() => {
return {
opacity: leftIconOpacity.value
};
});
const rightIconStyle = useAnimatedStyle(() => {
return {
opacity: rightIconOpacity.value
};
});
return (
<Animated.View style={[styles.container, containerStyle]}>
<Animated.View style={[styles.background, backgroundStyle]}>
<Animated.View style={[styles.leftIcon, leftIconStyle]}>
<Icon iconName='check' iconColor={DEFAULTS.LEFT_ICON_COLOR} />
</Animated.View>
<Animated.View style={[styles.rightIcon, rightIconStyle]}>
<Icon iconName='trash' iconColor={DEFAULTS.RIGHT_ICON_COLOR} />
</Animated.View>
</Animated.View>
<PanGestureHandler
activeOffsetX={[
-DEFAULTS.ACTIVATION_OFFSET,
DEFAULTS.ACTIVATION_OFFSET
]}
onGestureEvent={gestureHandler}
>
<Animated.View
ref={viewRef}
style={[style, styles.foreground, foregroundStyle]}
>
{children}
</Animated.View>
</PanGestureHandler>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: { flexDirection: 'row' },
background: {
...StyleSheet.absoluteFillObject,
flexDirection: 'row',
alignItems: 'center',
overflow: 'hidden'
},
foreground: {
width: '100%',
overflow: 'hidden'
},
leftIcon: { position: 'absolute', left: 24 },
rightIcon: { position: 'absolute', right: 24 }
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment