0

I am using React Native Swipeable for "swipe to select" functionality in the mobile app I am working on. I have a list of invoices. Each invoice is swipeable. When user swipe right, it displays selected checkbox in the left menu and invoice number should be added to selectedInvoices state of the invoices list component, without having to take any further action. To achieve this I used onSwipeableOpen and all works as expected, apart one thing. When user swipe right, invoice number is being added to the array of selected invoices, but swipeable component is getting closed straight away, so user cannot see left menu that indicates it is selected. Any idea how to prevent swipeable auto closing after executing onSwipeableOpen?

enter image description here

Link to the original package documentation: https://docs.swmansion.com/react-native-gesture-handler/docs/api/components/swipeable/

Current Behaviour Swipeable Document closes automatically after onSwipeableOpen execution when invoice number is being added to selectedInvoices state (array) in InvoicesList component.

Expected Behaviour Swipeable Document stays open, to display left menu to indicate invoice has been selected.

InvoicesList.tsx component

import Modal from 'react-native-modal';
import Moment from 'moment';
import { ColorSchemeName, StyleSheet, Text, TextStyle, useColorScheme, View, ViewStyle } from 'react-native';
import { Colours } from '../../assets/styles';
import { Document } from '../UI';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { InvoiceType } from '../../types';
import { RootState } from '../../redux/store';
import { SCREEN_HEIGHT, SCREEN_WIDTH, WINDOW_FONT_SCALE } from '../../services/DimensionsHelper';
import { useSelector } from 'react-redux';
import { useState } from 'react';

type Props = {
    invoices: Array<InvoiceType>,
    showSwipeDemo: boolean,
    swipeDemoContentStyle: ViewStyle,
    containerStyle?: ViewStyle,
};

export default function InvoicesList({ invoices, showSwipeDemo, swipeDemoContentStyle, containerStyle }: Props): JSX.Element {
    const theme: Style = getStyle(useColorScheme());
    const swipeDemoSeen: boolean = useSelector((state: RootState) => state.settings.swipeDemoSeen);
    const firstInvoice: InvoiceType = invoices[0];
    const [selectedInvoices, setSelectedInvoices] = useState<Array<number>>([]);

    const selectInvoice = (invoiceNumber: number): void => {
        setSelectedInvoices((currentSelectedInvoices: Array<number>) => [...currentSelectedInvoices, invoiceNumber]);
    };

    const unselectInvoice = (invoiceNumber: number): void => {
        setSelectedInvoices((currentSelectedInvoices: Array<number>) => currentSelectedInvoices.filter((invoice: number) => invoice !== invoiceNumber));
    };

    return (
        <>
            <GestureHandlerRootView>
                <View style={[ theme.container, containerStyle ]}>
                    {
                        invoices.map((invoice: InvoiceType, index: number) =>
                            <Document
                                key={ index }
                                number={ invoice.glitem }
                                title={ 'Invoice #' + invoice.documentNumber.toString() }
                                status={ invoice.status }
                                description={ invoice.customerReference }
                                date={ 'Invoiced ' + Moment(invoice.documentDate).format('D MMM YYYY ') }
                                value={ invoice.value }
                                onSwipeRight={ selectInvoice }
                                onSwipeableClose={ unselectInvoice }
                                showSwipeDemo={ false }
                            />
                        )
                    }
                </View>
            </GestureHandlerRootView>

            <Modal
                customBackdrop={ <View style={ theme.modalBackdrop } /> }
                backdropOpacity={ 0.7 }
                statusBarTranslucent={ true }
                isVisible={ showSwipeDemo && !swipeDemoSeen }
                animationIn='fadeIn'
                animationOut='fadeOut'
                useNativeDriver={ true }
                useNativeDriverForBackdrop={ true }
                hideModalContentWhileAnimating={ true }
            >
                <View style={[ theme.modalContent, swipeDemoContentStyle ]}>
                    <GestureHandlerRootView>
                        <Document
                            number={ firstInvoice.glitem }
                            title={ 'Invoice #' + firstInvoice.documentNumber.toString() }
                            status={ firstInvoice.status }
                            description={ firstInvoice.customerReference }
                            date={ 'Invoiced ' + Moment(firstInvoice.documentDate).format('D MMM YYYY ') }
                            value={ firstInvoice.value }
                            onSwipeRight={ () => {} }
                            onSwipeableClose={ () => {} }
                            showSwipeDemo={ showSwipeDemo && !swipeDemoSeen }
                        />
                    </GestureHandlerRootView>
                    <View style={ theme.instructionContainer }><Text style={ theme.instruction }>Swipe right to select</Text></View>
                </View>
            </Modal>
        </>
    );
}

type Style = {
    container: ViewStyle,
    modalBackdrop: ViewStyle,
    modalContent: ViewStyle,
    instructionContainer: ViewStyle,
    instruction: TextStyle,
};

const getStyle = (colourScheme: ColorSchemeName): Style => {
    return StyleSheet.create({
        container: {
            paddingHorizontal: 16,
            paddingBottom: 32,
        },
        modalBackdrop: {
            width: SCREEN_WIDTH,
            height: SCREEN_HEIGHT,
            backgroundColor: Colours.black.s100,
        },
        modalContent: {
            position: 'absolute',
            width: '101%',
            marginLeft: -2,
        },
        instructionContainer: {
            alignItems: 'center',
        },
        instruction: {
            fontFamily: 'Montserrat_700Bold',
            fontSize: 18 / WINDOW_FONT_SCALE,
            lineHeight: 22 / WINDOW_FONT_SCALE,
            color: Colours.white.s100,
            textTransform: 'uppercase',
        },
    });
};

Document.tsx component

import MkmIcon from './MkmIcon';
import StatusLabel from './StatusLabel';
import { ColorSchemeName, Pressable, StyleSheet, Text, TextStyle, useColorScheme, View, ViewStyle } from 'react-native';
import { Colours } from '../../assets/styles';
import { Swipeable } from 'react-native-gesture-handler';
import { WINDOW_FONT_SCALE } from '../../services/DimensionsHelper';
import { setSwipeDemoSeen } from '../../redux/settingsSlice';
import { useDispatch } from 'react-redux';
import { useLayoutEffect, useRef } from 'react';

type Props = {
    number: number,
    title: string,
    status: string,
    description?: string,
    date: string,
    value: number,
    onPress?: (number: number) => void,
    onSwipeRight?: (number: number) => void,
    onSwipeableClose?: (number: number) => void,
    showSwipeDemo?: boolean,
};

export default function Document({ number, title, status, description, date, value, onPress, onSwipeRight, onSwipeableClose, showSwipeDemo }: Props): JSX.Element {
    const theme: Style = getStyle(useColorScheme());
    const swipeableRef = useRef(null);
    const dispatch = useDispatch();

    const onPressHandler = (): void => {
        if (onPress) {
            onPress(number);
        }
    };

    const onSwipeRightHandler = (): void => {
        if (onSwipeRight) {
            onSwipeRight(number);
        }
    };

    const onSwipeableCloseHandler = (): void => {
        if (onSwipeableClose) {
            onSwipeableClose(number);
        }
    };

    const renderLeftCheckBox = (progress): JSX.Element => {
        return (
            <View style={ theme.checkContainer }>
                <MkmIcon name='CheckThick' colour={ Colours.white.s100 } style={ theme.check } />
            </View>
        );
    };

    const Container = ({ children }): JSX.Element => {
        return (
            onSwipeRight && onSwipeableClose
                ? <Swipeable
                    ref={ swipeableRef }
                    containerStyle={ theme.swipeableContainer }
                    renderLeftActions={ renderLeftCheckBox }
                    onSwipeableOpen={ onSwipeRightHandler }
                    onSwipeableClose={ onSwipeableCloseHandler }
                    overshootLeft={ false }
                >{ children }</Swipeable>
                : <View style={ theme.container }>{ children }</View>
        );
    };

    useLayoutEffect(() => {
        if (showSwipeDemo) {
            const swipeable: Swipeable = swipeableRef.current;

            setTimeout(() => {
                swipeable.openLeft();
            }, 1000);

            setTimeout(() => {
                swipeable.close();
            }, 2500);

            setTimeout(() => {
                dispatch(setSwipeDemoSeen());
            }, 3500)
        }
    }, [showSwipeDemo]);

    return (
        <Container>
            <Pressable style={ ({ pressed }) => [theme.documentContainer, (onPress && pressed) && theme.pressed ] } onPress={ onPressHandler }>
                <View style={ theme.row }>
                    <View style={ theme.column }>
                        <Text style={ theme.title }>{ title }</Text>
                    </View>
                    <View>
                        <StatusLabel status={ status } />
                    </View>
                </View>
                {
                    description &&
                    <View style={ theme.descriptionContainer }>
                        <Text style={ theme.description }>{ description }</Text>
                    </View>
                }
                <View style={[ theme.row, theme.rowBottom ]}>
                    <View style={ theme.column }>
                        <Text style={ theme.date }>{ date }</Text>
                    </View>
                    <View>
                        <Text style={ theme.amount }>£{ value }</Text>
                    </View>
                </View>
            </Pressable>
        </Container>
    );
}

type Style = {
    container: ViewStyle,
    swipeableContainer: ViewStyle,
    documentContainer: ViewStyle,
    pressed: ViewStyle,
    row: ViewStyle,
    column: ViewStyle,
    title: TextStyle,
    descriptionContainer: ViewStyle,
    description: TextStyle,
    rowBottom: ViewStyle,
    date: TextStyle,
    amount: TextStyle,
    checkContainer: ViewStyle,
    check: ViewStyle,
};

const getStyle = (colourScheme: ColorSchemeName): Style => {
    return StyleSheet.create({
        container: {
            marginBottom: 16,
        },
        swipeableContainer: {
            padding: -1,
            marginBottom: 16,
            borderRadius: 4,
            backgroundColor: Colours.success.s100,
        },
        documentContainer: {
            padding: 16,
            borderWidth: 1,
            borderBottomWidth: 4,
            borderRadius: 4,
            borderColor: Colours.grey.s30,
            backgroundColor: Colours.white.s100,
        },
        pressed: {
            opacity: 0.6,
        },
        row: {
            flex: 1,
            flexDirection: 'row',
            alignItems: 'center',
            alignContent: 'space-between',
        },
        column: {
            flex: 1,
        },
        title: {
            fontFamily: 'Montserrat_500Medium',
            fontSize: 14 / WINDOW_FONT_SCALE,
            lineHeight: 21 / WINDOW_FONT_SCALE,
            color: Colours.charcoal.s100,
        },
        descriptionContainer: {
            paddingTop: 8,
        },
        description: {
            fontFamily: 'Montserrat_600SemiBold',
            fontSize: 16 / WINDOW_FONT_SCALE,
            lineHeight: 24 / WINDOW_FONT_SCALE,
            color: Colours.charcoal.s100,
        },
        rowBottom: {
            paddingTop: 12,
        },
        date: {
            fontFamily: 'Montserrat_500Medium',
            fontSize: 14 / WINDOW_FONT_SCALE,
            lineHeight: 21 / WINDOW_FONT_SCALE,
            color: Colours.charcoal.s60,
        },
        amount: {
            fontFamily: 'Montserrat_700Bold',
            fontSize: 16 / WINDOW_FONT_SCALE,
            lineHeight: 24 / WINDOW_FONT_SCALE,
            color: Colours.charcoal.s100,
        },
        checkContainer: {
            paddingHorizontal: 12,
            alignItems: 'center',
            justifyContent: 'center',
        },
        check: {
            width: 30,
            height: 30,
            padding: 7,
            borderWidth: 1,
            borderColor: Colours.white.s100,
            borderRadius: 15,
        },
    });
};

1 Answers1

0

After small break to refresh my brain and then couple of hours further digging I found the root cause and the solution.

Swipeable component actually has not been getting closed after onSwipeableOpen execution. It looked like it's been getting closed as the component was re-rendered after updating selectedInvoices state.

It's because I've been using useState hook, then updating state is always causing re-render.

    const [selectedInvoices, setSelectedInvoices] = useState<Array<number>>([]);

    const selectInvoice = (invoiceNumber: number): void => {
        setSelectedInvoices((currentSelectedInvoices: Array<number>) => [...currentSelectedInvoices, invoiceNumber]);
    };

    const unselectInvoice = (invoiceNumber: number): void => {
        setSelectedInvoices((currentSelectedInvoices: Array<number>) => currentSelectedInvoices.filter((invoice: number) => invoice !== invoiceNumber));
    };

Solution was to use useRef hook to store selectedInvoices because useRef does not cause component re-render.

    const selectedInvoices = useRef<Array<number>>([]);

    const selectInvoice = (invoiceNumber: number): void => {
        selectedInvoices.current = [...selectedInvoices.current, invoiceNumber];
    };

    const unselectInvoice = (invoiceNumber: number): void => {
        selectedInvoices.current = selectedInvoices.current.filter((invoice: number) => invoice !== invoiceNumber);
    };