UniAppx安卓app实现左侧导航与右侧内容联动滚动

UniAppx安卓app实现左侧导航与右侧内容联动滚动

在移动端开发中,"左侧导航 + 右侧滚动内容"是电商类 APP 分类页面的经典交互模式。本文将基于实际项目代码,详细介绍如何在 UniAppx + uts + uvue 环境下实现这一功能。

功能概述

实现的核心功能包括:

  • 左侧导航菜单:垂直排列的菜单项,点击可切换到对应内容区域
  • 右侧滚动内容:可垂直滚动的内容区域,包含多个独立模块
  • 滚动联动:右侧内容滚动时自动高亮左侧对应菜单,点击左侧菜单时右侧内容平滑滚动

项目结构

复制代码
packageA/
└── stepperScroller/
    ├── index.uvue    # 主组件逻辑
    └── index.scss    # 样式文件

模板结构设计

模板采用 Flex 布局实现左右分栏。左侧是一个滚动视图容器,内部通过 v-for 循环渲染菜单项,每个菜单项绑定点击事件。右侧也是一个滚动视图容器,包含四个内容模块,每个模块有唯一的 id 用于定位。

关键属性说明:

  • scroll-top:绑定滚动位置,实现点击导航时的平滑滚动
  • scroll-with-animation:开启滚动动画
  • @scroll:监听滚动事件,实现导航联动

核心数据结构

组件使用 ref 和 reactive 管理状态,定义了两个 TypeScript 接口:StepperScrollerItem 和 offSetItem。其中 offSetHeight 数组是实现联动的核心数据,存储每个内容块相对于滚动容器顶部的距离。数据初始化时包含四个菜单项:首页、分类、购物车、我的。

滚动容器信息变量

三个关键变量用于计算滚动边界和判断滚动位置:

  • containerClientHeight:可视区域高度
  • containerScrollHeight:内容总高度
  • maxScrollTop:最大滚动距离

初始化计算偏移量

在 onMounted 钩子中,通过 nextTick 确保 DOM 渲染完成后再进行计算:

  1. 获取滚动容器的可视高度,使用 uni.getElementById 获取容器元素,通过 getBoundingClientRect 获取元素位置信息
  2. 遍历 offSetHeight 数组,计算每个内容块的偏移量 top,通过 rect.top 减去容器顶部位置得到相对偏移
  3. 累加每个内容块的高度,计算内容总高度
  4. 计算最大可滚动距离,即内容总高度减去可视区域高度,确保值不为负数

滚动监听与导航联动

handleScroll 函数处理滚动事件,根据滚动位置判断应该激活哪个菜单项:

  1. 获取当前滚动位置 scrollTop
  2. 检测是否滚动到底部,通过 scrollTop + 可视高度 >= 内容总高度来判断
  3. 如果滚动到底部,激活最后一个菜单项
  4. 否则从后往前遍历偏移量数组,找到第一个偏移量小于等于当前滚动位置的元素,将其索引设为当前激活项
  5. 只有当新索引与当前索引不同时才更新,避免不必要的更新

点击导航与内容滚动

switchNav 函数处理点击导航事件:

  1. 更新当前激活索引
  2. 通过 nextTick 确保 DOM 更新完成后再执行滚动
  3. 获取目标内容块的偏移量作为滚动目标位置
  4. 处理最后一项的边界情况:如果是最后一项且偏移量超过最大可滚动距离,则将目标位置设为最大滚动距离
  5. 更新 scrollTop 触发滚动

样式设计

使用 SCSS 嵌套语法实现清晰的布局结构:

  • container 使用 flex-direction: row 实现左右分栏
  • menu 固定宽度 24%,content 使用 flex: 1 占据剩余空间
  • 所有 flex 容器都显式声明 flex-direction,确保安卓兼容性
  • 内容区域包含四个不同高度和颜色的模块用于演示

安卓兼容性注意事项

在 UniApp 安卓端开发时,需要注意以下几点:

  1. flex-direction 必须显式声明:安卓端对 flex 布局的默认行为支持不一致,建议显式声明方向
  2. 避免使用 calc():部分安卓设备不支持 CSS calc() 函数,建议使用百分比或固定值
  3. getBoundingClientRect() 返回值:在安卓端可能需要类型断言

优化建议

1. 性能优化

滚动事件触发频率很高,建议添加节流优化,限制滚动事件处理的频率。

2. 动态内容支持

如果内容是动态加载的,需要在数据更新后重新计算偏移量,可通过 watch 监听数据变化。

3. 防抖处理

点击导航后,暂时禁用滚动监听,避免动画过程中触发导航切换,导致状态混乱。

总结

通过以上实现,我们完成了一个完整的"左侧导航 + 右侧内容联动滚动"功能。核心要点包括:

  1. 使用 getBoundingClientRect() 计算元素位置
  2. 维护偏移量数组实现滚动位置与导航的映射
  3. 处理边界情况确保滚动行为的正确性
  4. 使用 nextTick 确保 DOM 操作的时机正确

这种实现方式具有良好的兼容性和可扩展性,可以轻松应用于各种需要导航联动的场景。


完整代码

index.uvue

html 复制代码
<template>
	<view class="container">
		<scroll-view class="menu">
			<view @click="switchNav(index)" class="menu-item" v-for="(item, index) in stepperScrollerData" :key="index">
				<text :class="current === index ? 'active_text' : 'normal_text'">{{ item.title }}</text>
			</view>
		</scroll-view>
		<scroll-view id="content1" :scroll-top="data.scrollTop" class="content" direction="vertical" :scroll-with-animation="true" @scroll="handleScroll">
			<view id="demo1" class="demo1">第一段--内容{{stepperScrollerData[0].title}}</view>
			<view id="demo2" class="demo2">第二段--内容{{stepperScrollerData[1].title}}</view>
			<view id="demo3" class="demo3">第三段--内容{{stepperScrollerData[2].title}}</view>
			<view id="demo4" class="demo4">第四段--内容{{stepperScrollerData[3].title}}</view>
		</scroll-view>
	</view>
</template>

<script setup lang="uts">
	import { ref, onMounted,reactive,nextTick } from 'vue';

	type StepperScrollerItem = {
		title : string;
	}

	type offSetItem = {
		top : number;
		id : string;
	}

	const current = ref(0);
	const data = reactive({
		scrollTop: 0,
		currentScrollTop: 0,
	});
	const offSetHeight = ref<Array<offSetItem>>([
		{ top: 0, id: 'demo1' },
		{ top: 0, id: 'demo2' },
		{ top: 0, id: 'demo3' },
		{ top: 0, id: 'demo4' }
	]);

	const stepperScrollerData = ref<StepperScrollerItem[]>([
		{ title: '首页' },
		{ title: '分类' },
		{ title: '购物车' },
		{ title: '我的' }
	]);

	let containerClientHeight = 0;
	let containerScrollHeight = 0;
	let maxScrollTop = 0;

	const handleScroll = (e: UniScrollEvent) => {
		const scrollTop = e.detail.scrollTop as number;
		data.currentScrollTop = scrollTop;

		let newIndex = 0;

		const isAtBottom = scrollTop + containerClientHeight >= containerScrollHeight - 1;
		if (isAtBottom && offSetHeight.value.length > 0) {
			newIndex = offSetHeight.value.length - 1;
		} else {
			for (let i = offSetHeight.value.length - 1; i >= 0; i--) {
				if (scrollTop >= offSetHeight.value[i].top) {
					newIndex = i;
					break;
				}
			}
		}

		if (newIndex !== current.value) {
			current.value = newIndex;
		}
	};

	const switchNav = (index: number) => {
		current.value = index;
		nextTick(() => {
			let targetTop = offSetHeight.value[index].top;
			if (index === offSetHeight.value.length - 1 && targetTop > maxScrollTop) {
				targetTop = maxScrollTop;
			}
			data.scrollTop = targetTop;
		});
	};

	onMounted(() => {
		nextTick(() => {
			const content = uni.getElementById('content1');
			let containerTop = 0;
			let containerHeight = 0;
			if (content != null) {
				const rect = content.getBoundingClientRect();
				containerTop = rect.top;
				containerClientHeight = rect.height;
			}

			offSetHeight.value.forEach((item, index) => {
				const element = uni.getElementById(item.id);
				if (element != null) {
					const rect = element.getBoundingClientRect();
					offSetHeight.value[index].top = rect.top - (containerTop as number) + (data.currentScrollTop as number);
				}
			});

			let totalHeight = 0;
			offSetHeight.value.forEach((item, idx) => {
				const element = uni.getElementById(item.id);
				if (element != null) {
					const rect = element.getBoundingClientRect();
					totalHeight += rect.height;
				}
			});
			containerScrollHeight = totalHeight;
			
			maxScrollTop = containerScrollHeight - containerClientHeight;
			if (maxScrollTop < 0) maxScrollTop = 0;
			
			console.log('可视高度', containerClientHeight);
			console.log('内容总高度', containerScrollHeight);
			console.log('最大滚动距离', maxScrollTop);
			console.log('偏移量数组', offSetHeight.value);
		});
	});
</script>

<style scoped lang="scss" src="./index.scss">
</style>

index.scss

scss 复制代码
.container {
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    background-color: #f5f5f5;
    box-sizing: border-box;
    padding: 8px;

    .menu {
        width: 24%;
        height: 100%;
        margin-right: 16px;
        background-color: #fff;

        .menu-item {
            padding: 16px 0;
            display: flex;
            flex-direction: row;
            align-items: center;
            justify-content: center;
            color: red;

            .active_text {
                color: red;
            }

            .normal_text {
                color: #000;
            }
        }

    }

    .content {
        flex: 1;
        height: 100%;
        background-color: #fff;

        .demo1 {
            height: 300px;
            background-color: yellow;
            width: 100%;
        }

        .demo2 {
            height: 600px;
            background-color: red;
            width: 100%;
        }

        .demo3 {
            height: 400px;
            background-color: green;
            width: 100%;
        }

        .demo4 {
            height: 500px;
            background-color: blue;
            width: 100%;
        }
    }
}
相关推荐
plainGeekDev4 小时前
ProGuard → R8
android·java·kotlin
雨白4 小时前
C 语言字符串:从字符数组、指针到核心操作实战
android
Java小白笔记5 小时前
MySQL中存储过程大表分批删除历史数据
android·mysql·adb
aidou13145 小时前
Kotlin中沉浸式状态栏显示布局
android·kotlin·windowmanager·沉浸式状态栏·insetslistener·insetscompat·systembars
我命由我123456 小时前
Android 构建项目问题:XML 片段出错,com.ctc.wstx.exc.WstxUnexpectedCharException
android·xml·android studio·android jetpack·android-studio·android runtime
阿pin6 小时前
Android随笔-pipe是什么?
android·linux·pipe
帅次6 小时前
Android 高级工程师面试:Flow 与状态流 近1年高频追问 22 题
android·面试·职场和发展
雨季余静6 小时前
RustDesk Android APK 从零编译全步骤
android
辰合软件6 小时前
通达OA配置文件详解
android·adb
辰合软件7 小时前
通达OA目录结构与核心文件解析
android·数据库·oracle