在玩中学,直接上手实战是猫哥一贯的自学方法心得。假期期间实在无聊!我不睡懒觉、不看电影、也不刷手机、不玩游戏、也无处可去。那么我干嘛嘞?闲的都想看蚂蚁上树,无聊透顶,百无聊赖,感觉假期好没意思啊。做什么呢? 于是翻出来之前做过的"爱影家"影视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.csdn.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 浏览器里,横向滚动的惯用写法是:
css
.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 最容易踩的一个坑。
下面是有问题的页面写法:
html
<template>
<view>
<!-- 标题栏 -->
<view class="section-header">
<view class="section-title-wrap">
<view class="section-title-bar"></view>
<text class="section-title">{{ title }}</text>
</view>
<text class="section-more" @click="goMore">更多 ></text>
</view>
<!-- 横向滚动卡片,flex-shrink:0 是横向滚动生效的关键 -->
<scroll-view class="movie-scroll" scroll-x>
<view class="movie-row">
<view class="movie-card" v-for="(movie, index) in movies" :key="movie.id" @click="goDetail(movie.id)">
<image class="movie-cover" :src="movie.cover" mode="aspectFill" />
<view class="movie-card-info">
<text class="movie-title">{{ movie.title }}</text>
<text :class="movie.rate > 0 ? 'movie-rate' : 'movie-rate-none'">
{{ formatRate(movie.rate) }}
</text>
</view>
</view>
<!-- 右侧留白,防止最后一张紧贴边缘 -->
<view style="width: 10px; flex-shrink: 0;"></view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="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) : '暂无'
}
const goMore = () => {
uni.navigateTo({ url: `/pages/movie/movie-list?type=${props.type}` })
}
const goDetail = (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,防止被压缩
完整代码
html
<template>
<view class="box-office">
<!-- 标题栏 -->
<view class="section-header">
<view class="header-left">
<view class="title-bar"></view>
<text class="title">院线票房日榜</text>
</view>
<text v-if="day.length > 0" class="day-text">{{ day }}</text>
</view>
<!-- 卡片横向滚动 -->
<!-- 关键:cards-row 必须绑定精确的像素总宽度,原生引擎才能识别横向可滚动内容 -->
<scroll-view v-if="!loading && list.length > 0" class="cards-scroll" direction="horizontal">
<view class="cards-row" :style="`width: ${rowWidth}px;`">
<view
v-for="item in list"
:key="item.top"
class="movie-card"
:style="`width: ${cardWidth}px;`"
>
<!-- 顶部彩色条 -->
<view class="rank-accent" :style="`background-color: ${getRankColor(item.top)};`"></view>
<view class="card-body">
<!-- 排名 + 片名 -->
<view class="card-head">
<view class="rank-badge" :style="`background-color: ${getRankColor(item.top)};`">
<text class="rank-num" :style="`color: ${item.top <= 3 ? '#1a1a2e' : '#ffffff'};`">
{{ item.top }}
</text>
</view>
<view class="name-block">
<text class="movie-name" :numberOfLines="1">{{ item.name }}</text>
<text class="release-text">{{ item.release_date }}</text>
</view>
</view>
<!-- 分割线 -->
<view class="divider"></view>
<!-- 四项指标 2×2 排列 -->
<view class="metrics">
<view class="metric">
<text class="metric-val">{{ item.box_million }}</text>
<text class="metric-label">今日票房</text>
</view>
<view class="metric">
<text class="metric-val metric-highlight">{{ item.share_box }}</text>
<text class="metric-label">票房占比</text>
</view>
<view class="metric metric-bottom">
<text class="metric-val">{{ item.row_films }}</text>
<text class="metric-label">排 片 率</text>
</view>
<view class="metric metric-bottom">
<text class="metric-val">{{ item.row_seats }}</text>
<text class="metric-label">上 座 率</text>
</view>
</view>
</view>
</view>
<!-- 右侧留白 -->
<view style="width: 14px; flex-shrink: 0;"></view>
</view>
</scroll-view>
<!-- 加载中 -->
<view v-if="loading" class="placeholder">
<text class="placeholder-text">加载中...</text>
</view>
</view>
</template>
<script setup lang="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 => {
return 14 + (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 接收标题、分类、影片数组,实现复用。
最初的问题写法
vue
<!-- ❌ 问题写法:.movie-row 没有明确宽度,.movie-card 没有 flex-shrink: 0 -->
<scroll-view class="movie-scroll" direction="horizontal">
<view class="movie-row">
<view class="movie-card" v-for="(movie) in movies" ...>
...
</view>
</view>
</scroll-view>
css
.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)
ts
// 将 220rpx 转换为 px
const cardWidthPx = Math.floor(220 * uni.getWindowInfo().windowWidth / 750)
// 行总宽度 = 10(padding-left) + (卡片宽px + 10间距px) × 数量 + 10(右侧留白px)
const rowWidth = computed(() : number => {
return 10 + (cardWidthPx + 10) * props.movies.length + 10
})
修复2:给卡片加 flex-shrink: 0
css
.movie-card {
width: 220rpx;
flex-shrink: 0; /* 防止卡片被 flex 容器压缩 */
}
完整代码
html
<template>
<view>
<!-- 标题栏 -->
<view class="section-header">
<view class="section-title-wrap">
<view class="section-title-bar"></view>
<text class="section-title">{{ title }}</text>
</view>
<text class="section-more" @click="goMore">更多 ></text>
</view>
<!-- 横向滚动卡片:movie-row 必须设置明确的像素宽度,原生引擎才能识别可滚动区域 -->
<scroll-view class="movie-scroll" direction="horizontal">
<view class="movie-row" :style="`width: ${rowWidth}px;`">
<view
class="movie-card"
v-for="(movie) in movies"
:key="movie.id"
@click="goDetail(movie.id)"
>
<image class="movie-cover" :src="movie.cover" mode="aspectFill" />
<view class="movie-card-info">
<text class="movie-title">{{ movie.title }}</text>
<text :class="movie.rate > 0 ? 'movie-rate' : 'movie-rate-none'">
{{ formatRate(movie.rate) }}
</text>
</view>
</view>
<!-- 右侧留白,防止最后一张紧贴边缘 -->
<view style="width: 10px; flex-shrink: 0;"></view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="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 => {
return 10 + (cardWidthPx + 10) * props.movies.length + 10
})
const formatRate = (rate : number) : string => {
return rate > 0 ? '★ ' + rate.toFixed(1) : '暂无'
}
const goMore = () => {
uni.navigateTo({ url: `/pages/movie/movie-list?type=${props.type}` })
}
const goDetail = (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 宽度的明确像素尺寸 |
html
<!-- ❌ 错误:没有明确宽度,无法滚动 -->
<view class="row">...</view>
<!-- ✅ 正确:绑定精确的像素总宽度 -->
<view class="row" :style="`width: ${rowWidth}px;`">...</view>
rowWidth 的计算公式:
rowWidth = 左padding + (单卡宽px + 卡间距px) × 卡片数量 + 右留白px
坑2:卡片必须设置 flex-shrink: 0
原生 Flexbox 默认 flex-shrink: 1。当父容器(scroll-view)宽度固定时,如果不禁止收缩,卡片就会被压缩进容器,而不是形成可滚动的溢出内容。
css
/* ✅ 必须加,否则卡片实际渲染宽度会失效 */
.card {
flex-shrink: 0;
}
坑3:rpx 不能直接用于 JS 计算行宽
CSS 中的 rpx 是由渲染引擎在绘制时转换的响应式单位,JS 拿不到这个值。凡是涉及到计算行容器总宽度的地方,必须自己换算:
ts
// ✅ 正确:手动换算 rpx → px
const cardWidthPx = Math.floor(220 * uni.getWindowInfo().windowWidth / 750)
// ❌ 错误:220 在 JS 里只是卡片的 rpx 数值,直接相乘结果偏差很大
// const rowWidth = 220 * count // 这是 rpx 数值,不是 px
坑4:scroll-view 本身必须有固定高度
如果 scroll-view 没有设置固定高度,在某些平台上会塌陷为 0,内容不可见。
css
/* ✅ 高度用 rpx 或 px 均可,但必须明确 */
.movie-scroll {
width: 100%;
height: 380rpx;
}
坑5:行容器的 padding 必须计入总宽度
padding-left 占用空间,如果计算行宽时忽略它,最后一张卡片会被截断显示不完整。
ts
// ✅ 正确: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
三者缺一不可。对应到代码模板:
html
<template>
<!-- 要素1:scroll-view 有固定高度 -->
<scroll-view style="width: 100%; height: 200px;" direction="horizontal">
<!-- 要素2:行容器绑定精确像素宽度 -->
<view :style="`width: ${rowWidth}px; flex-direction: row;`">
<view
v-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>
<script setup lang="uts">
import { computed } from 'vue'
// 所有尺寸统一换算为 px
const windowWidth = uni.getWindowInfo().windowWidth
const paddingLeft = 10
const gap = 10
const trailingSpace = 10
// 设计稿给的 rpx 值 → px:rpx值 × windowWidth / 750
const 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、鸿蒙各端均可正常工作。