How to fix this bug with React Native Reanimate and Gesture Handler? - react-native

So I have this basic todo:
App.jsx
const ITEMS = [
{ id: 1, text: 'Example 1' },
{ id: 2, text: 'Example 2' },
{ id: 3, text: 'Example 3' }
];
export const App = () => {
const todoRefs = useSharedValue<Record<string, any>>({});
const closeOpenTodos = (id: number) => {
'worklet';
for (const key in todoRefs.value) {
if (Number(key) !== id) {
todoRefs.value[key].closeTodo();
}
}
};
return (
<ScrollView contentContainerStyle={styles.container}>
{ITEMS.map((item, index) => (
<Item key={index} {...{ ...item, todoRefs, closeOpenTodos }} />
))}
</ScrollView>
);
};
Item.jsx (i.e. todo)
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const BUTTON_WIDTH = 100;
const CONTAINER_HEIGHT = 80;
const TRANSLATE_X_THRESHOLD = -SCREEN_WIDTH * 0.7;
type ItemProps = {
id: number;
text: string;
todoRefs: Record<string, any>;
closeOpenTodos: (id: number) => void;
};
type ItemContext = {
translateX: number;
};
export const Item = ({ id, text, todoRefs, closeOpenTodos }: ItemProps) => {
const translateX = useSharedValue(0);
const containerHeight = useSharedValue(CONTAINER_HEIGHT);
const dismissItem = () => {
'worklet';
containerHeight.value = withTiming(0, { duration: 100 });
translateX.value = -SCREEN_WIDTH;
};
todoRefs.value = {
...todoRefs.value,
[id]: {
closeTodo: () => {
'worklet';
translateX.value = withTiming(0);
}
}
};
const panGestureEvent = useAnimatedGestureHandler<
PanGestureHandlerGestureEvent,
ItemContext
>({
onStart: (_event, context) => {
closeOpenTodos(id);
context.translateX = translateX.value;
},
onActive: (event, context) => {
// Prevent swiping to the right
if (event.translationX > 0) {
translateX.value = 0;
return;
}
translateX.value = event.translationX + context.translateX;
},
onEnd: (event, context) => {
// If swiping to the right, close item
if (event.translationX > 0) {
translateX.value = 0;
return;
}
if (event.translationX + context.translateX < TRANSLATE_X_THRESHOLD) {
dismissItem();
return;
}
translateX.value = withSpring(-BUTTON_WIDTH);
}
});
const animatedSliderStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: translateX.value }]
};
}, []);
const animatedContainerStyle = useAnimatedStyle(() => {
return {
height: containerHeight.value
};
}, []);
return (
<Animated.View style={[styles.container, animatedContainerStyle]}>
<Pressable style={[styles.button]} onPress={() => dismissItem()}>
<Text style={styles.buttonText}>Delete</Text>
</Pressable>
<PanGestureHandler onGestureEvent={panGestureEvent}>
<Animated.View style={[styles.slider, animatedSliderStyle]}>
<Text style={styles.sliderText}>{text}</Text>
</Animated.View>
</PanGestureHandler>
</Animated.View>
);
};
Basically when a todo is swiped open, it reveals a delete button. I want it so that if another todo it swiped open, all others close.
So I'm passing down a todoRefs (which technically aren't "refs") and closeOpenTodos.
All works fine, except this approach has introduced a strange bug.
When I go to delete items, the last one keeps re-appearing.
Is there a better way to do this?

Related

React Native sound on interval skipping

I'm trying to get React Native to whistle 3 times before a timer, so for example, whistle 3 seconds in a row, then let the timer go, then whistle again, but for some reason it is only doing it twice, it's skipping the middle whistle and sometimes the last one.
I've tried mounting the sound before hand, reducing the sound duration to about .3 seconds, and it is still skipping some plays. I know I need to do some refactor on the timers, but I think at least playing the sound should work.
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Dimensions,
Vibration,
} from "react-native";
import React from "react";
import { StatusBar } from "expo-status-bar";
import { Audio } from "expo-av";
const screen = Dimensions.get("window");
let timeout: NodeJS.Timeout | undefined = undefined;
interface TimerComponentProps {
timeInSeconds?: number;
}
export const TimerComponent: React.FC<TimerComponentProps> = ({
timeInSeconds = 5,
}) => {
const [remaningSeconds, setRemainingSeconds] = React.useState(timeInSeconds);
const [isActive, setIsActive] = React.useState(false);
const [sound, setSound] = React.useState<Audio.Sound | undefined>(undefined);
const [shouldCount, setShouldCount] = React.useState(false);
const [counter, setCounter] = React.useState(3);
const { minutes, seconds } = React.useMemo(() => {
const minutes = Math.floor(remaningSeconds / 60);
const seconds = remaningSeconds % 60;
return { minutes, seconds };
}, [remaningSeconds]);
async function mountSound() {
try {
const { sound } = await Audio.Sound.createAsync(
require("../../assets/audio/Whistle.wav")
);
setSound(sound);
} catch (error) {
console.error(error);
}
}
async function playWhistle() {
if (sound) {
try {
await sound.playAsync();
} catch (error) {
console.error(error);
}
}
}
const endTimer = async () => {
try {
await playWhistle();
setIsActive(false);
} catch (error) {
console.error(error);
}
};
const startCounter = async () => {
await mountSound();
setShouldCount(true);
};
const resetTimer = () => {
if (timeout) {
clearTimeout(timeout);
} else {
timeout = setTimeout(() => {
setRemainingSeconds(timeInSeconds);
clearTimeout(timeout);
}, 1000);
}
};
React.useEffect(() => {
let counterInterval: NodeJS.Timer | undefined = undefined;
if (shouldCount) {
counterInterval = setInterval(() => {
try {
if (counter === 1) {
setCounter((counter) => counter - 1);
}
if (counter > 1) {
playWhistle();
Vibration.vibrate();
setCounter((counter) => counter - 1);
} else {
// Plays the whistle sound and vibrates the device
playWhistle();
Vibration.vibrate();
// Restarts the counter
setCounter(3);
setShouldCount(false);
// Starts the timer
setIsActive(true);
// Stops the counter
clearInterval(counterInterval);
}
} catch (error) {
console.error(error);
}
}, 1000);
} else if (!shouldCount && counter !== 0) {
clearInterval(counterInterval);
}
return () => clearInterval(counterInterval);
}, [shouldCount, counter]);
React.useEffect(() => {
let timerInterval: NodeJS.Timer | undefined = undefined;
if (isActive) {
timerInterval = setInterval(() => {
if (remaningSeconds === 1) {
setRemainingSeconds((remaningSeconds) => remaningSeconds - 1);
}
if (remaningSeconds > 1) {
setRemainingSeconds((remaningSeconds) => remaningSeconds - 1);
} else {
Vibration.vibrate();
endTimer();
resetTimer();
}
}, 1000);
} else if (!isActive && remaningSeconds === 0) {
resetTimer();
clearInterval(timerInterval);
}
return () => clearInterval(timerInterval);
}, [isActive, remaningSeconds]);
React.useEffect(() => {
return sound
? () => {
sound.unloadAsync();
setSound(undefined);
}
: undefined;
}, [sound]);
const parseTime = (time: number) => {
return time < 10 ? `0${time}` : time;
};
return (
<View style={styles.container}>
<StatusBar style="light" />
<Text style={styles.timerText}>{`${parseTime(minutes)}:${parseTime(
seconds
)}`}</Text>
<TouchableOpacity onPress={startCounter} style={styles.button}>
<Text style={styles.buttonText}>{isActive ? "Pause" : "Start"}</Text>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#07121B",
alignItems: "center",
justifyContent: "center",
},
button: {
borderWidth: 10,
borderColor: "#B9AAFF",
width: screen.width / 2,
height: screen.width / 2,
borderRadius: screen.width / 2,
alignItems: "center",
justifyContent: "center",
},
buttonText: {
color: "#B9AAFF",
fontSize: 20,
},
timerText: {
color: "#fff",
fontSize: 90,
},
});
The issue was that expo-av leaves the audio file at it's end, so the next time you play it, nothing will sound because the file is already over, the way to fix it is pretty simple:
async function playWhistle() {
if (sound) {
try {
await sound.playAsync();
sound.setPositionAsync(0); // ADD THIS LINE
} catch (error) {
console.error(error);
}
}
}

Reusing custom TextInput component in react native?

I am using react native 0.68.5
When I call the renderReusableTextInput method in Main.js I am sending all the styles including the needed and unneeded styles. This can lead performance penalty.
So, is there a way that I can send the only needed styles to the renderReusableTextInput method.
I also want to know is the code reusing approach of mine correct or there are better ways.
I have the following code-logic structure:
(i) A Reusable component; It has only structure no style or state
(ii) A file that includes reusable methods
(iii) The Main component, which contains components, styles and states
ReusableTextInput.js
// import ...
const ReusableTextInput = forwardRef(({
inputName,
inputLabel,
inputValue,
secureTextEntry,
textInputContainerWarperStyle,
textInputContainerStyle,
textInputLabelStyle,
textInputStyle,
textInputHelperStyle,
textInputErrorStyle,
helperText,
inputError,
onFocus,
onChangeText,
onBlur,
}, inputRef) => {
return (
<View style={[textInputContainerWarperStyle]}>
<View style={[textInputContainerStyle]}>
<Text style={[textInputLabelStyle]}>
{inputLabel}
</Text>
<TextInput
ref={(elm) => inputRef[inputName] = elm}
style={[textInputStyle]}
value={inputValue}
secureTextEntry={secureTextEntry}
onChangeText={onChangeText}
onFocus={onFocus}
onBlur={onBlur}
/>
</View>
{
((inputRef[inputName]) && (inputRef[inputName].isFocused()) && (!inputValue))
?
<Text style={[textInputHelperStyle]}>
{helperText}
</Text>
:
null
}
{
((inputError) && (inputValue))
?
<Text style={[textInputErrorStyle]}>
{inputError}
</Text>
:
null
}
</View>
);
});
export default memo(ReusableTextInput);
reusableMethods.js
// import ...
const handleFocus = (state, setState, styles) => {
const stateData = { ...state };
stateData.styleNames.textInputContainer = {
...styles.textInputContainer,
...styles[`${stateData.name}ExtraTextInputContainer`],
...styles.textInputContainerFocus,
};
stateData.styleNames.textInputLabel = {
...styles.textInputLabel,
...styles[`${stateData.name}ExtraTextInputLabel`],
...styles.textInputLabelFocus,
};
stateData.styleNames.textInput = {
...styles.textInput,
...styles[`${stateData.name}ExtraTextInput`],
...styles.textInputFocus,
};
// other logics...
setState(stateData);
};
const handleChangeText = (state, setState, text) => {
const stateData = { ...state };
// individual validation
const schemaData = Joi.object().keys(stateData.validationObj); // I used Joi for validation
const inputData = { [stateData.name]: text };
const options = { abortEarly: false, errors: { label: false } };
const result = schemaData.validate(inputData, options);
// -----
stateData.error = (result.error) ? result.error.details[0].message : '';
stateData.value = text;
// other logics...
setState(stateData);
};
const handleBlur = (state, setState, styles) => {
const stateData = { ...state };
if (stateData.value) {
stateData.styleNames.textInputContainer = {
...styles.textInputContainer,
...styles[`${stateData.name}ExtraTextInputContainer`],
...styles.textInputContainerFocus,
...styles[`${stateData.name}ExtraTextInputContainerFocus`],
...styles.textInputContainerBlurText,
...styles[`${stateData.name}ExtraTextInputContainerBlurText`],
};
stateData.styleNames.textInputLabel = {
...styles.textInputLabel,
...styles[`${stateData.name}ExtraTextInputLabel`],
...styles.textInputLabelFocus,
...styles[`${stateData.name}ExtraTextInputLabelFocus`],
...styles.textInputLabelBlurText,
...styles[`${stateData.name}ExtraTextInputLabelBlurText`],
};
stateData.styleNames.textInput = {
...styles.textInput,
...styles[`${stateData.name}ExtraTextInput`],
...styles.textInputFocus,
...styles[`${stateData.name}ExtraTextInputFocus`],
...styles.textInputBlurText,
...styles[`${stateData.name}ExtraTextInputBlurText`],
};
}
else {
stateData.styleNames.textInputContainer = { ...styles.textInputContainer, ...styles[`${stateData.name}ExtraTextInputContainer`] };
stateData.styleNames.textInputLabel = { ...styles.textInputLabel, ...styles[`${stateData.name}ExtraTextInputLabel`] };
stateData.styleNames.textInput = { ...styles.textInput, ...styles[`${stateData.name}ExtraTextInput`] };
}
// other logics...
setState(stateData);
};
// other methods...
export const renderReusableTextInput = (
state,
setState,
inputRef,
styles,
// contains all the styles from Main component and here I am sending all the styles including the needed and unneeded styles. I want improvement here
) => {
return (
<ReusableTextInput
inputName={state.name}
inputLabel={state.label}
inputValue={state.value}
inputRef={inputRef}
secureTextEntry={state.secureTextEntry}
textInputContainerWarperStyle={{...styles.textInputContainerWarper, ...styles[`${state.name}ExtraTextInputContainerWarper`]}}
textInputContainerStyle={state.styleNames.textInputContainer}
textInputLabelStyle={state.styleNames.textInputLabel}
textInputStyle={state.styleNames.textInput}
textInputHelperStyle={{...styles.textInputHelper, ...styles[`${state.name}ExtraTextInputHelper`]}}
textInputErrorStyle={{...styles.textInputError, ...styles[`${state.name}ExtraTextInputError`]}}
helperText={state.helperText}
inputError={state.error}
onFocus={() => handleFocus(state, setState, styles)}
onChangeText={(text) => handleChangeText(state, setState, text)}
onBlur={() => handleBlur(state, setState, styles)}
/>
);
};
Main.js
// import Joi from 'joi';
// import { joiPasswordExtendCore } from 'joi-password';
// import { renderReusableTextInput } from ''; ...
const schema =
{
email: Joi.string().strict()
.case("lower")
.min(5)
.max(30)
.email({ minDomainSegments: 2, tlds: { allow: ["com", "net", "org"] } })
.required(),
countryCode: // Joi.string()...,
phoneNumber: // Joi.string()...,
password: // Joi.string()...,
// ...
};
const Main = () => {
const { width: windowWidth, height: windowHeight, scale, fontScale } = useWindowDimensions();
const minimumWidth = (windowWidth <= windowHeight) ? windowWidth : windowHeight;
const styles = useMemo(() => currentStyles(minimumWidth), [minimumWidth]);
const [email, setEmail] = useState({
name: 'email', // unchangeable
label: 'Email', // unchangeable
value: '',
error: '',
validationObj: { email: schema.email }, // unchangeable
trailingIcons: [require('../../file/image/clear_trailing_icon.png')], // unchangeable
helperText: 'only .com, .net and .org allowed', // unchangeable
styleNames: {
textInputContainer: { ...styles.textInputContainer, ...styles.emailExtraTextInputContainer },
textInputLabel: { ...styles.textInputLabel, ...styles.emailExtraTextInputLabel },
textInput: { ...styles.textInput, ...styles.emailExtraTextInput },
},
});
const [phoneNumber, setPhoneNumber] = useState({
// ...
});
const [countryCode, setCountryCode] = useState({
});
const [password, setPassword] = useState({
});
// ...
const references = useRef({});
return (
<View style={[styles.mainContainer]}>
{
useMemo(() => renderReusableTextInput(email, setEmail, references.current, styles), [email, minimumWidth])
}
</View>
);
}
export default memo(Main);
const styles__575 = StyleSheet.create({
// 320 to 575
mainContainer: {
},
textInputContainerWarper: {
},
emailExtraTextInputContainerWarper: {
},
countryCodeExtraTextInputContainerWarper: {
},
phoneNumberExtraTextInputContainerWarper: {
},
passwordExtraTextInputContainerWarper: {
},
textInputContainer: {
},
emailExtraTextInputContainer: {
},
textInputContainerFocus: {
},
textInputContainerBlurText: {
},
textInputLabel: {
},
emailExtraTextInputLabel: {
},
textInputLabelFocus: {
},
textInputLabelBlurText: {
},
textInput: {
},
emailExtraTextInput: {
},
textInputFocus: {
},
textInputBlurText: {
},
textInputHelper: {
},
emailExtraTextInputHelper: {
},
textInputError: {
},
emailExtraTextInputError: {
},
// other styles...
});
const styles_576_767 = StyleSheet.create({
// 576 to 767
});
const styles_768_ = StyleSheet.create({
// 768; goes to 1024;
});
const currentStyles = (width, stylesInitial = { ...styles__575 }, styles576 = { ...styles_576_767 }, styles768 = { ...styles_768_ }) => {
let styles = {};
if (width < 576) {
// ...
}
else if ((width >= 576) && (width < 768)) {
// ...
}
else if (width >= 768) {
// ...
}
return styles;
};
I tried mentioned approach and want a better answer.
First, I think you want global styles, so that you can access it from anywhere and you can also be able to execute needed code.
Make sure you use useMemo, useCallback in right manner, for better performance.
Move all Schema and Styles and Methods of your screens inside the reusableMethods.js file (at most case). It will act like the controller of all screens of your app, also make a demo method which return a tiny component and this method execute another method, so that you can get styles for different dimentions(see below code)
Store only changeable and needed properties in state variables.
Is code reusing approach of yours, correct? I can't say about that. I will say that it depends on developer choice.
you can try like below:
reusableMethods.js
// import all schema, style variants, utility methods and other
let screenStyles = {};
const executeDimensionBasedMethods = (width, screenName) => {
if (screenName === 'a_screen_name') screenStyles[screenName] = currentStyles(width, otherParameter);
// else if()
// ...
};
export const renderDimension = (width, screenName) => {
executeDimensionBasedMethods(width, screenName);
return (
<Text style={{ width: 0, height: 0 }}></Text>
);
};
// all method definitions and logics
export const renderReusableTextInput = (
state,
setState,
inputRef,
screenName
) => {
return (
<ReusableTextInput
inputName={state.name}
inputLabel={state.label}
inputValue={state.value}
inputRef={inputRef}
secureTextEntry={state.secureTextEntry}
textInputContainerWarperStyle={{ ...screenStyles[screenName].textInputContainerWarper, ...screenStyles[screenName][`${state.name}ExtraTextInputContainerWarper`] }}
textInputContainerStyle={state.styleNames.textInputContainer || { ...screenStyles[screenName].textInputContainer }
textInputLabelStyle={state.styleNames.textInputLabel || { ...screenStyles[screenName].textInputLabel }
textInputStyle={state.styleNames.textInput || { ...screenStyles[screenName].textInput }
textInputHelperStyle={{ ...screenStyles[screenName].textInputHelper, ...screenStyles[screenName][`${state.name}ExtraTextInputHelper`] }}
textInputErrorStyle={{ ...screenStyles[screenName].textInputError, ...screenStyles[screenName][`${state.name}ExtraTextInputError`] }}
helperText={state.helperText}
inputError={state.error}
onFocus={() => handleFocus(state, setState, screenStyles[screenName])}
onChangeText={(text) => handleChangeText(state, setState, text)}
onBlur={() => handleBlur(state, setState, screenStyles[screenName])}
/>
);
};
Main.js
const Main = () => {
const { width: windowWidth, height: windowHeight} = useWindowDimensions();
const minimumWidth = (windowWidth <= windowHeight) ? windowWidth : windowHeight;
const [email, setEmail] = useState({
name: 'email', // unchangeable
label: 'Email', // unchangeable
value: '',
error: '',
trailingIcons: [require('../../file/image/clear_trailing_icon.png')], // unchangeable
helperText: 'only .com, .net and .org allowed', // unchangeable
styleNames: {
textInputContainer: undefined,
textInputLabel: undefined,
textInput: undefined,
},
});
// other states
const references = useRef({});
return (
<>
{
useMemo(() => renderDimension(minimumWidth), [minimumWidth])
}
<View style={{ // inline style}}>
{
useMemo(() => renderReusableTextInput(email, setEmail, references.current, 'nameofscreen'), [email, minimumWidth])
}
</View>
</>
}
export default memo(Main);

FlatList in ReactNative does not update/re-render when its data changes

Hej, I advanced my FlatList in React Native with
a) inbox/archive views
and b) with standard filter functionalities.
It's working somehow, but is not production ready!
Can someone please check this (I think well-organized) code and tell me where I do what wrong?
What is not working:
a) FlatList does not always re-render/update when the stream state, which is its data prop, changes
b) FlatList does not remove an item immediately when I archive/unarchive via swipe functionality. I have to manually change the view to see ...
c) FlatList does not directly apply the filter on state, I have to click twice to make it happen ...
//React
import { View, StyleSheet, Pressable, Animated, FlatList } from "react-native";
import { useCallback, useContext, useEffect, useState, useMemo } from "react";
//Internal
import SelectBtn from "./SelectBtn";
import SessionComponent from "./SessionComponent";
import LoadingOverlay from "../notification/LoadingOverlay";
import { SessionsContext } from "../../store/context-reducer/sessionsContext";
//External
import { Ionicons } from "#expo/vector-icons";
import { useNavigation } from "#react-navigation/native";
import { database, auth } from "../../firebase";
import { ref, onValue, remove, update } from "firebase/database";
function SessionStream() {
const navigation = useNavigation();
const sessionsCtx = useContext(SessionsContext);
const currentSessions = sessionsCtx.sessions;
const [isFetching, setIsFetching] = useState(true);
const [stream, setStream] = useState([]);
const [inbox, setInbox] = useState([]);
const [archive, setArchive] = useState([]);
const [filter, setFilter] = useState([]);
const sessionList = ["Sessions", "Archive"];
const sortList = ["ABC", "CBA", "Latest Date", "Earliest Date"];
useEffect(() => {
//Fetches all sessions from the database
async function getSessions() {
setIsFetching(true);
const uid = auth.currentUser.uid;
const sessionsRef = ref(database, "users/" + uid + "/sessions/");
try {
onValue(sessionsRef, async (snapshot) => {
const response = await snapshot.val();
if (response !== null) {
const responseObj = Object.entries(response);
const sessionsData = responseObj.map((item) => {
return {
id: item[1].id,
title: item[1].title,
laps: item[1].laps,
endTime: item[1].endTime,
note: item[1].note,
identifier: item[1].identifier,
date: item[1].date,
smed: item[1].smed,
externalRatio: item[1].externalRatio,
internalRatio: item[1].internalRatio,
untrackedRatio: item[1].untrackedRatio,
archived: item[1].archived,
};
});
sessionsCtx.setSession(sessionsData);
setIsFetching(false);
} else {
sessionsCtx.setSession([]);
setIsFetching(false);
}
});
} catch (err) {
alert(err.message);
setIsFetching(false);
}
}
getSessions();
}, []);
useEffect(() => {
//Sorts sessions into archived and unarchived
setInbox(
currentSessions.filter((session) => {
return session.archived === false || session.archived === undefined;
})
);
setArchive(
currentSessions.filter((session) => {
return session.archived === true;
})
);
}, [currentSessions, archiveHandler, unArchiveHandler, sessionsCtx, stream]);
if (isFetching) {
setTimeout(() => {
return <LoadingOverlay />;
}, 5000);
}
const onPressHandler = useCallback(
//Callback to open the session
(item) => {
navigation.navigate("Detail", {
sessionID: item.id,
});
},
[onPressHandler]
);
const rightSwipeActions = useCallback(
//Swipe actions for the session list
(item, swipeAnimatedValue) => {
return (
<View
style={{
flexDirection: "row",
width: 168,
height: 132,
}}
>
{item.archived === false ? (
<Pressable
onPress={archiveHandler.bind(this, item)}
style={({ pressed }) => pressed && styles.swipePressed}
>
<View style={styles.archive}>
<Animated.View
style={[
styles.archive,
{
transform: [
{
scale: swipeAnimatedValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
extrapolate: "clamp",
}),
},
],
},
]}
>
<Ionicons
name="ios-archive-outline"
size={24}
color="white"
/>
</Animated.View>
</View>
</Pressable>
) : (
<Pressable
onPress={unArchiveHandler.bind(this, item)}
style={({ pressed }) => pressed && styles.swipePressed}
>
<View style={styles.unArchive}>
<Animated.View
style={[
styles.unArchive,
{
transform: [
{
scale: swipeAnimatedValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
extrapolate: "clamp",
}),
},
],
},
]}
>
<Ionicons
name="md-duplicate-outline"
size={24}
color="white"
/>
</Animated.View>
</View>
</Pressable>
)}
<Pressable
onPress={deleteHandler.bind(this, item)}
style={({ pressed }) => pressed && styles.pressed}
>
<View style={styles.trash}>
<Animated.View
style={[
styles.trash,
{
transform: [
{
scale: swipeAnimatedValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
extrapolate: "clamp",
}),
},
],
},
]}
>
<Ionicons name="trash-outline" size={24} color="white" />
</Animated.View>
</View>
</Pressable>
</View>
);
},
[rightSwipeActions]
);
const deleteHandler = useCallback(
(item) => {
try {
sessionsCtx.deleteSession(item.id); // delete from local context
const uid = auth.currentUser.uid;
const sessionRef = ref(
database,
"users/" + uid + "/sessions/" + item.id
);
remove(sessionRef); // delete from firebase
} catch (error) {
alert(error.message);
}
},
[deleteHandler]
);
const archiveHandler = (item) => {
try {
const id = item.id;
const updatedSession = {
...item, // copy current session
archived: true,
};
const uid = auth.currentUser.uid;
const sessionRef = ref(database, "users/" + uid + "/sessions/" + id);
update(sessionRef, updatedSession);
/* sessionsCtx.updateSession(id, updatedSession); */
//update inbox state
setInbox(
currentSessions.filter((session) => {
const updatedData = session.archived === false;
return updatedData;
})
);
//update archive state
setArchive(
currentSessions.filter((session) => {
const updatedData = session.archived === true;
return updatedData;
})
);
} catch (error) {
alert(error.message);
}
};
const unArchiveHandler = (item) => {
try {
const id = item.id;
const updatedSession = {
...item, // copy current session
archived: false,
};
const uid = auth.currentUser.uid;
const sessionRef = ref(database, "users/" + uid + "/sessions/" + id);
update(sessionRef, updatedSession);
/* sessionsCtx.updateSession(id, updatedSession); */
//update unarchived session list
setArchive((preState) => {
//remove the item from archived list
preState.filter((session) => session.id !== item.id);
return [...preState];
});
} catch (error) {
alert(error.message);
}
};
const selectSessionHandler = useCallback(
(selectedItem) => {
switch (selectedItem) {
case "Sessions":
setStream(inbox);
break;
case "Archive":
setStream(archive);
break;
}
},
[selectSessionHandler, inbox, archive]
);
const selectFilterHandler = (selectedItem) => {
//filter the session list
switch (selectedItem) {
case "ABC":
// Use the Array.sort() method to sort the list alphabetically in ascending order
const sortedList = stream.sort((a, b) => {
return a.title.localeCompare(b.title);
});
setStream((preState) => {
return [...sortedList];
});
break;
case "CBA":
// Use the Array.sort() method to sort the list alphabetically in descending order
const sortedList2 = stream.sort((a, b) => {
return b.title.localeCompare(a.title);
});
setStream((preState) => {
return [...sortedList2];
});
break;
case "Latest Date":
// Use the Array.sort() method to sort the list by date in descending order
const sortedList3 = stream.sort((a, b) => {
return b.date.localeCompare(a.date);
});
setStream((preState) => {
return [...sortedList3];
});
break;
case "Earliest Date":
// Use the Array.sort() method to sort the list by date in ascending order
const sortedList4 = stream.sort((a, b) => {
return a.date.localeCompare(b.date);
});
setStream((preState) => {
return [...sortedList4];
});
break;
}
};
const renderSessionItem = useCallback(({ item }) => {
return (
<Pressable
/* style={({ pressed }) => pressed && styles.pressed} */
onPress={onPressHandler.bind(null, item)}
key={item.id}
>
<SessionComponent
key={item.id}
title={item.title}
identifier={item.identifier}
date={item.date}
rightSwipeActions={rightSwipeActions.bind(null, item)}
smed={item.smed}
endTime={item.endTime}
/>
</Pressable>
);
}, []);
return (
<View style={styles.container}>
<View style={styles.menuRow}>
<SelectBtn
data={sortList}
onSelect={(item) => selectFilterHandler(item)}
/>
<SelectBtn
data={sessionList}
onSelect={(item) => selectSessionHandler(item)}
/>
</View>
<FlatList
data={stream}
renderItem={renderSessionItem}
keyExtractor={(item) => item.id}
extraData={stream}
/>
</View>
);
}
export default SessionStream;
What I already tried:
I tried ChatGPT the whole day yesterday ... ;-)
I tried updating the global state for sessions to trigger re-renders ...
I tried updating the local state as obj or via the spread operator to ...
I tried extraData prop at FlatList
I removed useCallback to make sure it doesnt somehow block ...
can you do this when you are updating your stream state
const oldStream = stream;
const newStream = newStream; // put the value that you have updated
const returnedTarget= Object.assign(stream, newStream);
setStream(returnedTarget);
The problem might be you are mutating the copy obj
ref: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
c) in your selectFilterHandler method you are updating filter and in the same method you are trying to use updated filter value. which is not possible as useState does not update value immediately.
b) in your archiveHandler i think you are not updating the setStream state. if you are trying to run
const selectSessionHandler = useCallback(
(selectedItem) => {
switch (selectedItem) {
case "Sessions":
setStream(inbox);
break;
case "Archive":
setStream(archive);
break;
}
},
[selectSessionHandler, inbox, archive]
);
this method whenever inbox/archive changes it will not work it will be work only when you call this method and any of the value in dependency array has changed. (you can use useEffect and pass and deps arr [inbox,archive] which will run every time and can update your state.

Update position of All places in react-native-sortable-listview

I am using react-native-sortable-listview in react-native for sorting same places.
constructor() {
this.state = {
makers: [
{ kolkata: 'Hawrah Birdge' },
{ Delhi: 'Lal Kila' },
{ Agra: 'Taj Mahal' },
{ Mumbai: 'India Gate' },
],
allObj: {},
order: []
};
}
componentDidMount() {
const newAllObj = this.getAllObjFromMaker(this.state.makers);
const newOrder = this.getOrderFromMaker(newAllObj);
this.setState({ allObj: newAllObj, order: newOrder });
}
getAllObjFromMaker(makers) {
const allObj = makers.reduce((result, d) => {
result[`${d.coordinate.latitude}_${d.coordinate.longitude}`] = d;
return result;
}, {});
return allObj;
}
getOrderFromMaker(allObj) {
const order = Object.keys(allObj);
return order;
}
renderOneDraggableMilestone(milestone) {
const i = this.state.makers.indexOf(milestone);
return (
<TouchableOpacity {...this.props.sortHandlers}>
<Text>{i + 1}</Text>
<Text>{milestone.address}</Text>
</TouchableOpacity>
);
}
arrangedMilestoneList(e) {
const arr = this.state.makers;
arr.splice(e.to, 0, arr.splice(e.from, 1)[0]);
const newAllObj = this.getAllObjFromMaker(arr);
const newOrder = this.getOrderFromMaker(newAllObj);
this.setState({ makers: arr, allObj: newAllObj, order: newOrder
});
}
render() {
return (
<SortableListView
data={this.state.allObj}
order={this.state.order}
activeOpacity={0.5}
onRowMoved={e => {
this.arrangedMilestoneList(e);
this.forceUpdate();
}}
renderRow={(row) => this.renderOneDraggableMilestone(row)}
/>
);
}
I want to arrange places and also their position in this.state.makers as I am doing using i in renderOneDraggableMilestone. On renderRow only draggable place are render so only their position is updated. And renderRow is last to excute so forceUpdate is also not working.
How to rerender after executing renderRow. So all position could be updated.
Ok I have find a way to re-render as follow.
<SortableListView
key={this.state.count}
data={this.state.allObj}
order={this.state.order}
activeOpacity={0.5}
onRowMoved={e => {
this.setState({ count: this.state.count + 1 });
this.props.arrangedMilestoneList(e);
console.log('onRowMoved is called');
}}
onMoveEnd={() => console.log('onMoveEnd is fired')}
renderRow={(row, s1, i) => this.renderOneDraggableMilestone(row, s1, i)}
/>
What I am doing is I added a key attribute to SortableListView and update this key on each onRowMoved action. And because of this it causes re-render.

Scrollable image with pinch-to-zoom in react-native

I'm trying to display an image in my React Native app (Android) and I want to give users an ability to zoom that image in and out.
This also requires the image to be scrollable once zoomed in.
How would I go about it?
I tried to use ScrollView to display a bigger image inside, but on Android it can either scroll vertically or horizontally, not both ways.
Even if that worked there is a problem of making pinch-to-zoom work.
As far as I understand I need to use PanResponder on a custom view to zoom an image and position it accordingly. Is there an easier way?
I ended up rolling my own ZoomableImage component. So far it's been working out pretty well, here is the code:
import React, { Component } from "react";
import { View, PanResponder, Image } from "react-native";
import PropTypes from "prop-types";
function calcDistance(x1, y1, x2, y2) {
const dx = Math.abs(x1 - x2);
const dy = Math.abs(y1 - y2);
return Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));
}
function calcCenter(x1, y1, x2, y2) {
function middle(p1, p2) {
return p1 > p2 ? p1 - (p1 - p2) / 2 : p2 - (p2 - p1) / 2;
}
return {
x: middle(x1, x2),
y: middle(y1, y2)
};
}
function maxOffset(offset, windowDimension, imageDimension) {
const max = windowDimension - imageDimension;
if (max >= 0) {
return 0;
}
return offset < max ? max : offset;
}
function calcOffsetByZoom(width, height, imageWidth, imageHeight, zoom) {
const xDiff = imageWidth * zoom - width;
const yDiff = imageHeight * zoom - height;
return {
left: -xDiff / 2,
top: -yDiff / 2
};
}
class ZoomableImage extends Component {
constructor(props) {
super(props);
this._onLayout = this._onLayout.bind(this);
this.state = {
zoom: null,
minZoom: null,
layoutKnown: false,
isZooming: false,
isMoving: false,
initialDistance: null,
initialX: null,
initalY: null,
offsetTop: 0,
offsetLeft: 0,
initialTop: 0,
initialLeft: 0,
initialTopWithoutZoom: 0,
initialLeftWithoutZoom: 0,
initialZoom: 1,
top: 0,
left: 0
};
}
processPinch(x1, y1, x2, y2) {
const distance = calcDistance(x1, y1, x2, y2);
const center = calcCenter(x1, y1, x2, y2);
if (!this.state.isZooming) {
const offsetByZoom = calcOffsetByZoom(
this.state.width,
this.state.height,
this.props.imageWidth,
this.props.imageHeight,
this.state.zoom
);
this.setState({
isZooming: true,
initialDistance: distance,
initialX: center.x,
initialY: center.y,
initialTop: this.state.top,
initialLeft: this.state.left,
initialZoom: this.state.zoom,
initialTopWithoutZoom: this.state.top - offsetByZoom.top,
initialLeftWithoutZoom: this.state.left - offsetByZoom.left
});
} else {
const touchZoom = distance / this.state.initialDistance;
const zoom =
touchZoom * this.state.initialZoom > this.state.minZoom
? touchZoom * this.state.initialZoom
: this.state.minZoom;
const offsetByZoom = calcOffsetByZoom(
this.state.width,
this.state.height,
this.props.imageWidth,
this.props.imageHeight,
zoom
);
const left =
this.state.initialLeftWithoutZoom * touchZoom + offsetByZoom.left;
const top =
this.state.initialTopWithoutZoom * touchZoom + offsetByZoom.top;
this.setState({
zoom,
left:
left > 0
? 0
: maxOffset(left, this.state.width, this.props.imageWidth * zoom),
top:
top > 0
? 0
: maxOffset(top, this.state.height, this.props.imageHeight * zoom)
});
}
}
processTouch(x, y) {
if (!this.state.isMoving) {
this.setState({
isMoving: true,
initialX: x,
initialY: y,
initialTop: this.state.top,
initialLeft: this.state.left
});
} else {
const left = this.state.initialLeft + x - this.state.initialX;
const top = this.state.initialTop + y - this.state.initialY;
this.setState({
left:
left > 0
? 0
: maxOffset(
left,
this.state.width,
this.props.imageWidth * this.state.zoom
),
top:
top > 0
? 0
: maxOffset(
top,
this.state.height,
this.props.imageHeight * this.state.zoom
)
});
}
}
_onLayout(event) {
const layout = event.nativeEvent.layout;
if (
layout.width === this.state.width &&
layout.height === this.state.height
) {
return;
}
const zoom = layout.width / this.props.imageWidth;
const offsetTop =
layout.height > this.props.imageHeight * zoom
? (layout.height - this.props.imageHeight * zoom) / 2
: 0;
this.setState({
layoutKnown: true,
width: layout.width,
height: layout.height,
zoom,
offsetTop,
minZoom: zoom
});
}
componentWillMount() {
this._panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: () => true,
onMoveShouldSetPanResponderCapture: () => true,
onPanResponderGrant: () => {},
onPanResponderMove: evt => {
const touches = evt.nativeEvent.touches;
if (touches.length === 2) {
this.processPinch(
touches[0].pageX,
touches[0].pageY,
touches[1].pageX,
touches[1].pageY
);
} else if (touches.length === 1 && !this.state.isZooming) {
this.processTouch(touches[0].pageX, touches[0].pageY);
}
},
onPanResponderTerminationRequest: () => true,
onPanResponderRelease: () => {
this.setState({
isZooming: false,
isMoving: false
});
},
onPanResponderTerminate: () => {},
onShouldBlockNativeResponder: () => true
});
}
render() {
return (
<View
style={this.props.style}
{...this._panResponder.panHandlers}
onLayout={this._onLayout}
>
<Image
style={{
position: "absolute",
top: this.state.offsetTop + this.state.top,
left: this.state.offsetLeft + this.state.left,
width: this.props.imageWidth * this.state.zoom,
height: this.props.imageHeight * this.state.zoom
}}
source={this.props.source}
/>
</View>
);
}
}
ZoomableImage.propTypes = {
imageWidth: PropTypes.number.isRequired,
imageHeight: PropTypes.number.isRequired,
source: PropTypes.object.isRequired
};
export default ZoomableImage;
There's a much easier way now.
Just make a ScollView with minimumZoomScale and maximumZoomScale:
import React, { Component } from 'react';
import { AppRegistry, ScrollView, Text } from 'react-native';
export default class IScrolledDownAndWhatHappenedNextShockedMe extends Component {
render() {
return (
<ScrollView minimumZoomScale={1} maximumZoomScale={5} >
<Text style={{fontSize:96}}>Scroll me plz</Text>
<Text style={{fontSize:96}}>If you like</Text>
<Text style={{fontSize:96}}>Scrolling down</Text>
<Text style={{fontSize:96}}>What's the best</Text>
<Text style={{fontSize:96}}>Framework around?</Text>
<Text style={{fontSize:80}}>React Native</Text>
</ScrollView>
);
}
}
// skip these lines if using Create React Native App
AppRegistry.registerComponent(
'AwesomeProject',
() => IScrolledDownAndWhatHappenedNextShockedMe);
In my case I have to add images inside Viewpager with Zoom functionality.
So I have used these two library.
import ViewPager from '#react-native-community/viewpager'
import PhotoView from 'react-native-photo-view-ex';
which you can install from.
npm i #react-native-community/viewpager
npm i react-native-photo-view-ex
So I have used this code.
class ResumeView extends React.Component {
render() {
preivewArray = this.props.showPreview.previewArray
var pageViews = [];
for (i = 0; i < preivewArray.length; i++) {
pageViews.push(<View style={style.page}>
<PhotoView
source={{ uri: preivewArray[i].filePath }}
minimumZoomScale={1}
maximumZoomScale={3}
// resizeMode='stretch'
style={{ width: a4_width, height: a4_height, alignSelf: 'center' }} />
</View>);
}
return (
<ViewPager
onPageScroll={this.pageScroll}
style={{ width: '100%', height: a4_height }}>
{pageViews}
</ViewPager>
)
}
pageScroll = (event) => {
console.log("onPageScroll")
}
}
You can simply use the react-native-image-zoom-viewer or react-native-image-pan-zoom library for that. Using this libraries you don't have to code manually.
npm i react-native-photo-view-ex
import PhotoView from 'react-native-photo-view-ex';
<PhotoView
style={{ flex: 1, width: '100%', height: '100%' }}
source={{ uri: this.state.filePath }} // you can supply any URL as well
minimumZoomScale={1} // max value can be 1
maximumZoomScale={2} // max value can be 3
/>
Don't go deep if you are working with react-native because things will go more and more complex as deep you go.
Give it a try...
npm i react-native-image-zoom-viewer --save
or
yarn add react-native-image-zoom-viewer
copy this code and put it in app.js and hit Run button.
import React from 'react';
import {View} from 'react-native';
import ImageViewer from 'react-native-image-zoom-viewer';
const image = [
{
url:
'https://static8.depositphotos.com/1020341/896/i/950/depositphotos_8969502-stock-photo-human-face-with-cracked-texture.jpg',
},
];
const App = () => {
return (
<View style={{flex: 1}}>
<ImageViewer imageUrls={image} />
</View>
);
};
export default App;
Features like zoom, pan, tap/swipe to switch image using react-native-gesture-handler,react-native-reanimated. Perfectly and smoothly running on android/ios.
USAGE
<ImageZoomPan
uri={'https://picsum.photos/200/300'}
activityIndicatorProps={{
color: COLOR_SECONDARY,
}}
onInteractionStart={onInteractionStart}
onInteractionEnd={onInteractionEnd}
onLongPressActiveInteration={onPressIn}
onLongPressEndInteration={onPressOut}
onSwipeTapForNext={onSwipeTapForNext}
onSwipeTapForPrev={onSwipeTapForPrev}
minScale={0.8}
onLoadEnd={start}
resizeMode={isFullScreen ? 'cover' : 'contain'} //'stretch'
/>
Image Zoom component
import React, {useRef, useState} from 'react';
import {ActivityIndicator,Dimensions, Image} from 'react-native';
import {
LongPressGestureHandler,
PanGestureHandler,
PinchGestureHandler,
State,
TapGestureHandler,
} from 'react-native-gesture-handler';
import Animated, {
useAnimatedGestureHandler,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import styles from './styles';
const clamp = (value, min, max) => {
'worklet';
return Math.min(Math.max(min, value), max);
};
const noop = () => {};
const getDeviceWidth = () => {
return Dimensions.get('window').width;
};
const AnimatedImage = Animated.createAnimatedComponent(Image);
export default function ImageZoom({
uri = '',
minScale = 1,
maxScale = 5,
minPanPointers = 2,
maxPanPointers = 2,
isPanEnabled = true,
isPinchEnabled = true,
onLoadEnd = noop,
onInteractionStart = noop,
onInteractionEnd = noop,
onPinchStart = noop,
onPinchEnd = noop,
onPanStart = noop,
onPanEnd = noop,
onLongPressActiveInteration = noop,
onLongPressEndInteration = noop,
onSwipeTapForNext = noop,
onSwipeTapForPrev = noop,
style = {},
containerStyle = {},
imageContainerStyle = {},
activityIndicatorProps = {},
renderLoader,
resizeMode = 'cover',
...props
}) {
const panRef = useRef();
const pinchRef = useRef();
const isInteracting = useRef(false);
const isPanning = useRef(false);
const isPinching = useRef(false);
const doubleTapRef = useRef(null);
const [isLoading, setIsLoading] = useState(true);
const [state, setState] = useState({
canInteract: false,
centerX: 0,
centerY: 0,
});
const {canInteract, centerX, centerY} = state;
const scale = useSharedValue(1);
const initialFocalX = useSharedValue(0);
const initialFocalY = useSharedValue(0);
const focalX = useSharedValue(0);
const focalY = useSharedValue(0);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const onInteractionStarted = () => {
if (!isInteracting.current) {
isInteracting.current = true;
onInteractionStart();
}
};
const onInteractionEnded = () => {
if (isInteracting.current && !isPinching.current && !isPanning.current) {
isInteracting.current = false;
onInteractionEnd();
}
};
const onPinchStarted = () => {
onInteractionStarted();
isPinching.current = true;
onPinchStart();
};
const onPinchEnded = () => {
isPinching.current = false;
onPinchEnd();
onInteractionEnded();
};
const onPanStarted = () => {
onInteractionStarted();
isPanning.current = true;
onPanStart();
};
const onPanEnded = () => {
isPanning.current = false;
onPanEnd();
onInteractionEnded();
};
const panHandler = useAnimatedGestureHandler({
onActive: event => {
translateX.value = event.translationX;
translateY.value = event.translationY;
},
onFinish: () => {
translateX.value = withTiming(0);
translateY.value = withTiming(0);
},
});
const pinchHandler = useAnimatedGestureHandler({
onStart: event => {
initialFocalX.value = event.focalX;
initialFocalY.value = event.focalY;
},
onActive: event => {
// onStart: focalX & focalY result both to 0 on Android
if (initialFocalX.value === 0 && initialFocalY.value === 0) {
initialFocalX.value = event.focalX;
initialFocalY.value = event.focalY;
}
scale.value = clamp(event.scale, minScale, maxScale);
focalX.value = (centerX - initialFocalX.value) * (scale.value - 1);
focalY.value = (centerY - initialFocalY.value) * (scale.value - 1);
},
onFinish: () => {
scale.value = withTiming(1);
focalX.value = withTiming(0);
focalY.value = withTiming(0);
initialFocalX.value = 0;
initialFocalY.value = 0;
},
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{translateX: translateX.value},
{translateY: translateY.value},
{translateX: focalX.value},
{translateY: focalY.value},
{scale: scale.value},
],
}));
const onLayout = ({
nativeEvent: {
layout: {x, y, width, height},
},
}) => {
setState(current => ({
...current,
canInteract: true,
centerX: x + width / 2,
centerY: y + height / 2,
}));
};
const onImageLoadEnd = () => {
onLoadEnd();
setIsLoading(false);
};
const onLongPress = event => {
if (event.nativeEvent.state === State.ACTIVE) {
onLongPressActiveInteration();
}
if (
event.nativeEvent.state === State.END ||
event.nativeEvent.state === State.CANCELLED
) {
onLongPressEndInteration();
}
};
const onSingleTapEvent = event => {
let e = event.nativeEvent;
if (e.state === State.ACTIVE) {
if (e.x < getDeviceWidth() / 2) {
onSwipeTapForPrev();
} else {
onSwipeTapForNext();
}
}
};
return (
<PinchGestureHandler
ref={pinchRef}
simultaneousHandlers={[panRef]}
onGestureEvent={pinchHandler}
onActivated={onPinchStarted}
onCancelled={onPinchEnded}
onEnded={onPinchEnded}
onFailed={onPinchEnded}
enabled={isPinchEnabled && canInteract}>
<Animated.View style={[styles.container, containerStyle]}>
<PanGestureHandler
ref={panRef}
simultaneousHandlers={[pinchRef]}
onGestureEvent={panHandler}
onActivated={onPanStarted}
onCancelled={onPanEnded}
onEnded={onPanEnded}
onFailed={onPanEnded}
minPointers={minPanPointers}
maxPointers={maxPanPointers}
enabled={isPanEnabled && canInteract}>
<Animated.View
onLayout={onLayout}
style={[styles.content, imageContainerStyle]}>
<TapGestureHandler
waitFor={doubleTapRef}
onHandlerStateChange={onSingleTapEvent}>
<TapGestureHandler
ref={doubleTapRef}
onHandlerStateChange={() => null}
numberOfTaps={2}>
<LongPressGestureHandler
onHandlerStateChange={onLongPress}
minDurationMs={800}>
<AnimatedImage
style={[styles.container, style, animatedStyle]}
source={{uri}}
resizeMode={resizeMode}
onLoadEnd={onImageLoadEnd}
{...props}
/>
</LongPressGestureHandler>
</TapGestureHandler>
</TapGestureHandler>
{isLoading &&
(renderLoader ? (
renderLoader()
) : (
<ActivityIndicator
size="large"
style={styles.loader}
color="dimgrey"
{...activityIndicatorProps}
/>
))}
</Animated.View>
</PanGestureHandler>
</Animated.View>
</PinchGestureHandler>
);
}