La implementación de un encabezado que se mantiene fijo (sticky header) combinado con navegación por anclas y sincronización automática de tabs según la posición del scroll es un patrón muy común en aplicaciones móviles H5 construidas con Vue.js. A continuación se presenta una solución completa que reescribe la lógica utilizando una estructura diferente.
Componente Vue
<template>
<div class="exam-container">
<div
ref="stickyBar"
class="exam-toolbar"
:class="{ 'sticky-active': stickyEnabled }"
>
<div class="toolbar-top">
<i class="icon-back" @click="goBack"><</i>
<span class="toolbar-title">Exploración médica</span>
<input
v-model="searchTerm"
class="toolbar-search"
placeholder="Buscar elemento"
/>
</div>
<nav class="tab-bar">
<div
v-for="(tab, idx) in tabList"
:key="tab.id"
class="tab-item"
@click="jumpToSection(idx)"
>
<span :class="{ 'tab-selected': activeTab === idx }">
{{ tab.label }}
</span>
<div v-if="activeTab === idx" class="tab-indicator"></div>
</div>
</nav>
</div>
<div class="section-wrapper">
<div
v-for="(block, idx) in sectionData"
:key="block.id"
:ref="el => sectionRefs[idx] = el"
class="content-block"
>
<div class="block-header">
<div class="header-accent"></div>
<h3 class="header-text">{{ block.label }}</h3>
&div>
<div class="block-body">
<div
v-for="entry in block.entries"
:key="entry.id"
class="entry-card"
>
<p class="entry-label">{{ entry.title }}</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { fetchExamData } from "@/services/examApi";
export default {
name: "StickyAnchorView",
data() {
return {
searchTerm: "",
tabList: [],
sectionData: [],
activeTab: 0,
stickyEnabled: false,
stickyThreshold: 0,
sectionRefs: [],
scrollHandler: null
};
},
async created() {
await this.loadSections();
},
mounted() {
this.scrollHandler = this.handleScroll.bind(this);
window.addEventListener("scroll", this.scrollHandler, { passive: true });
this.$nextTick(() => {
this.stickyThreshold = this.$refs.stickyBar.offsetTop;
});
},
beforeDestroy() {
window.removeEventListener("scroll", this.scrollHandler);
},
methods: {
goBack() {
this.$router.go(-1);
},
jumpToSection(idx) {
this.activeTab = idx;
const target = this.sectionRefs[idx];
if (!target) return;
const offset = target.offsetTop - 130;
window.scrollTo({ top: offset, behavior: "smooth" });
},
handleScroll() {
const scrollY =
window.pageYOffset ||
document.documentElement.scrollTop ||
document.body.scrollTop;
this.stickyEnabled = scrollY > this.stickyThreshold;
const blocks = this.sectionRefs;
for (let i = blocks.length - 1; i >= 0; i--) {
const relativeOffset = blocks[i].offsetTop - blocks[0].offsetTop - 130;
if (scrollY >= relativeOffset) {
this.activeTab = i;
break;
}
}
},
async loadSections() {
try {
const response = await fetchExamData();
const info = response.data.data.items;
this.tabList = info.map(item => ({
id: item.id,
label: item.name
}));
this.sectionData = info;
} catch (error) {
console.error("Error loading sections:", error);
}
}
}
};
</script>
Estilos
.exam-container {
width: 100%;
}
.exam-toolbar {
width: 100%;
background: #ffffff;
border-bottom: 1px solid #f0f0f0;
.toolbar-top {
display: flex;
align-items: center;
padding-top: 30px;
gap: 8px;
.icon-back {
color: #000;
font-size: 22px;
font-weight: bold;
cursor: pointer;
}
.toolbar-title {
font-size: 18px;
font-weight: bold;
color: #000;
}
.toolbar-search {
flex: 1;
max-width: 60%;
border-radius: 21px;
background: #f9f9f9;
border: 1px solid #eee;
padding: 6px 12px;
}
}
.tab-bar {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
overflow-y: hidden;
margin-top: 5px;
.tab-item {
margin-left: 20px;
line-height: 40px;
text-align: center;
white-space: nowrap;
font-size: 16px;
cursor: pointer;
&:last-child {
margin-right: 20px;
}
span {
text-decoration: none;
color: #a8a8a8;
}
.tab-selected {
color: #00a0e9;
}
.tab-indicator {
width: 20px;
height: 2px;
background: #00a0e9;
margin: 0 auto;
border-radius: 20px;
}
}
}
}
.section-wrapper {
background: #f9f9f9;
padding: 10px 0 30px;
.content-block {
width: 90%;
margin: 10px auto;
background: #fff;
.block-header {
display: flex;
align-items: center;
padding: 10px;
.header-accent {
height: 16px;
width: 3px;
border-radius: 10px;
background: #00a0e9;
}
.header-text {
font-weight: bold;
color: #000;
margin-left: 5px;
font-size: 15px;
}
}
.block-body {
display: flex;
flex-wrap: wrap;
.entry-card {
width: 30%;
background: #f9f9f9;
text-align: center;
margin: 10px 5px;
.entry-label {
font-size: 14px;
font-weight: 400;
word-break: break-word;
}
}
}
}
}
.sticky-active {
position: fixed;
top: 0;
left: 0;
z-index: 999;
}
Explicación de la lógica principal
El componente gestiona tres comportamientos interrelacionados:
- Encabezado fijo (sticky): Durante el evento
mounted, se captura eloffsetTopdel elemento de referencia. En cada evento de scroll, se compara la posición actual del scroll con ese umbral. Si lo supera, se aplica la clasesticky-activeque convierte el encabezado enposition: fixed. - Navegación por anclas: Al hacer clic en un tab, el método
jumpToSectionlocaliza la sección correspondiente utilizando referencias dinámicas (sectionRefs) en lugar dequerySelector, lo que resulta más robusto. Se utilizawindow.scrollToconbehavior: 'smooth'para una transición fluida. - Activación automática de tabs: El método
handleScrollrecorre las secciones en orden inverso. Cuando detecta que la posición del scroll supera el umbral relativo de una sección, actualizaactiveTaby detiene el bucle.
Consideraciones importantes
El listener de scroll se registra con { passive: true } para mejorar el rendimiento en dispositivos móviles. Es obligatorio eliminar el listener en beforeDestroy para evitar fugas de memoria al navegar entre rutas. El desplazamiento aplica un offset fijo de 130 píxeles para compensar la altura del encabezado fijo y evitar que el contenido quede oculto detrás del mismo.