import React from 'react'; import { LayoutChangeEvent, StyleProp, StyleSheet, View, ViewStyle, I18nManager, } from 'react-native'; import SlideButtonThumb, { SlideButtonThumbProps } from './SlideButtonThumb'; import SlideButtonText, { SlideButtonTextProps } from './SlideButtonText'; import Animated, { runOnJS, useAnimatedGestureHandler, useAnimatedStyle, useSharedValue, withSpring, } from 'react-native-reanimated'; import { PanGestureHandlerGestureEvent } from 'react-native-gesture-handler'; const DEFAULT_HEIGHT = 56; const DEFAULT_BORDER_RADIUS = DEFAULT_HEIGHT / 2; const DEFAULT_CONTAINER_PADDING = 5; const DEFAULT_COMPLETE_THRESHOLD = 70; const DEFAULT_CONTAINER_COLOR = '#0095FF'; const DEFAULT_UNDERLAY_COLOR = '#42AAFF'; const DEFAULT_TITLE = 'Slide to confirm'; const DEFAULT_AUTO_RESET = false; const DEFAULT_AUTO_RESET_DELAY = 1080; const DEFAULT_ANIMATION = false; const DEFAULT_ANIMATION_DURATION = 180; export type SlideButtonPropsExtends = Omit< SlideButtonCommonProps, 'translateX' | 'scrollDistance' | 'endReached' | 'isRTL' > & Omit< SlideButtonThumbProps, | 'opacity' | 'gestureHandler' | 'translateX' | 'scrollDistance' | 'endReached' | 'isRTL' > & Omit< SlideButtonTextProps, 'translateX' | 'scrollDistance' | 'endReached' | 'isRTL' >; interface SlideButtonProps extends SlideButtonPropsExtends { width?: number; disabled?: boolean; completeThreshold?: number; onSlideStart?: () => void; onSlideEnd?: () => void; onReachedToStart?: () => void; onReachedToEnd?: () => void; underlayStyle?: StyleProp; containerStyle?: StyleProp; thumbStyle?: StyleProp; autoReset?: boolean; autoResetDelay?: number; } type AnimatedGHContext = { startX: number; }; export type SlideButtonCommonProps = { height?: number; borderRadius?: number; padding?: number; translateX: Animated.SharedValue; endReached: boolean; scrollDistance: number; reverseSlideEnabled?: boolean; animation?: boolean; animationDuration?: number; dynamicResetEnabled?: boolean; dynamicResetDelaying?: boolean; }; const SlideButton = ({ width, height, borderRadius, completeThreshold, disabled, padding, title, titleContainerStyle, titleStyle, icon, thumbStyle, containerStyle, underlayStyle, onReachedToStart, onReachedToEnd, onSlideEnd, onSlideStart, reverseSlideEnabled, autoReset, autoResetDelay, animation, animationDuration, dynamicResetEnabled, dynamicResetDelaying, }: SlideButtonProps) => { const [dimensions, setDimensions] = React.useState({ width: 0, height: 0 }); const [endReached, setEndReached] = React.useState(false); const timeoutRef = React.useRef(); const gestureDisabled = useSharedValue(disabled); const dragX = useSharedValue(0); const isRTL = I18nManager.isRTL; const rtlMultiplier = isRTL ? -1 : 1; const opacity = disabled ? 0.55 : 1; let borderWidth = 0; let thumbWidth = height! - padding! * 2; let childHeight = height! - padding! * 2; if (thumbStyle !== undefined) { let tWidth = StyleSheet.flatten(thumbStyle).width; if (tWidth !== undefined) { thumbWidth = Number(tWidth); } } if (containerStyle !== undefined) { let bWidth = StyleSheet.flatten(containerStyle).borderWidth; if (bWidth !== undefined) { borderWidth = Number(bWidth); } } const radius = borderRadius! - padding!; const scrollDistance = (dimensions.width - padding! * 2 - thumbWidth - borderWidth * 2) * rtlMultiplier; const slideThreshold = scrollDistance * (completeThreshold! / 100); const onLayoutContainer = async (e: LayoutChangeEvent) => { const { width, height } = e.nativeEvent.layout; const { width: w, height: h } = dimensions; if (w !== width || h !== height) { setDimensions({ width, height }); } }; React.useEffect(() => { gestureDisabled.value = disabled; }, [disabled]); React.useEffect(() => { if (dynamicResetEnabled && !dynamicResetDelaying) { reset(); } }, [dynamicResetDelaying]); React.useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); const underlayAnimStyle = useAnimatedStyle(() => { return { width: thumbWidth - borderWidth * 2 + dragX.value * rtlMultiplier, }; }); //let KEY = isRTL ? 'right' : 'left'; const underlayDynamicStyle = { left: padding!, height: childHeight - borderWidth * 2, borderRadius: radius, }; const handleComplete = (reached: boolean) => { // console.log(`handleComplete: ${reached}`); if (reached) { setEndReached(true); onReachedToEnd && onReachedToEnd(); if (!dynamicResetEnabled) { if (autoReset) { gestureDisabled.value = true; timeoutRef.current = setTimeout(() => { reset(); }, autoResetDelay); } } if (!reverseSlideEnabled) { gestureDisabled.value = true; } } else { setEndReached(false); onReachedToStart && onReachedToStart(); } }; const clamp = (value: number, lowerBound: number, upperBound: number) => { 'worklet'; return Math.min(Math.max(lowerBound, value), upperBound); }; const reset = () => { 'worklet'; dragX.value = withSpring(0, { damping: 20, stiffness: 100 }, () => { runOnJS(handleComplete)(false); }); gestureDisabled.value = false; }; const moveTo = (value: number, complete: boolean) => { 'worklet'; dragX.value = withSpring(value, { damping: 20, stiffness: 100 }, () => { runOnJS(handleComplete)(complete); }); }; const animatedGestureHandler = useAnimatedGestureHandler< PanGestureHandlerGestureEvent, AnimatedGHContext >({ onStart: (_, context) => { context.startX = dragX.value; runOnJS(onSlideStart!)(); }, onActive: (event, context) => { if (gestureDisabled.value) { return; } const translationX = context.startX + event.translationX; if (isRTL) { dragX.value = clamp(translationX, scrollDistance, 0); } else { dragX.value = clamp(translationX, 0, scrollDistance); } }, onEnd: () => { // console.log(`onEnd: dragX : ${dragX.value}`); if (gestureDisabled.value) { return; } runOnJS(onSlideEnd!)(); if (isRTL) { if (dragX.value > slideThreshold) { if (dragX.value === 0) { runOnJS(handleComplete)(false); return; } moveTo(0, false); } else { if (dragX.value === scrollDistance) { runOnJS(handleComplete)(true); return; } moveTo(scrollDistance, true); } } else { if (dragX.value < slideThreshold) { if (dragX.value === 0) { runOnJS(handleComplete)(false); return; } moveTo(0, false); } else { if (dragX.value === scrollDistance) { runOnJS(handleComplete)(true); return; } moveTo(scrollDistance, true); } } }, }); return ( { if (reverseSlideEnabled) { gestureDisabled.value = true; } }} animEnded={() => { if (reverseSlideEnabled) { gestureDisabled.value = false; } }} /> ); }; export default React.memo(SlideButton); SlideButton.defaultProps = { height: DEFAULT_HEIGHT, borderRadius: DEFAULT_BORDER_RADIUS, padding: DEFAULT_CONTAINER_PADDING, title: DEFAULT_TITLE, completeThreshold: DEFAULT_COMPLETE_THRESHOLD, disabled: false, reverseSlideEnabled: true, autoReset: DEFAULT_AUTO_RESET, autoResetDelay: DEFAULT_AUTO_RESET_DELAY, animation: DEFAULT_ANIMATION, animationDuration: DEFAULT_ANIMATION_DURATION, dynamicResetEnabled: false, dynamicResetDelaying: false, onSlideStart: () => {}, onSlideEnd: () => {}, onReachedToStart: () => {}, onReachedToEnd: () => {}, }; const styles = StyleSheet.create({ container: { backgroundColor: DEFAULT_CONTAINER_COLOR, alignSelf: 'center', justifyContent: 'center', width: '100%', marginVertical: 10, height: DEFAULT_HEIGHT, borderRadius: DEFAULT_BORDER_RADIUS, shadowColor: '#000', shadowOffset: { width: 0, height: 1, }, shadowOpacity: 0.2, shadowRadius: 1.41, elevation: 2, }, underlayContainer: { position: 'absolute', backgroundColor: DEFAULT_UNDERLAY_COLOR, }, });