Implementing Hierarchical Slide Navigation in Vue with Touch Event Coordination
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:
- Child-level scrolling - Individual slide items within a parent section
- Parent-level scrolling - Full-page transitions between major sections
- 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:
- User initiates swipe gesture on nested carousel element
- Nested handler processes movement and calculates displacement
- System checks if nested scroll has reached boundaries
- If boundary reached and swipe continues, event propagates to parent handler
- Parent handler transitions to next/previous main section
- 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.