Search code examples
iosreactjssafaricarouseltouch-event

Touchevents not firing on IOS Safari in React custom carousel component after first swipe


I created a custom photo carousel component in React (because external libraries were either too hard to work with or did not do the things that I wanted it to do), where you can swipe to the next/previous photo on mobile. Everything works fine on Android, but it's just IOS Safari.

The Problem

I have a page that maps out several carousels. The first carousel in the map works perfectly fine. Subsequent carousels will swipe correctly AFTER the first slide, but once it transitions to the second slide, the touch events stop firing, and it will not swipe. What I want is all the carousels like the first carousel. No error messages seen either. See video:

Code

Here is the custom component:

import { useState, useEffect } from 'react'

const Carousel = ({ children }) => {
  const IMG_WIDTH = 400

  const [currentIndex, setCurrentIndex] = useState(0)
  const [lastTouch, setLastTouch] = useState(0)
  const [movement, setMovement] = useState(0)
  const [transitionDuration, setTransitionDuration] = useState('')
  const [transitionTimeout, setTransitionTimeout] = useState(null)
  const maxLength = children.length - 1,
    maxMovement = maxLength * IMG_WIDTH

  useEffect(() => {
    return () => {
      clearTimeout(transitionTimeout)
    }
  }, [])

  const transitionTo = (index, duration) => {
    setCurrentIndex(index)
    setMovement(index * IMG_WIDTH)
    setTransitionDuration(`${duration}s`)

    setTransitionTimeout(
      setTimeout(() => {
        setTransitionDuration('0s')
      }, duration * 100))
  }

  const handleMovementEnd = () => {
    const endPosition = movement / IMG_WIDTH
    const endPartial = endPosition % 1
    const endingIndex = endPosition - endPartial
    const deltaInteger = endingIndex - currentIndex

    let nextIndex = endingIndex

    if (deltaInteger >= 0) {
      if (endPartial >= 0.1) {
        nextIndex++
      }
    } else if (deltaInteger < 0) {
      nextIndex = currentIndex - Math.abs(deltaInteger)
      if (endPartial > 0.9) {
        nextIndex++
      }
    }

    transitionTo(nextIndex, Math.min(0.5, 1 - Math.abs(endPartial)))
  }

  const handleMovement = delta => {
    clearTimeout(transitionTimeout)

    const maxLength = children.length - 1
    let nextMovement = movement + delta

    if (nextMovement < 0) {
      nextMovement = 0
    }

    if (nextMovement > maxLength * IMG_WIDTH) {
      nextMovement = maxLength * IMG_WIDTH
    }
    setMovement(nextMovement)
    setTransitionDuration('0s')
  }

  const handleTouchStart = event => {
    setLastTouch(event.nativeEvent.touches[0].clientX)
  }

  const handleTouchMove = event => {
    const delta = lastTouch - event.nativeEvent.touches[0].clientX
    setLastTouch(event.nativeEvent.touches[0].clientX)
    handleMovement(delta)
  }

  const handleTouchEnd = () => {
    handleMovementEnd()
    setLastTouch(0)
  }

  return (
    <div
      className="main"
      style={{ width: IMG_WIDTH }}
      onTouchStart={handleTouchStart}
      onTouchMove={handleTouchMove}
      onTouchEnd={handleTouchEnd}>
      <div
        className="swiper"
        style={{
          transform: `translateX(${movement * -1}px)`,
          transitionDuration: transitionDuration
        }}>
        {children} // This is just <img /> tags
      </div>
      <div className="bullets">
        {[...Array(children.length)].map((bullet, index) => (
          <div key={`bullet-${index}`} className={`dot ${currentIndex === index && 'red-dot'}`} />
        ))}
      </div>
    </div>
  )
}

export default Carousel

And here is the part of my code where I am using the custom component:

return (
    <>
      <Announcement />
      <Header />

      <section className="tiles-section">
        <Title />
        {Component} // This is just a component that simply maps out the Carousel, nothing special and no extra styling
      </section>
      ...
    </>
  )

And CSS:

.main {
    overflow: hidden;
    position: relative;
    touch-action: pan-y;
}

.swiper {
    display: flex;
    overflow-x: visible;
    transition-property: transform;
    will-change: transform;
}

What I know/tried

  • Removing some of the components above the Carousel (eg. Title, Announcement, etc.) makes part of the slides other than the first slide swipable. It can be half of the height of the slide or 1/3 of the height that is swipable, depending on how much I remove. This excludes the first carousel, that one still works perfectly fine
  • I've tried removing a bunch of CSS, didn't work
  • Tried adding event listeners to the 'main' div, maybe I did something wrong but swiping one carousel ends up swiping all the carousels
  • Thought it had something to do with the touch event handler functions I created in my custom carousel component, but it seems like they work fine, because after changing them to console.logs and manually translating the slides, the touch events are still not firing.

Update #1

I put the touch event handlers in a new div that wraps {child}. The second slide is swipeable now but the third slide is still a bust.

References

This is the tutorial link I followed to create the carousel for more context to the custom carousel component.


Solution

  • After days and hours, the solution is kind of odd. I'm guessing you need to set the target correctly to the element's div id. I put this in the useEffect, so the result looks like this:

    useEffect(() => {
        document.getElementById(id).addEventListener('touchstart', () => false, { passive: false })
    
        return () => {
          clearTimeout(transitionTimeout)
        }
      }, [])
    

    NOTE that the id MUST be unique especially if creating a map of the same component like what I did. Otherwise, swiping one carousel will swipe all carousels.