uni-app x跨平台开发实战:开发鸿蒙HarmonyOS滚动卡片组件,scroll-view无法滚动踩坑全记录
在玩中学,直接上手实战是猫哥一贯的自学方法心得。假期期间实在无聊!我不睡懒觉、不看电影、也不刷手机、不玩游戏、也无处可去。那么我干嘛嘞?闲的都想看蚂蚁上树,无聊透顶,百无聊赖,感觉假期好没意思啊。做什么呢? 于是翻出来之前做过的“爱影家”影视app项目,找个跨多端的技术栈再玩一把。本节实战是scroll-view无法滚动踩坑全记录。
本节实战是scroll-view无法滚动踩坑全记录。本以为scroll-view用法挺简单的,参考官方文档用呗。结果想实现个HarmonyOS滚动卡片组件,起初死活无法横向滚动。最终找到了原因和解决办法,特此总结分享下,有用到的小伙伴可以点击收藏。
该免费观影APP,使用uni-app x框架开发跨六端的免费观影app。
项目开源地址:https://gitcode.com/qq8864/uniappx_imovie
下图是实现的热门影视和即将上映等横向滚动卡片组件运行效果。

项目背景
本文基于爱影家(imovie)项目的 uni-app x框架重构实现,介绍在 uni-app x 中封装横向滚动卡片组件时遇到的核心问题及解决办法。(爱影家(imovie)项目我都做烂了,有uniapp版,鸿蒙原生版,还有这里的 uni-app x框架重构实现版本。)
本项目用到的后台影视和音乐接口文档:https://blog.ZEEKLOG.net/qq8864/article/details/154404554
本项目首页包含两类横向滚动组件:
movie-section:通用影片横向列表,展示封面、片名、评分,复用于"正在热映"、"即将上映"等多个区块box-office:院线票房日榜,每张卡片近全屏宽,可左右翻页浏览
开发过程中,box-office 组件一开始就能正常横向滑动,而后来封装的 movie-section 照猫画虎写完后,scroll-view 设置了 direction="horizontal",却死活无法横向滚动。对比两段代码的差异,揭示了 uni-app x 原生布局引擎与 Web CSS 之间一个非常关键的行为差异。
uni-app x 是 DCloud 推出的新一代跨平台开发框架,支持将代码编译为多个平台的原生代码:
- Android 平台:编译为 Kotlin
- iOS 平台:编译为 Swift
- 鸿蒙 Next 平台:编译为 ArkTS
- Web 和小程序平台:编译为 JS
核心问题:原生引擎不认 flex 溢出
在 Web 浏览器里,横向滚动的惯用写法是:
.scroll-container{overflow-x: auto;white-space: nowrap;/* 或 display:flex + flex-wrap:nowrap */}子元素内容超出容器宽度,浏览器就会自动产生横向滚动,这是 Web CSS 规范中溢出即可滚动的逻辑。
但 uni-app x 在 App 端并不跑在浏览器里。底层是原生布局引擎(Android 上类似 Flexbox 但行为有差异),其核心差异在于:
scroll-view内的行容器如果没有一个明确的、超出scroll-view宽度的像素尺寸,原生引擎就认为内容没有溢出,不会开启横向滚动。
仅靠 flex-direction: row + flex-wrap: nowrap 并不足以让原生引擎识别内容可横向滚动。这是从 Web 迁移到 uni-app x 最容易踩的一个坑。
下面是有问题的页面写法:
<template><view><!-- 标题栏 --><viewclass="section-header"><viewclass="section-title-wrap"><viewclass="section-title-bar"></view><textclass="section-title">{{ title }}</text></view><textclass="section-more"@click="goMore">更多 ></text></view><!-- 横向滚动卡片,flex-shrink:0 是横向滚动生效的关键 --><scroll-viewclass="movie-scroll"scroll-x><viewclass="movie-row"><viewclass="movie-card"v-for="(movie, index) in movies":key="movie.id"@click="goDetail(movie.id)"><imageclass="movie-cover":src="movie.cover"mode="aspectFill"/><viewclass="movie-card-info"><textclass="movie-title">{{ movie.title }}</text><text:class="movie.rate > 0 ? 'movie-rate' : 'movie-rate-none'"> {{ formatRate(movie.rate) }} </text></view></view><!-- 右侧留白,防止最后一张紧贴边缘 --><viewstyle="width: 10px;flex-shrink: 0;"></view></view></scroll-view></view></template><scriptsetuplang="uts">import{ MovieItem }from'@/api/movie'const props = defineProps<{title: string type: string movies: Array<MovieItem>}>()const formatRate =(rate : number):string=>{return rate >0?'★ '+ rate.toFixed(1):'暂无'}constgoMore=()=>{ uni.navigateTo({url:`/pages/movie/movie-list?type=${props.type}`})}constgoDetail=(id: string)=>{ uni.navigateTo({url:`/pages/movie/detail?id=${id}`})}</script><style>.section-header{flex-direction: row;justify-content: space-between;align-items: center;padding: 18px 10px 10px 10px;}.section-title-wrap{flex-direction: row;align-items: center;}.section-title-bar{width: 4px;height: 16px;background-color: #e67e22;border-radius: 2px;margin-right: 8px;}.section-title{font-size: 16px;font-weight: bold;color: #ffffff;}.section-more{font-size: 13px;color: #f5c518;}.movie-scroll{width: 100%;height: 400rpx;}.movie-row{flex-direction: row;padding-left: 10px;}.movie-card{width: 220rpx;margin-right: 10px;background-color: #1c1c2e;border-radius: 8px;overflow: hidden;flex-shrink: 0;}.movie-cover{width: 220rpx;height: 300rpx;}.movie-card-info{padding: 6px 8px 8px 8px;}.movie-title{font-size: 12px;color: #e8e8e8;}.movie-rate{font-size: 12px;color: #f5c518;margin-top: 3px;}.movie-rate-none{font-size: 12px;color:rgba(255, 255, 255, 0.35);margin-top: 3px;}</style>先不往下看,上述代码界面代码实现有问题吗?能看出scroll-view为何不能横向滚动吗?如果你一眼就看到了问题,那后面的就不用看了。如果没看出来,可往后看下猫哥分享的scroll-view不能横向滚动的踩坑记录。
uniapp-x的scroll-view容器组件相关文档介绍地址:https://doc.dcloud.net.cn/uni-app-x/component/scroll-view.html
票房榜组件(box-office)的实现
box-office 组件展示院线票房日榜,每张卡片占接近全屏宽度,适合左右翻页浏览。其正确工作的关键在于:用 JS 算出行容器的精确像素宽度,通过内联样式绑定给容器。
核心思路
- 卡片宽度用像素(px)计算,基于
uni.getWindowInfo().windowWidth得到屏幕宽度 - 行容器总宽度 = 左侧 padding + (卡片宽 + 间距) × 数量 + 右侧留白,通过
computed响应数据变化 - 卡片设置
flex-shrink: 0,防止被压缩
完整代码
<template><viewclass="box-office"><!-- 标题栏 --><viewclass="section-header"><viewclass="header-left"><viewclass="title-bar"></view><textclass="title">院线票房日榜</text></view><textv-if="day.length > 0"class="day-text">{{ day }}</text></view><!-- 卡片横向滚动 --><!-- 关键:cards-row 必须绑定精确的像素总宽度,原生引擎才能识别横向可滚动内容 --><scroll-viewv-if="!loading && list.length > 0"class="cards-scroll"direction="horizontal"><viewclass="cards-row":style="`width: ${rowWidth}px;`"><viewv-for="item in list":key="item.top"class="movie-card":style="`width: ${cardWidth}px;`"><!-- 顶部彩色条 --><viewclass="rank-accent":style="`background-color: ${getRankColor(item.top)};`"></view><viewclass="card-body"><!-- 排名 + 片名 --><viewclass="card-head"><viewclass="rank-badge":style="`background-color: ${getRankColor(item.top)};`"><textclass="rank-num":style="`color: ${item.top <= 3 ? '#1a1a2e' : '#ffffff'};`"> {{ item.top }} </text></view><viewclass="name-block"><textclass="movie-name":numberOfLines="1">{{ item.name }}</text><textclass="release-text">{{ item.release_date }}</text></view></view><!-- 分割线 --><viewclass="divider"></view><!-- 四项指标 2×2 排列 --><viewclass="metrics"><viewclass="metric"><textclass="metric-val">{{ item.box_million }}</text><textclass="metric-label">今日票房</text></view><viewclass="metric"><textclass="metric-val metric-highlight">{{ item.share_box }}</text><textclass="metric-label">票房占比</text></view><viewclass="metric metric-bottom"><textclass="metric-val">{{ item.row_films }}</text><textclass="metric-label">排 片 率</text></view><viewclass="metric metric-bottom"><textclass="metric-val">{{ item.row_seats }}</text><textclass="metric-label">上 座 率</text></view></view></view></view><!-- 右侧留白 --><viewstyle="width: 14px;flex-shrink: 0;"></view></view></scroll-view><!-- 加载中 --><viewv-if="loading"class="placeholder"><textclass="placeholder-text">加载中...</text></view></view></template><scriptsetuplang="uts">import{ ref, computed, onMounted }from'vue'import{ MovieApi, PiaoItem }from'@/api/movie'const list = ref<PiaoItem[]>([])const day = ref<string>('')const loading = ref<boolean>(true)// 卡片宽度:屏幕宽度减去两侧各 14px padding,单张接近全屏const cardWidth = Math.floor(uni.getWindowInfo().windowWidth -28)// 行总宽度:原生布局引擎需要明确的宽度才能识别横向可滚动内容// 14(padding-left) + (cardWidth + 12margin) × n + 14(右侧留白)const rowWidth =computed(():number=>{return14+(cardWidth +12)* list.value.length +14})// 按排名返回强调色:金 / 银 / 铜 / 深蓝const getRankColor =(top : number):string=>{if(top ===1)return'#f5c518'if(top ===2)return'#9eb3c2'if(top ===3)return'#e67e22'return'#3a5085'}onMounted(()=>{ MovieApi.getPiaomovie().then((result: any)=>{const raw = result as{list: PiaoItem[],day: string } list.value = raw.list.filter((item : PiaoItem):boolean=> item.name.length >0) day.value = raw.day loading.value =false}).catch((_: any)=>{ loading.value =false})})</script><style>.box-office{margin-top: 4px;}.section-header{flex-direction: row;justify-content: space-between;align-items: center;padding: 18px 12px 10px 12px;}.header-left{flex-direction: row;align-items: center;}.title-bar{width: 4px;height: 16px;background-color: #e67e22;border-radius: 2px;margin-right: 8px;}.title{font-size: 16px;font-weight: bold;color: #ffffff;}.day-text{font-size: 11px;color:rgba(255, 255, 255, 0.35);}/* scroll-view 必须有固定高度 */.cards-scroll{width: 100%;height: 160px;}.cards-row{flex-direction: row;flex-wrap: nowrap;padding-left: 14px;}/* flex-shrink: 0 防止卡片被压缩 */.movie-card{margin-right: 12px;background-color: #16213e;border-radius: 12px;overflow: hidden;flex-shrink: 0;}.rank-accent{width: 100%;height: 2px;}.card-body{padding: 14px;}.card-head{flex-direction: row;align-items: flex-start;margin-bottom: 10px;}.rank-badge{width: 34px;height: 34px;border-radius: 17px;align-items: center;justify-content: center;flex-shrink: 0;margin-right: 12px;margin-top: 2px;}.rank-num{font-size: 16px;font-weight: bold;}.name-block{flex: 1;}.movie-name{font-size: 17px;font-weight: bold;color: #ffffff;margin-bottom: 5px;}.release-text{font-size: 11px;color:rgba(255, 255, 255, 0.45);}.divider{height: 1px;background-color:rgba(255, 255, 255, 0.08);margin-bottom: 12px;}.metrics{flex-direction: row;flex-wrap: wrap;}.metric{width: 50%;margin-bottom: 4px;}.metric-bottom{margin-bottom: 0;}.metric-val{font-size: 15px;font-weight: bold;color: #e8e8e8;margin-bottom: 2px;}.metric-highlight{color: #f5c518;}.metric-label{font-size: 10px;color:rgba(255, 255, 255, 0.35);letter-spacing: 1px;}.placeholder{height: 100px;align-items: center;justify-content: center;}.placeholder-text{font-size: 13px;color:rgba(255, 255, 255, 0.35);}</style>电影卡片组件(movie-section)的实现
movie-section 是首页各区块(正在热映、即将上映等)的通用横向卡片列表,通过 props 接收标题、分类、影片数组,实现复用。
最初的问题写法
<!-- ❌ 问题写法:.movie-row 没有明确宽度,.movie-card 没有 flex-shrink: 0 --> <scroll-view direction="horizontal"> <view> <view v-for="(movie) in movies" ...> ... </view> </view> </scroll-view> .movie-row{flex-direction: row;flex-wrap: nowrap;/* ❌ 缺少明确宽度,原生引擎不知道内容有多宽 */}.movie-card{width: 220rpx;/* ❌ 缺少 flex-shrink: 0,卡片会被压缩进容器 */}两处关键点
修复1:计算行容器的精确像素宽度
卡片 CSS 中写的是 220rpx,但 JS 计算必须换算成 px:
1rpx = windowWidth(px) ÷ 750 220rpx = 220 × windowWidth ÷ 750 (px) // 将 220rpx 转换为 pxconst cardWidthPx = Math.floor(220* uni.getWindowInfo().windowWidth /750)// 行总宽度 = 10(padding-left) + (卡片宽px + 10间距px) × 数量 + 10(右侧留白px)const rowWidth =computed(():number=>{return10+(cardWidthPx +10)* props.movies.length +10})修复2:给卡片加 flex-shrink: 0
.movie-card{width: 220rpx;flex-shrink: 0;/* 防止卡片被 flex 容器压缩 */}完整代码
<template><view><!-- 标题栏 --><viewclass="section-header"><viewclass="section-title-wrap"><viewclass="section-title-bar"></view><textclass="section-title">{{ title }}</text></view><textclass="section-more"@click="goMore">更多 ></text></view><!-- 横向滚动卡片:movie-row 必须设置明确的像素宽度,原生引擎才能识别可滚动区域 --><scroll-viewclass="movie-scroll"direction="horizontal"><viewclass="movie-row":style="`width: ${rowWidth}px;`"><viewclass="movie-card"v-for="(movie) in movies":key="movie.id"@click="goDetail(movie.id)"><imageclass="movie-cover":src="movie.cover"mode="aspectFill"/><viewclass="movie-card-info"><textclass="movie-title">{{ movie.title }}</text><text:class="movie.rate > 0 ? 'movie-rate' : 'movie-rate-none'"> {{ formatRate(movie.rate) }} </text></view></view><!-- 右侧留白,防止最后一张紧贴边缘 --><viewstyle="width: 10px;flex-shrink: 0;"></view></view></scroll-view></view></template><scriptsetuplang="uts">import{ computed }from'vue'import{ MovieItem }from'@/api/movie'const props = defineProps<{title: string type: string movies: Array<MovieItem>}>()// 将 220rpx 转换为 px(原生引擎需要明确的像素宽度)const cardWidthPx = Math.floor(220* uni.getWindowInfo().windowWidth /750)// 10(padding-left) + (cardWidth + 10margin) × n + 10(右侧留白)const rowWidth =computed(():number=>{return10+(cardWidthPx +10)* props.movies.length +10})const formatRate =(rate : number):string=>{return rate >0?'★ '+ rate.toFixed(1):'暂无'}constgoMore=()=>{ uni.navigateTo({url:`/pages/movie/movie-list?type=${props.type}`})}constgoDetail=(id: string)=>{ uni.navigateTo({url:`/pages/movie/detail?id=${id}`})}</script><style>.section-header{flex-direction: row;justify-content: space-between;align-items: center;padding: 18px 10px 10px 10px;}.section-title-wrap{flex-direction: row;align-items: center;}.section-title-bar{width: 4px;height: 16px;background-color: #e67e22;border-radius: 2px;margin-right: 8px;}.section-title{font-size: 16px;font-weight: bold;color: #ffffff;}.section-more{font-size: 13px;color: #f5c518;}/* scroll-view 必须有固定高度 */.movie-scroll{width: 100%;height: 380rpx;}.movie-row{flex-direction: row;flex-wrap: nowrap;padding-left: 10px;}.movie-card{width: 220rpx;margin-right: 10px;background-color: #1c1c2e;border-radius: 8px;overflow: hidden;flex-shrink: 0;/* 关键:防止卡片被 flex 容器压缩 */}.movie-cover{width: 220rpx;height: 300rpx;}.movie-card-info{padding: 6px 8px 8px 8px;}.movie-title{font-size: 12px;color: #e8e8e8;}.movie-rate{font-size: 12px;color: #f5c518;margin-top: 3px;}.movie-rate-none{font-size: 12px;color:rgba(255, 255, 255, 0.35);margin-top: 3px;}</style>踩坑注意事项
坑1:内容行容器必须有明确的像素总宽度(最核心)
这是横向滚动生效的必要条件。
| 平台 | 触发横向滚动的条件 |
|---|---|
| Web 浏览器 | 子元素自然溢出即可,overflow-x: auto 自动处理 |
| uni-app x 原生端 | 必须为行容器声明超出 scroll-view 宽度的明确像素尺寸 |
<!-- ❌ 错误:没有明确宽度,无法滚动 --><viewclass="row">...</view><!-- ✅ 正确:绑定精确的像素总宽度 --><viewclass="row":style="`width: ${rowWidth}px;`">...</view>rowWidth 的计算公式:
rowWidth = 左padding + (单卡宽px + 卡间距px) × 卡片数量 + 右留白px 坑2:卡片必须设置 flex-shrink: 0
原生 Flexbox 默认 flex-shrink: 1。当父容器(scroll-view)宽度固定时,如果不禁止收缩,卡片就会被压缩进容器,而不是形成可滚动的溢出内容。
/* ✅ 必须加,否则卡片实际渲染宽度会失效 */.card{flex-shrink: 0;}坑3:rpx 不能直接用于 JS 计算行宽
CSS 中的 rpx 是由渲染引擎在绘制时转换的响应式单位,JS 拿不到这个值。凡是涉及到计算行容器总宽度的地方,必须自己换算:
// ✅ 正确:手动换算 rpx → pxconst cardWidthPx = Math.floor(220* uni.getWindowInfo().windowWidth /750)// ❌ 错误:220 在 JS 里只是卡片的 rpx 数值,直接相乘结果偏差很大// const rowWidth = 220 * count // 这是 rpx 数值,不是 px坑4:scroll-view 本身必须有固定高度
如果 scroll-view 没有设置固定高度,在某些平台上会塌陷为 0,内容不可见。
/* ✅ 高度用 rpx 或 px 均可,但必须明确 */.movie-scroll{width: 100%;height: 380rpx;}坑5:行容器的 padding 必须计入总宽度
padding-left 占用空间,如果计算行宽时忽略它,最后一张卡片会被截断显示不完整。
// ✅ 正确:padding-left 和右侧留白都算进去const rowWidth =10/*padding-left*/+(cardWidthPx +10)* count +10/*trailing*/// ❌ 错误:漏算 padding,最后一张卡片右侧会被裁剪const rowWidth =(cardWidthPx +10)* count 横向滚动组件封装三要素
基于以上踩坑经验,总结出一套在 uni-app x 中封装横向滚动组件必须满足的三要素:
1. scroll-view 有固定高度 ↓ 2. 内容行容器绑定精确的像素总宽度(:style="width: Xpx") ↓ 3. 每张卡片设置 flex-shrink: 0 三者缺一不可。对应到代码模板:
<template><!-- 要素1:scroll-view 有固定高度 --><scroll-viewstyle="width: 100%;height: 200px;"direction="horizontal"><!-- 要素2:行容器绑定精确像素宽度 --><view:style="`width: ${rowWidth}px; flex-direction: row;`"><viewv-for="item in list":key="item.id":style="`width: ${cardWidthPx}px; margin-right: ${gap}px; flex-shrink: 0;`"><!-- 卡片内容 --></view><view:style="`width: ${trailingSpace}px; flex-shrink: 0;`"></view></view></scroll-view></template><scriptsetuplang="uts">import{ computed }from'vue'// 所有尺寸统一换算为 pxconst windowWidth = uni.getWindowInfo().windowWidth const paddingLeft =10const gap =10const trailingSpace =10// 设计稿给的 rpx 值 → px:rpx值 × windowWidth / 750const cardWidthPx = Math.floor(220* windowWidth /750)// 要素2 的宽度计算const rowWidth =computed(():number=>{return paddingLeft +(cardWidthPx + gap)* props.list.length + trailingSpace })</script>Web 与 uni-app x 原生端的行为对比
| 维度 | Web 浏览器 | uni-app x 原生端 |
|---|---|---|
| 横向滚动触发条件 | 子元素溢出即可,浏览器自动处理 | 必须为行容器声明超出容器的明确像素宽度 |
| flex 溢出识别 | 自动识别 | 不自动识别,需显式声明宽度 |
| rpx 单位 | 不支持 | CSS 中支持;JS 中需手动换算为 px |
| flex-shrink 默认值 | 1(会压缩子元素) | 1(行为同 Web,同样会压缩) |
| overflow 属性 | 支持,控制溢出显示 | uni-app x 中 overflow 行为与 Web 有差异,横向滚动不依赖此属性 |
总结
uni-app x 编译为原生代码后,布局引擎与 Web 浏览器的行为存在差异。实现横向滚动卡片时,踩坑记录,必须牢记:
- 行容器要有明确的像素宽度——这是原生引擎识别横向可滚动内容的前提。
- 卡片要禁止收缩——
flex-shrink: 0让卡片保持固定宽度形成溢出 - rpx 转 px 要手动换算——JS 层的计算全部使用 px,rpx 只用于 CSS 样式
掌握这三条,横向滚动组件在 Android、iOS、鸿蒙各端均可正常工作。