鸿蒙HarmonyOS开发:基于Swiper组件和自定义指示器实现多图片进度条轮播功能
文章目录
- 一、概述
- 1、场景介绍
- 2、技术选型
- 二、实现方案
- 1、图片区域实现
- 2、底部导航点设计
- 3、手动切换
- 三、所有代码
- 1、设置沉浸式
- 2、外层Tabs效果
- 3、ImageSwiper组件
- 四、效果展示
一、概述
在短视频平台上,经常可以见到多图片合集。它的特点是:由多张图片组成一个合集,图片可以自动进行轮播,也可以手动去进行图片切换。自动轮播时,图片下方的进度条缓慢加载至完成状态;手动切换时,图片下方的进度条瞬间切换至已完成状态或未完成状态。
由于原生Swiper组件自带的导航点指示器目前只支持数字和圆点的样式,不支持对应的特殊样式,因此需要通过自定义指示器(即进度条)来模拟底部的导航条效果。
1、场景介绍
常见的图文作品,可以自动循环播放和手动切换播放合集中的图片。
-
当作品自动播放时,图片每过几秒会自动切换到下一张,且下方进度条进度与该图片的停留时间匹配。
-
当作品手动播放时,下方进度条会跟着图片的滑动切换而改变成未完成状态或已完成状态。
2、技术选型
从技术角度看,图文作品轮播效果可通过Swiper组件和它的指示器的联动效果实现,由于Swiper组件的指示器无法自定义,所以需要拆开实现:
-
上面图片的轮播部分继续使用Swiper组件实现。
-
下面的指示器,由于Swiper组件只有两种显示模式,一个是圆点,一个是数字,很明显是不能实现进度条的效果。所以需要关闭原生指示器,自定义一个指示器。
二、实现方案
1、图片区域实现
图片区域需要使用Swiper组件来实现。将图片合集的数据传入Swiper组件后,需要对Swiper组件设置一些属性,来完成图片自动轮播效果:
-
通过设置loop属性控制是否循环播放,该属性默认值为true。当loop为true时,在显示第一页或最后一页时,可以继续往前切换到前一页或者往后切换到后一页。如果loop为false,则在第一页或最后一页时,无法继续向前或者向后切换页面。
-
通过设置autoPlay属性,控制是否自动轮播子组件。该属性默认值为false。autoPlay为true时,会自动切换播放子组件。
-
通过设置interval属性,控制子组件与子组件之间的播放间隔。interval属性默认值为3000,单位毫秒。
-
通过设置indicator属性为false,来关闭Swiper组件自带的导航点指示器样式。
-
通过设置indicatorInteractive属性为false,来设置禁用组件导航点交互功能。
Swiper(this.swiperController) {
LazyForEach(this.data, (item: PhotoData) => {
Image($r(`app.media.` + item.id))
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
}, (item: PhotoData) => JSON.stringify(item))
}
.width('100%')
.height('100%')
.autoPlay(true)
.indicator(false)
.loop(false)
.indicatorInteractive(false)
.duration(300)
.curve(Curve.Linear)
示意效果如下图所示。
2、底部导航点设计
底部导航点(进度条)有三种样式:未完成状态的样式、已完成状态的样式和正在进行进度增长的样式。
-
使用层叠布局 (Stack),配合Row容器来实现进度条的布局。
-
要实现进度条缓慢增长至完成状态且用时与图片播放时间相匹配的效果,可以给Row容器组件添加属性动画 (animation),设置duration(动画持续时间)与图片播放时间匹配即可。
-
进度条状态切换:通过播放图片的currentIndex与进度条的index进行比较,当currentIndex大于或等于index时,需要将进度条样式设置成已完成状态,否则是未完成状态。可以通过设置完成时进度条的背景颜色为Color.White或Color.Grey来实现两种样式的进度条切换。
创建自定义组件progressComponent。
@Builder
progressComponent() {
Row({ space: 5 }) {
ForEach(this.progressData, (item: PhotoData, index: number) => {
Stack({ alignContent: Alignment.Start }) {
// 底层灰色
Row()
.zIndex(0)
.width('100%')
.height(2)
.borderRadius(2)
.backgroundColor(Color.Grey)
//上层白色
Row()
.zIndex(1)
.width(this.currentIndex >= index ? '100%' : 0)
.height(2)
.borderRadius(2)
.backgroundColor(Color.White)
.animation({
duration: this.duration - 400,
curve: Curve.Linear,
iterations: 1,
playMode: PlayMode.Normal,
onFinish: () => {
if (this.currentIndex === this.progressData.length - 1) {
this.duration = 400;
this.currentIndex = -1;
}
}
})
}
.layoutWeight(1)
}, (item: PhotoData) => JSON.stringify(item))
}
.width('100%')
.height(40)
}
上述代码中,this.progressData为图片集合的数组,this.currentIndex为当前播放的图片在图片集合数组中的索引,index为进度条对应的图片在图片集合数组中的索引。当this.currentIndex >= index时,表示图片集合数组中索引0-index的进度条都是已完成状态。
示意效果如下图所示。
3、手动切换
当图片集合手动播放时,随着图片的切换,下方进度条会跟随着切换为已完成状态或未完成状态。此时,开发者需要给Swiper组件添加onGestureSwipe事件,来判断页面是否跟手滑动。
Swiper(this.swiperController) {
// ...
}
.onGestureSwipe((index: number, extraInfo: SwiperAnimationEvent) => {
this.slide = true;
})
slide为布尔值,用来判断页面是否跟手滑动。默认值为false,当页面跟手滑动时,slide的值为true。
然后根据slide是否为手动滑动来判断:是否循环播放,是否自动轮播,进度条动画效果等功能。
三、所有代码
-
外层包个Tabs实现仿抖音效果。
-
上面图片的轮播部分使用Swiper组件实现。
-
下面的指示器,需要关闭原生指示器,自定义指示器(进度条)来实现。
-
设置窗口的布局为沉浸式布局,设置状态栏文字颜色为白色。
1、设置沉浸式
// EntryAbility.ets
onWindowStageCreate(windowStage: window.WindowStage): void {
// Main window is created, set main page for this ability
hilog.info(0x0000, 'testTag', '%{public}s', 'Ability onWindowStageCreate');
// 获取该WindowStage实例下的主窗口。
const mainWindow = windowStage.getMainWindowSync();
// 设置主窗口的布局是否为沉浸式布局。
mainWindow.setWindowLayoutFullScreen(true).then(() => {
hilog.info(0x0000, 'testTag', 'Succeeded in setting the window layout to full-screen mode');
}).catch((err: BusinessError) => {
hilog.info(0x0000, 'testTag', 'Failed to set the window layout to full-screen mode. Cause: %{public}s', JSON.stringify(err) ?? '');
})
// 状态栏文字颜色。
const sysBarProps: window.SystemBarProperties = {
statusBarContentColor: '#ffffff'
};
// 设置主窗口三键导航栏、状态栏的属性。
mainWindow.setWindowSystemBarProperties(sysBarProps).then(() => {
hilog.info(0x0000, 'testTag', 'Succeeded in setting the system bar properties');
}).catch((err: BusinessError) => {
hilog.info(0x0000, 'testTag', 'Failed to set system bar properties. Cause: %{public}s', JSON.stringify(err) ?? '');
})
// ........
}
2、外层Tabs效果
其他文章有详细介绍Tabs效果的具体实现。此处不再赘述。
// MultipleImagePage.ets
import { ImageSwiper } from "./ImageSwiper";
interface TabBar {
icon?: Resource
text?: string
}
//自定义tabBar样式
@Extend(Column)
function tabBarContainerStyle() {
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor(Color.Transparent)
}
@Entry
@Component
struct MultipleImagePage {
@State selectedTabIndex: number = 0
private tabBars: TabBar[] = [
{ text: '首页' },
{ text: '朋友' },
{ text: '发布', icon: $r('app.media.add') },
{ text: '消息' },
{ text: '我' }
]
//文字tabBar
@Builder
TabBarTextBuilder(tabBarText: string, tabIndex: number) {
Column() {
Text(tabBarText)
.fontColor(Color.White)
.opacity(
this.selectedTabIndex === tabIndex ? 1 : 0.6
)
}
.tabBarContainerStyle()
}
//中间图片tabBar
@Builder
TabBarIconBuilder(icon: Resource) {
Column() {
Image(icon)
.width(36)
.margin({bottom:8})
}
.tabBarContainerStyle()
}
build() {
Tabs({ barPosition: BarPosition.End }) {
ForEach(this.tabBars, (tabBar: TabBar, index) => {
TabContent() {
if (index === 0) {
ImageSwiper()
} else {
Column() {
Text(tabBar.text)
.fontColor(Color.White)
.fontSize(40)
}
.width('100%')
.height('100%')
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.backgroundColor(Color.Black)
}
}
.tabBar(
tabBar.icon ?
this.TabBarIconBuilder(tabBar.icon) :
this.TabBarTextBuilder(tabBar.text, index)
)
}, (tabBar: TabBar) => JSON.stringify(tabBar))
}
.barOverlap(true) // 设置TabBar是否背后变模糊并叠加在TabContent之上。
.barHeight(56) // 设置TabBar的高度值。
.backgroundColor(Color.Transparent)
.barBackgroundColor(Color.Transparent) // 设置TabBar的背景颜色。
.barBackgroundBlurStyle(BlurStyle.NONE) // 设置TabBar的背景模糊材质。关闭模糊
.divider({ strokeWidth: 1, color: 'rgba(255, 255, 255, 0.20)' }) // 设置区分TabBar和TabContent的分割线样式。
.onChange((index: number) => {
this.selectedTabIndex = index
})
.hitTestBehavior(HitTestMode.Transparent) // 设置组件的触摸测试类型。自身和子节点都响应触摸测试,不会阻塞兄弟节点的触摸测试。不会影响祖先节点的触摸测试。
}
}
3、ImageSwiper组件
import { DataSource, PhotoData } from '../model/ImageData'
import { OperateButton } from "./OperateButton";
@Extend(Text)
function videoInfoStyle() {
.fontSize(14)
.fontColor('rgba(255, 255, 255, 0.80)')
}
@Component
export struct ImageSwiper {
private swiperController: SwiperController = new SwiperController();
@State progressData: PhotoData[] = [];
@State data: DataSource = new DataSource([]);
@State currentIndex: number = -1;
@State duration: number = 3000;
@State slide: boolean = false;
// 进度条
@Builder
progressComponent() {
Row({ space: 5 }) {
ForEach(this.progressData, (item: PhotoData, index: number) => {
Stack({ alignContent: Alignment.Start }) {
Row()
.zIndex(0)
.width('100%')
.height(2)
.borderRadius(2)
.backgroundColor(Color.Grey)
Row()
.zIndex(1)
.width(this.currentIndex >= index && !this.slide ? '100%' : 0)
.height(2)
.borderRadius(2)
.backgroundColor(Color.White)
.animation(!this.slide ? {
duration: this.duration - 400,
curve: Curve.Linear,
iterations: 1,
playMode: PlayMode.Normal,
onFinish: () => {
if (this.currentIndex === this.progressData.length - 1) {
this.duration = 400;
this.currentIndex = -1;
}
}
} : { duration: 0 })
Row()
.zIndex(2)
.width(this.currentIndex >= index && this.slide ? '100%' : 0)
.height(2)
.borderRadius(2)
.backgroundColor(Color.White)
}
.layoutWeight(1)
}, (item: PhotoData) => JSON.stringify(item))
}
.width('100%')
.height(40)
.margin({ bottom: 60 })
}
//底部文字
@Builder
bottomTextComponent() {
Column({ space: 15 }) {
Row({ space: 10 }) {
Text('@山猫')
.fontColor(Color.White)
Text('2024-12-23 14:52')
.videoInfoStyle()
}
Text('海的那边,是迷雾中的诗,是浪尖上的歌,还是梦里的远方')
.videoInfoStyle()
}
.padding(16)
.width('80%')
.alignItems(HorizontalAlign.Start)
.margin({ right: '20%', bottom: 100 })
.hitTestBehavior(HitTestMode.Transparent)
}
//右侧操作栏
@Builder
rightOperateComponent() {
Column({ space: 20 }) {
OperateButton({
head: $r('app.media.user'),
likeCount: 123,
commentCount: 234,
collectCount: 345,
shareCount: 456
})
}
.width('20%')
.padding(16)
.margin({ bottom: 100 })
}
//轮播数据创建
aboutToAppear() {
let list: PhotoData[] = [];
for (let i = 1; i <= 7; i++) {
let newPhotoData = new PhotoData();
newPhotoData.id = i;
list.push(newPhotoData);
}
this.progressData = list;
this.data = new DataSource(list);
}
build() {
Stack({ alignContent: Alignment.BottomEnd }) {
//轮播图片
Swiper(this.swiperController) {
LazyForEach(this.data, (item: PhotoData) => {
Image($r(`app.media.` + item.id))
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
}, (item: PhotoData) => JSON.stringify(item))
}
.width('100%')
.height('100%')
.autoPlay(!this.slide ? true : false)
.indicator(false)
.loop(!this.slide ? true : false)
.indicatorInteractive(false)
.duration(400)
.curve(Curve.Linear)
.onChange((index) => {
this.duration = 3000;
this.currentIndex = index;
})
.onAppear(() => {
this.currentIndex = 0;
})
.onGestureSwipe((index: number, extraInfo: SwiperAnimationEvent) => {
this.slide = true;
})
// 底部文字描述
this.bottomTextComponent();
// 右侧操作栏
this.rightOperateComponent()
// 进度条
this.progressComponent();
}
}
}
四、效果展示
-
运行应用后,不滑动屏幕时,图片自动轮播,且下方进度条缓慢增长至已完成状态,播放完成时,会继续循环播放。
-
滑动屏幕时,图片跟随滑动方向而进行切换,此时会关闭自动轮播和循环播放的效果,且下方进度条瞬间增长至已完成状态。