Fading Coder

One Final Commit for the Last Sprint

Home > Tech > Content

Implementing Hierarchical Slide Navigation in Vue with Touch Event Coordination

Tech 1

Problem Overview

Building multi-layer slide interfaces in Vue requires careful coordination between parent and child page scrolling behaviors. When a parent container contains nested swipable components, touch events must be properly routed to prevent interference and ensure smooth user experience across all interaction levels.

Core Solution Architecture

The implementation distinguishes between three distinct scroll states:

  1. Child-level scrolling - Individual slide items within a parent section
  2. Parent-level scrolling - Full-page transitions between major sections
  3. Footer reveal mechanism - Bottom component exposure when reaching section boundaries

Touch Event Directive Implementation

Creating reusable touch directives provides a clean abstraction for swipe detection across the application:

// touchHandler.js
export class TouchHandler {
  constructor(element, callback, gestureType) {
    this.element = element;
    this.callback = callback;
    this.gestureType = gestureType;
    this.startX = 0;
    this.startY = 0;
    this.isValidMovement = true;
    this.isLongPress = true;
    
    this.bindEvents();
  }
  
  bindEvents() {
    this.element.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: true });
    this.element.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: true });
    this.element.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false });
  }
  
  handleTouchStart(event) {
    this.isValidMovement = true;
    this.isLongPress = true;
    this.startX = event.changedTouches[0].pageX;
    this.startY = event.changedTouches[0].pageY;
    
    this.pressTimer = setTimeout(() => {
      if (this.isLongPress && this.isValidMovement) {
        this.triggerCallback('longpress');
        this.isLongPress = false;
      }
    }, 1000);
  }
  
  handleTouchEnd(event) {
    clearTimeout(this.pressTimer);
    const endX = event.changedTouches[0].pageX;
    const endY = event.changedTouches[0].pageY;
    
    const deltaX = endX - this.startX;
    const deltaY = endY - this.startY;
    const distance = Math.abs(deltaX) + Math.abs(deltaY);
    
    if (distance > 50) {
      const horizontalThreshold = Math.abs(deltaX) > Math.abs(deltaY);
      
      if (horizontalThreshold) {
        if (deltaX > 30) this.triggerCallback('swiperight');
        if (deltaX < -30) this.triggerCallback('swipeleft');
      } else {
        if (deltaY > 30) this.triggerCallback('swipedown');
        if (deltaY < -30) this.triggerCallback('swipeup');
      }
    } else {
      if (this.isLongPress) {
        this.triggerCallback('tap');
      }
    }
  }
  
  handleTouchMove() {
    this.isValidMovement = false;
  }
  
  triggerCallback(gesture) {
    if (this.gestureType === gesture || this.gestureType === 'all') {
      this.callback();
    }
  }
}

// directives/touch.js
import { TouchHandler } from '../utils/touchHandler';

export const swipeUp = {
  mounted(el, binding) {
    el._touchHandler = new TouchHandler(el, binding.value, 'swipeup');
  },
  unmounted(el) {
    if (el._touchHandler) {
      el._touchHandler = null;
    }
  }
};

export const swipeDown = {
  mounted(el, binding) {
    el._touchHandler = new TouchHandler(el, binding.value, 'swipedown');
  }
};

export const swipeLeft = {
  mounted(el, binding) {
    el._touchHandler = new TouchHandler(el, binding.value, 'swipeleft');
  }
};

export const swipeRight = {
  mounted(el, binding) {
    el._touchHandler = new TouchHandler(el, binding.value, 'swiperight');
  }
};

Main Component Implementation

The component manages scroll state through a hierarchical tracking system:

<template>
  <div class="container">
    <header-component />
    
    <main class="viewport" :style="viewportStyles">
      <section class="carousel" ref="carousel">
        <article v-for="(section, index) in sections" :key="index" class="slide">
          <div class="slide-content">
            <img :src="section.background" :style="slideStyles" />
            
            <div class="text-overlay">
              <h2>{{ section.title }}</h2>
            </div>
            
            <div v-if="section.hasNestedCarousel" 
                 class="nested-carousel"
                 :class="{ 'is-visible': currentSection === index }"
                 @swipeup="handleNestedSwipeUp(index)"
                 @swipedown="handleNestedSwipeDown(index)">
              <ul class="nested-items">
                <li v-for="(item, itemIndex) in section.nestedItems" :key="itemIndex">
                  <img :src="item.image" />
                  <button @click="navigateTo(item.route)">{{ item.label }}</button>
                </li>
              </ul>
            </div>
          </div>
        </article>
      </section>
      
      <div v-if="showOverlay" 
           class="scroll-overlay"
           @scroll="handleWindowScroll">
      </div>
    </main>
    
    <footer-component />
  </div>
</template>

<script>
import HeaderComponent from './components/Header.vue';
import FooterComponent from './components/Footer.vue';
import { swipeUp, swipeDown } from '../directives/touch';

export default {
  name: 'HierarchicalCarousel',
  
  directives: {
    swipeUp,
    swipeDown
  },
  
  components: {
    HeaderComponent,
    FooterComponent
  },
  
  data() {
    return {
      viewportHeight: 0,
      currentSection: 0,
      nestedScrollPosition: 0,
      maxNestedScroll: 2,
      showOverlay: false,
      footerHeight: 48,
      nestedScrollStates: {},
      
      sections: [
        {
          title: 'Welcome',
          background: '/static/images/section-1-bg.png',
          hasNestedCarousel: false
        },
        {
          title: 'Data Dashboard',
          background: '/static/images/section-2-bg.png',
          hasNestedCarousel: true,
          nestedItems: [
            { image: '/static/images/item-1.png', label: 'View Data', route: '/data' },
            { image: '/static/images/item-2.png', label: 'Reports', route: '/reports' },
            { image: '/static/images/item-3.png', label: 'Analytics', route: '/analytics' }
          ]
        },
        {
          title: 'Family Updates',
          background: '/static/images/section-3-bg.png',
          hasNestedCarousel: false
        }
      ]
    };
  },
  
  computed: {
    viewportStyles() {
      return {
        width: '10rem',
        height: `${this.viewportHeight}px`
      };
    },
    
    slideStyles() {
      return {
        width: '100%',
        height: `${this.viewportHeight}px`
      };
    }
  },
  
  mounted() {
    this.initializeDimensions();
    this.bindGlobalScrollHandler();
  },
  
  beforeUnmount() {
    this.unbindGlobalScrollHandler();
  },
  
  methods: {
    initializeDimensions() {
      this.viewportHeight = document.documentElement.clientHeight - this.footerHeight;
      this.nestedScrollStates = this.sections.map(() => 0);
    },
    
    bindGlobalScrollHandler() {
      window.addEventListener('scroll', this.handleWindowScroll, { passive: true });
    },
    
    unbindGlobalScrollHandler() {
      window.removeEventListener('scroll', this.handleWindowScroll);
    },
    
    handleWindowScroll() {
      this.showOverlay = false;
    },
    
    canScrollParent() {
      const currentSlide = this.$el.querySelectorAll('.slide')[this.currentSection];
      const nestedCarousel = currentSlide?.querySelector('.nested-carousel');
      
      if (!nestedCarousel) return true;
      
      const currentNestedState = this.nestedScrollStates[this.currentSection];
      return currentNestedState >= this.maxNestedScroll;
    },
    
    handleParentSwipeUp() {
      if (this.canScrollParent() && this.currentSection < this.sections.length - 1) {
        this.currentSection++;
        this.updateCarouselPosition();
        this.resetNestedScroll();
      }
    },
    
    handleParentSwipeDown() {
      if (this.currentSection > 0) {
        this.currentSection--;
        this.updateCarouselPosition();
        this.resetNestedScroll();
      }
    },
    
    updateCarouselPosition() {
      const offset = -(this.viewportHeight * this.currentSection);
      this.$refs.carousel.style.transform = `translateY(${offset}px)`;
    },
    
    resetNestedScroll() {
      const currentSlide = this.$el.querySelectorAll('.slide')[this.currentSection];
      const nestedCarousel = currentSlide?.querySelector('.nested-carousel ul');
      
      if (nestedCarousel) {
        nestedCarousel.style.transform = 'translateY(0)';
      }
      
      this.nestedScrollStates[this.currentSection] = 0;
    },
    
    handleNestedSwipeUp(sectionIndex) {
      const currentState = this.nestedScrollStates[sectionIndex];
      
      if (currentState < this.maxNestedScroll) {
        this.nestedScrollStates[sectionIndex]++;
        this.animateNestedScroll(sectionIndex, -350);
      } 
      
      if (currentState === this.maxNestedScroll - 1) {
        this.showOverlay = this.currentSection === this.sections.length - 1;
      }
    },
    
    handleNestedSwipeDown(sectionIndex) {
      const currentState = this.nestedScrollStates[sectionIndex];
      
      if (currentState > 0) {
        this.nestedScrollStates[sectionIndex]--;
        this.animateNestedScroll(sectionIndex, 350);
      }
      
      this.showOverlay = false;
    },
    
    animateNestedScroll(sectionIndex, delta) {
      const currentSlide = this.$el.querySelectorAll('.slide')[sectionIndex];
      const nestedCarousel = currentSlide?.querySelector('.nested-carousel ul');
      
      if (nestedCarousel) {
        const currentPosition = this.nestedScrollStates[sectionIndex] * delta;
        nestedCarousel.style.transform = `translateY(${currentPosition}px)`;
      }
    },
    
    navigateTo(route) {
      this.$router.push({ path: route });
    }
  }
};
</script>

<style scoped>
.container {
  overflow: hidden;
  height: 100vh;
}

.viewport {
  overflow: hidden;
  position: relative;
}

.carousel {
  width: 10rem;
  transition: transform 0.3s ease-out;
}

.slide {
  position: relative;
  width: 10rem;
}

.slide-content {
  position: relative;
  width: 100%;
}

.text-overlay {
  position: absolute;
  top: 30%;
  left: 0;
  right: 0;
  text-align: center;
  color: #fff;
  font-size: 1.5rem;
  line-height: 1.8;
}

.nested-carousel {
  position: absolute;
  top: 185px;
  left: 50%;
  transform: translateX(-50%);
  width: 355px;
  height: 350px;
  overflow: hidden;
  opacity: 0;
  z-index: 99;
  transition: opacity 0.3s ease;
}

.nested-carousel.is-visible {
  opacity: 1;
}

.nested-items {
  transition: transform 0.3s ease;
}

.nested-items li {
  width: 100%;
  height: 350px;
  position: relative;
}

.scroll-overlay {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  height: 50vh;
  z-index: 999;
  display: none;
}

.scroll-overlay.active {
  display: block;
}
</style>

Scroll State Management Logic

The core challenge involves determining which scroll layer should receive touch events. The system uses a state-based approach where each nested carousel maintains its own scroll position:

// scrollCoordinator.js
export class ScrollCoordinator {
  constructor(options = {}) {
    this.scrollThresholds = options.scrollThresholds || { up: 3, down: -1 };
    this.transitionTiming = options.transitionTiming || 300;
  }
  
  canPropagateToParent(nestedPosition, hasNestedContent, direction) {
    if (!hasNestedContent) return true;
    
    if (direction === 'up') {
      return nestedPosition < this.scrollThresholds.up;
    }
    
    return nestedPosition > this.scrollThresholds.down;
  }
  
  calculateNestedOffset(position, itemHeight = 350) {
    return position * itemHeight;
  }
  
  shouldShowOverlay(sectionIndex, totalSections, position) {
    return sectionIndex === totalSections - 1 && position >= this.scrollThresholds.up;
  }
}

Browser Compatibility Considerations

Native touch events behave differently across browsers, particularly in WeChat's embedded browser. Implementing a unified scroll detection strategy ensures consistent behavior:

// scrollPolyfill.js
export function initializeScrollDetection() {
  let lastScrollTop = 0;
  let scrollTimeout = null;
  
  const handleScroll = (event) => {
    const currentScroll = window.pageYOffset || document.documentElement.scrollTop;
    const scrollDirection = currentScroll > lastScrollTop ? 'down' : 'up';
    
    clearTimeout(scrollTimeout);
    scrollTimeout = setTimeout(() => {
      document.querySelectorAll('.scroll-overlay').forEach(overlay => {
        overlay.classList.remove('active');
      });
    }, 100);
    
    lastScrollTop = currentScroll <= 0 ? 0 : currentScroll;
  };
  
  if (typeof window !== 'undefined') {
    window.addEventListener('scroll', handleScroll, { passive: true });
    
    // WeChat browser specific: detect scroll within visual viewport
    window.addEventListener('scroll', (e) => {
      const visualViewport = window.visualViewport;
      if (visualViewport) {
        handleScroll(e);
      }
    }, { passive: true });
  }
  
  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}

Event Propagation Flow

The touch event flow follows a clear hierarchy:

  1. User initiates swipe gesture on nested carousel element
  2. Nested handler processes movement and calculates displacement
  3. System checks if nested scroll has reached boundaries
  4. If boundary reached and swipe continues, event propagates to parent handler
  5. Parent handler transitions to next/previous main section
  6. Nested carousel position resets when parent section changes

Key Implementation Details

Nested Content Detection: Each slide element is inspected for nested carousel presence using DOM queries. The presence of a specific class indicates nested scrollable content.

Position Synchronization: The translateY transform property controls both main carousel positioning and nested item scrolling, ensuring synchronized visual updates.

Overlay Management: A full-width overlay element positioned at the bottom acts as a scroll capture mechanism when users reach the final section and attempt to scroll further.

Memory Cleanup: Event listeners and reference timers must be cleared during component unmounting to prevent memory leaks and ensure proper garbage collection.

Related Articles

Understanding Strong and Weak References in Java

Strong References Strong reference are the most prevalent type of object referencing in Java. When an object has a strong reference pointing to it, the garbage collector will not reclaim its memory. F...

Comprehensive Guide to SSTI Explained with Payload Bypass Techniques

Introduction Server-Side Template Injection (SSTI) is a vulnerability in web applications where user input is improper handled within the template engine and executed on the server. This exploit can r...

Implement Image Upload Functionality for Django Integrated TinyMCE Editor

Django’s Admin panel is highly user-friendly, and pairing it with TinyMCE, an effective rich text editor, simplifies content management significantly. Combining the two is particular useful for bloggi...

Leave a Comment

Anonymous

◎Feel free to join the discussion and share your thoughts.