HarmonyOS开发实战:图片全屏查看器:Swiper 轮播 + 缩略图导航
图片全屏查看器:Swiper 轮播 + 缩略图导航
前言
在「海风日记」中,用户写日记时往往会插入多张照片。当用户点击日记卡片中的图片后,App 进入图片全屏查看器(ImageViewerPage):黑色全屏背景 + 主图 Swiper 轮播 + 底部分页圆点 + 缩略图导航条 + 底部操作栏(保存 / 分享 / 更多),构成了一套与 iOS Photos 几乎等价的浏览体验。
本文将从ImageViewerPage.ets源码出发,深入讲解:
- 全屏黑色背景的沉浸式体验设计
- Swiper 主图轮播的实现细节(loop、indicator、onChange)
- 分页圆点指示器的两种状态(当前页拉长)
- 缩略图导航条的水平 Scroll + 选中边框高亮
- 底部操作栏的三段式等分布局
- 大图懒加载与内存优化策略
一个好的图片查看器,核心是「让用户专注于图片本身」—— 一切装饰元素(按钮、指示器)都应当在用户不需要时隐去。
一、整体布局:四段式 Column
ImageViewerPage采用Column 四段式结构,从上到下依次为:顶部操作栏、主图 Swiper、分页圆点 + 缩略图条、底部操作栏。
@Entry@Componentstruct ImageViewerPage{@StateimageUrls:string[]=[]@StatecurrentIndex:number=0@StateshowChrome:boolean=true// 控制顶/底栏显隐build(){Stack(){// 主体:黑色全屏背景Column(){this.TopBarBuilder()// 1. 顶部操作栏this.MainSwiperBuilder()// 2. 主图轮播this.IndicatorBuilder()// 3. 分页圆点 + 缩略图条this.BottomBarBuilder()// 4. 底部操作栏}.height('100%').backgroundColor('#000000')}.height('100%').onClick(()=>{// 单击切换顶/底栏显隐(沉浸式阅读)this.showChrome=!this.showChrome})}}1.1 关键设计点
| 设计点 | 实现 | 收益 |
|---|---|---|
| 黑色全屏背景 | .backgroundColor('#000000') | 让图片成为视觉焦点 |
| 单击切换 UI | showChrome状态控制 | 沉浸式阅读体验 |
| 四段式 Column | TopBar / Swiper / Indicator / BottomBar | 结构清晰、易于维护 |
二、主图轮播:Swiper 核心配置
主图区域使用Swiper组件,配置项有讲究:
@BuilderMainSwiperBuilder(){Swiper(this.swiperController){ForEach(this.imageUrls,(url:string,idx:number)=>{Stack(){Image(url).width('100%').height('100%').objectFit(ImageFit.Contain)// 保持比例,不裁剪.draggable(true)// 允许长按拖拽.onComplete((event)=>{// 图片加载完成回调,可用于埋点})}.width('100%').height('100%')},(url:string)=>url)}.layoutWeight(1).indicator(false)// 关闭自带圆点,使用自定义指示器.loop(false)// 不循环:避免最后一张跳回第一张.duration(300)// 切换动画时长.curve(Curve.EaseInOut)// 切换曲线.onChange((index:number)=>{this.currentIndex=index// 滚动缩略图条到对应位置this.scrollToThumb(index)})}2.1 为什么不用loop(true)?
loop(true)在最后一张右滑时会跳回第一张,看似流畅,但在「日记图片浏览」场景下会造成认知混乱:
- 用户期望「这是第 3 张,右滑没了」
- 而非「右滑回到第 1 张」
loop(false)让用户清楚自己处于序列的哪个位置。
2.2objectFit的选择
| 取值 | 行为 | 适用场景 |
|---|---|---|
Contain | 保持比例,完整显示 | ✅ 图片查看器 |
Cover | 保持比例,填满容器 | ❌ 会裁剪 |
Fill | 拉伸填满 | ❌ 严重变形 |
Auto | 系统决定 | ❌ 不可控 |
图片查看器必须用Contain,否则用户看到的图片是裁切过的,无法判断拍摄内容。
2.3 SwiperController 编程式控制
privateswiperController:SwiperController=newSwiperController()// 在缩略图点击时调用this.swiperController.showIndex(idx)SwiperController让我们能在代码中主动跳转到指定索引,而不仅依赖用户左右滑动。
三、分页圆点指示器:当前页拉长
分页圆点采用经典的「当前页拉长、其余页圆点」设计:
@BuilderIndicatorBuilder(){Row({space:6}){ForEach(this.imageUrls,(url:string,idx:number)=>{Stack().width(this.currentIndex===idx?16:6)// 当前页拉长.height(6).borderRadius(3).backgroundColor(this.currentIndex===idx?'#FFFFFF':'rgba(255,255,255,0.4)').animation({duration:200,curve:Curve.EaseOut,iterations:1})},(url:string,idx:number)=>`${url}-${idx}`)}.width('100%').justifyContent(FlexAlign.Center).padding({top:12,bottom:12})}3.1 动画的妙用
通过.animation()装饰器,圆点从「短」变「长」时会自动播放补间动画,无需手动animateTo。这是 ArkUI 声明式动画的精髓:只描述终态,过程交给框架。
3.2 颜色对比
- 当前页:纯白
#FFFFFF—— 视觉锚点 - 非当前页:半透明白
rgba(255,255,255,0.4)—— 弱化但可见
这种对比能让用户一眼看出「自己在第几张」。
四、缩略图导航条:水平 Scroll + 选中边框
缩略图条是一个水平可滚动的Scroll + Row,每张图片对应一个 56×56vp 的方块,当前选中项带 2vp 白色边框。
privatescroller:Scroller=newScroller()@BuilderThumbnailBarBuilder(){Scroll(this.scroller){Row({space:8}){ForEach(this.imageUrls,(url:string,idx:number)=>{Stack(){Image(url).width('100%').height('100%').objectFit(ImageFit.Cover)// 缩略图可裁剪}.width(56).height(56).borderRadius(8).backgroundColor('#E8A0A0').border({width:this.currentIndex===idx?2:0,color:'#FFFFFF'}).onClick(()=>{this.currentIndex=idxthis.swiperController.showIndex(idx)}).animation({duration:150,curve:Curve.EaseOut})},(url:string)=>url)}.padding({left:14,right:14})}.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width('100%').padding({bottom:8})}4.1 缩略图为什么用objectFit(ImageFit.Cover)?
主图用Contain保真,但缩略图只有 56×56vp,如果用Contain,图片周围会出现大片黑色留白,视觉杂乱。用Cover让图片填满方块,裁掉边缘也无妨——用户只需要识别「这是哪张」即可。
4.2 自动滚动到当前缩略图
scrollToThumb(idx:number){// 每个缩略图占 56 + 8 = 64vp,加上左侧 padding 14vpconstx=Math.max(0,idx*64-100)this.scroller.scrollTo({xOffset:x,yOffset:0,animation:{duration:250,curve:Curve.EaseInOut}})}这个逻辑确保当主图切换时,对应缩略图自动滚动到可视区域,避免用户手动找。
五、底部操作栏:三段式等分布局
底部操作栏使用Row + layoutWeight(1)实现 1:1:1 等分:
@BuilderBottomBarBuilder(){Row({space:0}){// 1. 保存Column({space:4}){SymbolGlyph($r('sys.symbol.square_and_arrow_down')).fontSize(22).fontColor(['#FFFFFF'])Text('保存').fontSize(10).fontColor('rgba(255,255,255,0.6)')}.layoutWeight(1).onClick(()=>this.saveImage())// 2. 分享Column({space:4}){SymbolGlyph($r('sys.symbol.shareplay')).fontSize(22).fontColor(['#FFFFFF'])Text('分享').fontSize(10).fontColor('rgba(255,255,255,0.6)')}.layoutWeight(1).onClick(()=>this.shareImage())// 3. 更多Column({space:4}){SymbolGlyph($r('sys.symbol.ellipsis_circle')).fontSize(22).fontColor(['#FFFFFF'])Text('更多').fontSize(10).fontColor('rgba(255,255,255,0.6)')}.layoutWeight(1).onClick(()=>this.showMoreActions())}.width('100%').height(70).padding({top:12,bottom:12}).backgroundColor('rgba(0,0,0,0.8)')// 半透明黑底,让图片透出来}5.1 为什么space: 0?
space: 0看似多余,但显式写出可以避免父级Row默认间距污染布局。在精确等分场景下,这是好习惯。
5.2 半透明黑底的视觉作用
.backgroundColor('rgba(0,0,0,0.8)')让底部栏不完全遮挡图片,用户在操作时仍能看到主图底部,体验更连贯。
六、顶部操作栏:返回 + 计数 + 多选
@BuilderTopBarBuilder(){Row(){// 返回按钮SymbolGlyph($r('sys.symbol.chevron_left')).fontSize(22).fontColor(['#FFFFFF']).onClick(()=>router.back()).padding({left:14})// 计数文本Text(`${this.currentIndex+1}/${this.imageUrls.length}`).fontSize(14).fontColor('#FFFFFF').layoutWeight(1).textAlign(TextAlign.Center)// 更多操作入口SymbolGlyph($r('sys.symbol.checkmark_circle')).fontSize(20).fontColor(['#FFFFFF']).onClick(()=>this.enterMultiSelectMode()).padding({right:14})}.width('100%').height(56).backgroundColor('rgba(0,0,0,0.8)')}计数文本1 / 8是图片查看器的「灵魂」——它让用户知道还剩多少张要看,避免焦虑感。
七、沉浸式体验:单击切换 UI 显隐
.onClick(()=>{this.showChrome=!this.showChrome})// 在 TopBarBuilder 和 BottomBarBuilder 中.opacity(this.showChrome?1:0).animation({duration:250,curve:Curve.EaseInOut})这是图片查看器最经典的设计:
- 单击主图 → 顶/底栏淡出 → 图片占满整个屏幕
- 再单击 → 顶/底栏淡入 → 可继续操作
八、大图懒加载与内存优化
日记中可能有几十张高清图,如果一次性全部加载,会造成 OOM。优化策略如下:
8.1 使用syncLoad(false)+ 占位图
Image(url).syncLoad(false)// 异步加载,不阻塞 UI.alt($r('app.media.placeholder'))// 占位图.objectFit(ImageFit.Contain)8.2 仅当前页 + 前后各 1 页加载高清
Image(this.shouldLoadHD(idx)?url:this.getThumbnail(url)).objectFit(ImageFit.Contain)8.3 离开页面时释放资源
aboutToDisappear(){// 清理 Image 组件缓存this.imageUrls=[]}九、与其他页面的协作关系
ImageViewerPage在「海风日记」中的调用链路:
DiaryDetailPage / DiaryDetail2Page │ 点击图片 ▼ ImageViewerPage (全屏浏览) │ 长按 / 更多 ▼ ImageMultiSelectPage (多选模式) │ ▼ 保存到相册 / 分享 / 删除十、常见问题与踩坑记录
Q1:Swiper 切换时主图闪烁?
原因:Image默认每次切换都重新加载。
解决:使用.syncLoad(false)+ 占位图,并在onChange中预加载下一张。
Q2:缩略图条点击无反应?
原因:SwiperController.showIndex()在aboutToAppear之前调用,控制器未初始化。
解决:在aboutToAppear中初始化控制器。
Q3:黑色背景下 Text 看不清?
原因:默认fontColor是黑色。
解决:所有覆盖在黑底上的文本必须显式设fontColor('#FFFFFF')或半透明白。
十一、总结
本文通过「海风日记」的图片查看器,深入讲解了全屏图片浏览的完整实现:
- Swiper 轮播——
loop(false)+objectFit(Contain)+SwiperController - 分页圆点—— 当前页拉长 + 半透明非当前页
- 缩略图导航—— 水平 Scroll + 选中边框 + 自动滚动
- 底部操作栏—— 三段式等分 + 半透明黑底
- 沉浸式体验—— 单击切换 UI 显隐
- 内存优化—— 异步加载 + 缩略图预览 + 离开释放
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源
- Swiper 组件文档
- Scroll 组件文档
- Image 组件文档
- 海风日记项目源码
- HarmonyOS 开发者官网
- ArkUI 动画文档
