I'm working on this feedback board web site, and I have this roadmap route where users get to see what is the web site improvement roadmap. On mobile I would like to have a three section slide: planned, in-progress and live. So I got this code from Stack Overflow that enables me to detect touch sliding events. So my goal was to navigate from a roadmap stage to another whenever the user swipes left or right.
BUT whenever I swipe to "Live" or "Planned" section, and then try go go back to the "In-Progress" section , it jumps it and go directly to the section after it.
To repreduce this: here is the live link
Here is the event listener that I used:
document.querySelector('#roadmap_stages').addEventListener('gesture-right',()=>{
onScroll("roadmap_stages","r")
switch(currentStage){
case "In-Progress":
dispatch(setcurrentStage("Planned"))
break;
case "Live":
dispatch(setcurrentStage("In-Progress"))
break;
}
})
document.querySelector('#roadmap_stages').addEventListener('gesture-left',()=>{
onScroll("roadmap_stages","l")
switch(currentStage){
case "Planned":
dispatch(setcurrentStage("In-Progress"))
break;
case "In-Progress":
dispatch(setcurrentStage("Live"))
break;
}
})
Here is the onScroll
function: the goal of this function is to take care of the animations
function onScroll (id,direction){
const navigater = document.querySelector(".roadmap_roadmap_stages__FAUDD")
const currentPosition = window.getComputedStyle(navigater).left
let to;
if(direction === "r"){
switch (currentStage){
case "Planned":
to = ""
break;
case "In-Progress":
to = "one"
break;
case "Live":
to = "two"
break;
}
}
else{
switch (currentStage){
case "Planned":
to = "two"
break;
case "In-Progress":
to = "three"
break;
case "Live":
to = ""
break;
}
}
navigater.style.left = `${currentPosition}`
navigater.style.animationName = `${to}`
}
Here is my redux slice:
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
screenWidth:null,
isMenuOpen:false,
isSortOpen:false,
sortMethode:"Most Upvotes",
filter:"all",
currentStage:"In-Progress",
}
const uiSlice = createSlice({
name:"ui",
initialState,
reducers:{
setScreenWidth:(state,{payload})=>{
state.screenWidth = payload
},
toggleMenu:(state,{payload})=>{
state.isMenuOpen = payload
},
toggleSort:(state,{payload})=>{
state.isSortOpen = payload
},
setSortMethode:(state,{payload})=>{
state.sortMethode = payload
},
setFilter:(state,{payload})=>{
state.filter = payload
},
setcurrentStage:(state,{payload})=>{
console.log(state.currentStage)
state.currentStage = payload
console.log(state.currentStage)
},
}
})
export default uiSlice.reducer
export const {setScreenWidth,toggleMenu,toggleSort,setSortMethode,setFilter,setcurrentStage} = uiSlice.actions
and here are the animations
@keyframes one {
100%{left: 0;}
}
@keyframes two {
100%{left: -100%;}
}
@keyframes three {
100%{left: -200%;}
}
and here is the whole function just for reference :
import React, { useEffect, useRef } from 'react'
//components
import Stage from './Stage'
//styles
import styles from "@/styles/css/roadmap.module.css"
//state
import { useDispatch, useSelector } from 'react-redux'
import { store } from '@/state/store'
import { setcurrentStage } from '@/state/slices/uiSlice'
export default function RoadmapStages(props) {
const {planned,inProgress,live} = props.roadmapData
const stages = useRef(null)
const dispatch = useDispatch()
const currentStage = store.getState().ui.currentStage
// dispatch(setcurrentStage("tagopi"))
function onScroll (id,direction){
const navigater = document.querySelector(".roadmap_roadmap_stages__FAUDD")
const currentPosition = window.getComputedStyle(navigater).left
let to;
if(direction === "r"){
switch (currentStage){
case "Planned":
to = ""
break;
case "In-Progress":
to = "one"
break;
case "Live":
to = "two"
break;
}
}
else{
switch (currentStage){
case "Planned":
to = "two"
break;
case "In-Progress":
to = "three"
break;
case "Live":
to = ""
break;
}
}
navigater.style.left = `${currentPosition}`
navigater.style.animationName = `${to}`
}
useEffect(()=>{
//mobile-scrolling-event-listener
(function(d) {
// based on original source: https://stackoverflow.com/a/17567696/334451
var newEvent = function(e, name) {
// This style is already deprecated but very well supported in real world: https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/initCustomEvent
// in future we want to use CustomEvent function: https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent
var a = document.createEvent("CustomEvent");
a.initCustomEvent(name, true, true, e.target);
e.target.dispatchEvent(a);
a = null;
return false
};
var debug = false; // emit info to JS console for all touch events?
var active = false; // flag to tell if touchend should complete the gesture
var min_gesture_length = 20; // minimum gesture length in pixels
var tolerance = 0.3; // value 0 means pixel perfect movement up or down/left or right is required, 0.5 or more means any diagonal will do, values between can be tweaked
var sp = { x: 0, y: 0, px: 0, py: 0 }; // start point
var ep = { x: 0, y: 0, px: 0, py: 0 }; // end point
var touch = {
touchstart: function(e) {
active = true;
var t = e.touches[0];
sp = { x: t.screenX, y: t.screenY, px: t.pageX, py: t.pageY };
ep = sp; // make sure we have a sensible end poin in case next event is touchend
debug && console.log("start", sp);
},
touchmove: function(e) {
if (e.touches.length > 1) {
active = false;
debug && console.log("aborting gesture because multiple touches detected");
return;
}
var t = e.touches[0];
ep = { x: t.screenX, y: t.screenY, px: t.pageX, py: t.pageY };
debug && console.log("move", ep, sp);
},
touchend: function(e) {
if (!active)
return;
debug && console.log("end", ep, sp);
var dx = Math.abs(ep.x - sp.x);
var dy = Math.abs(ep.y - sp.y);
if (Math.max(dx, dy) < min_gesture_length) {
debug && console.log("ignoring short gesture");
return; // too short gesture, ignore
}
if (dy > dx && dx/dy < tolerance && Math.abs(sp.py - ep.py) > min_gesture_length) { // up or down, ignore if page scrolled with touch
newEvent(e, (ep.y - sp.y < 0 ? 'gesture-up' : 'gesture-down'));
//e.cancelable && e.preventDefault();
}
else if (dx > dy && dy/dx < tolerance && Math.abs(sp.px - ep.px) > min_gesture_length) { // left or right, ignore if page scrolled with touch
newEvent(e, (ep.x - sp.x < 0 ? 'gesture-left' : 'gesture-right'));
//e.cancelable && e.preventDefault();
}
else {
debug && console.log("ignoring diagonal gesture or scrolled content");
}
active = false;
},
touchcancel: function(e) {
debug && console.log("cancelling gesture");
active = false;
}
};
for (var a in touch) {
d.addEventListener(a, touch[a], false);
// TODO: MSIE touch support: https://github.com/CamHenlin/TouchPolyfill
}
})(window.document);
document.querySelector('#roadmap_stages').addEventListener('gesture-right',()=>{
onScroll("roadmap_stages","r")
switch(currentStage){
case "In-Progress":
dispatch(setcurrentStage("Planned"))
break;
case "Live":
dispatch(setcurrentStage("In-Progress"))
break;
}
})
document.querySelector('#roadmap_stages').addEventListener('gesture-left',()=>{
onScroll("roadmap_stages","l")
switch(currentStage){
case "Planned":
dispatch(setcurrentStage("In-Progress"))
break;
case "In-Progress":
dispatch(setcurrentStage("Live"))
break;
}
})
},[])
return (
<div ref={stages} className={styles.roadmap_stages} id="roadmap_stages" >
<Stage stageData={planned} />
<Stage stageData={inProgress} />
<Stage stageData={live} />
</div>
)
}
Here is the GitHub link
I was and I'm still stuck on this bug for two days and would highly any help from the Stack Overflow community.
As far as I can tell this is an issue of stale closure over the currentStage
state. Firstly, the RoadmapStages
component isn't subscribed to app's redux store, so it is not going to be notified of any changes. Secondly, the onScroll
callbacks are never re-instantiated to close over any updated currentStage
values.
I suggest the following refactor to (A) subscribe the component to redux state changes and (B) correctly handle instantiating the callbacks and cleaning up effects.
export default function RoadmapStages(props) {
const { planned, inProgress, live } = props.roadmapData;
const stages = useRef(null);
const dispatch = useDispatch();
const { currentStage } = useSelector(state => state.ui);
useEffect(() => {
// mobile-scrolling-event-listener
/* Return any necessary mobile touch/scroll handlers if necessary */
}, []);
useEffect(() => {
function onScroll (id, direction) {
const navigater = document.querySelector(".roadmap_roadmap_stages__FAUDD");
const currentPosition = window.getComputedStyle(navigater).left
let to;
if (direction === "r") {
switch (currentStage){
case "Planned":
to = ""
break;
case "In-Progress":
to = "one"
break;
case "Live":
to = "two"
break;
}
} else {
switch (currentStage){
case "Planned":
to = "two"
break;
case "In-Progress":
to = "three"
break;
case "Live":
to = ""
break;
}
}
navigater.style.left = `${currentPosition}`
navigater.style.animationName = `${to}`
}
const handleRightGesture = () => {
onScroll("roadmap_stages", "r");
switch(currentStage) {
case "In-Progress":
dispatch(setcurrentStage("Planned"));
break;
case "Live":
dispatch(setcurrentStage("In-Progress"));
break;
}
};
const handleLeftGesture = () => {
onScroll("roadmap_stages", "l");
switch(currentStage) {
case "Planned":
dispatch(setcurrentStage("In-Progress"));
break;
case "In-Progress":
dispatch(setcurrentStage("Live"));
break;
}
}
stages.current.addEventListener('gesture-right', handleRightGesture);
stages.current.addEventListener('gesture-left', handleLeftGesture);
return () => {
stages.current.removeEventListener('gesture-right', handleRightGesture);
stages.current.removeEventListener('gesture-left', handleLeftGesture);
};
}, [currentStage]);
return (
<div ref={stages} className={styles.roadmap_stages} id="roadmap_stages" >
<Stage stageData={planned} />
<Stage stageData={inProgress} />
<Stage stageData={live} />
</div>
);
}