鸿蒙上实现世界杯主界面
最近在看社区提供的 app_samples,其中有一个线性容器 ArrayList,看我后让我想起 Android 中 Scroll 与 ListView 嵌套使用时需要解决的滑动冲突问题。
我想在 OpenHarmony 系统上是否也存在类似问题,Scroll 与 List 嵌套后是否存在滑动问题?
Scroll 内嵌套 List 先说个结论: 不会出现 List 中只显示一个 item 问题 滑动事件不会冲突,在 List 区域可以滑动列表,在非 List 区域可以滑动 Scroll 滚动时,若 List 不设置宽高,则默认全部加载,在对性能有要求的场景下建议指定 List 的宽高
基础信息
Scroll 和 List 都属于基础容器:
Scroll: 可滚动的容器组件,当子组件的布局尺寸超过父组件的尺寸时,内容可以滚动。
官方介绍: https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/arkui-ts/ts-container-scroll.md
List: 列表包含一系列相同宽度的列表项。适合连续、多行呈现同类数据,例如图片和文本。
官方介绍: https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/arkui-ts/ts-container-list.md
需求
既然在 OpenHarmony 系统中 Scroll 与 List 不存在冲突问题,我们做一些其他的尝试,让 Scroll 与 List 的滚动结合实现联动。
场景:实现世界杯主界面,包括球员 banner、赛事、积分榜。 启动页,3s 后进入主页面 头部显示球员 banner,首次显示 3 个球员,每隔 3 秒切换一个球员 球赛列表,包括:对战球队、比分、比赛状态(未开赛、已结束、进行中)、赛程 球赛列表拉到最后一条,触发全屏显示积分榜 点击返回首页,返回到页面顶部,球赛列表返回首条显示 在一个页面中实现 草图如下:
效果如下:
开发环境 IDE:DevEco Studio 3.0 Beta4 Build Version: 3.0.0.992, built on July 14, 2022SDK:Full SDK 9 3.2.7.6系统:OpenHarmony v3.2 beta3
实践
声明:示例中的数据的自己构建的,只为示例显示使用,与实际比赛数据存在差异,请忽略。 ①创建项目
说明:在 DevEco Studio IDE 中构建 OpenHarmony Stage 模型项目,SDK 选择 9(3.2.7.6)。
②关键代码import { BaseDataSource } from "../MainAbility/model/BaseDataSource" import { Information } from "../MainAbility/model/Information" import { MatchInfo, MatchState } from "../MainAbility/common/FlagData" import { MatchDataResource } from "../MainAbility/model/MatchDataResource" import { BannerDataResource } from "../MainAbility/model/BannerDataResource" const TAG: string = "ScrollList" // 0代表滚动到List顶部,1代表中间值,2代表滚动到List底部 const SCROLL_LIST_POSITION = { START: 0, CENTER: 1, END: 2 } const LIST_START = { TOP: 0, BUTTON: 1 } class MatchDataSource extends BaseDataSource { constructor(infos: Information[]) { super(infos) } } class BannerDataSource extends BaseDataSource { constructor(infos: BannerDataResource[]) { super(infos) } } function mock(): Information[] { var infos = [] for (var i = 0; i < 10; i++) { var item = new Information() item.id = i item.state = Math.floor(Math.random() * 2) // 获取0~2的随机整数 var homeIndex: number = Math.floor(Math.random() * 12) // 获取0~12的随机整数 item.homeName = MatchInfo[homeIndex].name item.homeFlag = MatchInfo[homeIndex].resource var awayFieldIndex: number = Math.floor(Math.random() * 12) // 获取0~12的随机整数 if (awayFieldIndex === homeIndex) { awayFieldIndex = Math.floor(Math.random() * 12) // 获取0~12的随机整数 } item.awayFieldName = MatchInfo[awayFieldIndex].name item.awayFieldFlag = MatchInfo[awayFieldIndex].resource if (item.state != MatchState.NOTSTART) { item.homeScore = Math.floor(Math.random() * 6) item.awayFiledScore = Math.floor(Math.random() * 6) } var data: number = Math.floor(Math.random() * 20) // 获取0~20的随机整数 var time: number = Math.floor(Math.random() * 24) // 获取0~24的随机整数 item.gameTime = "12 - " + data + " " + time + " : 00" infos[i] = item } return infos } function mockBanner(): BannerDataResource[] { var banners = [{ id: 1, resource: $r("app.media.banner_01") }, { id: 2, resource: $r("app.media.banner_02") }, { id: 3, resource: $r("app.media.banner_03") }, { id: 4, resource: $r("app.media.banner_04") }, { id: 5, resource: $r("app.media.banner_05") } ] return banners } @Entry @Component struct Index { private listPosition: number = SCROLL_LIST_POSITION.START @State private listState: number = LIST_START.TOP private scrollerForScroll: Scroller = new Scroller() // 可滚动容器组件的控制器 private scrollerForList: Scroller = new Scroller() // mock数据 private matchData: Information[] = mock() private matchDataSource: MatchDataSource = new MatchDataSource(this.matchData) // banner private bannerData: BannerDataResource[] = mockBanner() private bannerDataSource: BannerDataSource = new BannerDataSource(this.bannerData) private swiperController: SwiperController = new SwiperController() @State private isShowFlashscreen: boolean = true private timeOutID: number aboutToAppear() { this.startTimeout() } aboutToDisappear() { this.stopTimeout() } build() { Stack() { if (this.isShowFlashscreen) { Image($r("app.media.flashscreen")) .width("100%") .height("100%") .objectFit(ImageFit.Cover) } else { Scroll(this.scrollerForScroll) { Column() { Swiper(this.swiperController) { LazyForEach(this.bannerDataSource, (item: BannerDataResource) => { Image(item.resource) .width("33.3%") .height("100%") .objectFit(ImageFit.Cover) }, item => item.id.toString()) } .width("100%") .height("35%") .cachedCount(3) .index(0) .autoPlay(true) .loop(true) .displayMode(SwiperDisplayMode.AutoLinear) .indicator(false) .indicatorStyle({ selectedColor: $r("app.color.red_bg") }) Divider().strokeWidth(3).color($r("app.color.red_bg")) Column() { List({ space: 10, scroller: this.scrollerForList }) { LazyForEach(this.matchDataSource, (item: Information) => { ListItem() { Row() { Column({ space: 10 }) { Image(item.homeFlag) .width(60) .height(45) .objectFit(ImageFit.Contain) Text(item.homeName) .width("100%") .fontSize(16) .textAlign(TextAlign.Center) } .width("30%") Column({ space: 10 }) { Text(this.getMatchState(item.state)) .width("100%") .fontSize(12) .fontColor($r("app.color.event_text")) .textAlign(TextAlign.Center) Text(this.getMatchSource(item)) .width("100%") .fontSize(18) .textAlign(TextAlign.Center) Text(item.gameType) .width("100%") .fontSize(12) .fontColor($r("app.color.event_text")) .textAlign(TextAlign.Center) } .width("30%") Column({ space: 10 }) { Image(item.awayFieldFlag) .width(60) .height(45) .objectFit(ImageFit.Contain) Text(item.awayFieldName) .width("100%") .fontSize(16) .textAlign(TextAlign.Center) } .width("30%") } .width("100%") .height("100%") .justifyContent(FlexAlign.SpaceBetween) .border({ radius: 15 }) .backgroundColor($r("app.color.white")) } .width("100%") .height(95) }, item => item.id.toString()) } .width("90%") .height("100%") .edgeEffect(EdgeEffect.Spring) // 滑动效果 .onReachStart(() => { // 滑动开始 this.listPosition = SCROLL_LIST_POSITION.START }) .onReachEnd(() => { // 滑动结束 this.listPosition = SCROLL_LIST_POSITION.END }) .onScrollBegin((dx: number, dy: number) => { console.info(TAG, `listPositinotallow=${this.listPosition} dx=${dx} ,dy=${dy}`) if (this.listPosition == SCROLL_LIST_POSITION.START && dy >= 0) { // 列表顶部 // this.scrollerForScroll.scrollBy(0, -dy) this.scrollerForScroll.scrollEdge(Edge.Start) this.listState = LIST_START.TOP } else if (this.listPosition == SCROLL_LIST_POSITION.END && dy <= 0) { // 列表底部 // this.scrollerForScroll.scrollBy(0, -dy) this.scrollerForScroll.scrollEdge(Edge.Bottom) this.listState = LIST_START.BUTTON } this.listPosition = SCROLL_LIST_POSITION.CENTER return { dxRemain: dx, dyRemain: dy } }) } .width("100%") .height("60%") .padding({ top: 20, bottom: 20 }) .borderRadius({ bottomLeft: 15, bottomRight: 15 }) .backgroundColor($r("app.color.content_bg")) Column() { if (this.listState === LIST_START.TOP) { Text("继续上滑 积分排名") .width("100%") .height("5%") .fontColor($r("app.color.white")) .fontSize(14) .textAlign(TextAlign.Center) } else { Text("回到首页") .width("100%") .height("5%") .fontColor($r("app.color.white")) .fontSize(14) .textAlign(TextAlign.Center) .onClick(() => { this.scrollerForScroll.scrollEdge(Edge.Start) this.scrollerForList.scrollToIndex(0) this.listState = LIST_START.TOP }) } Stack() { Image($r("app.media.result_1")) .width("100%") .height("100%") .objectFit(ImageFit.Cover) Column() { }.width("100%") .height("100%") .backgroundColor("#55000000") Image($r("app.media.football_poster")) .width("100%") .height("100%") .objectFit(ImageFit.Contain) .opacity(0.70) .borderRadius({ topLeft: 15, topRight: 15 }) }.width("100%") .height("95%") } .width("100%") .height("100%") } } .width("100%") .height("100%") .onScrollBegin((dx: number, dy: number) => { return { dxRemain: dx, dyRemain: 0 } }) } }.width("100%") .height("100%") .backgroundColor($r("app.color.main_bg")) } getMatchState(state: number): string { var stateVal: string switch (state) { case MatchState.PROGRESS: { stateVal = "进行中" break; } case MatchState.NOTSTART: { stateVal = "未开赛" break; } case MatchState.CLOSED: { stateVal = "已结束" break; } default: stateVal = "" } return stateVal; } getMatchSource(data: Information): string { if (data.state === MatchState.NOTSTART) { return "- : -" } else { return data.homeScore + " : " + data.awayFiledScore } } startTimeout() { this.timeOutID = setTimeout(() => { this.isShowFlashscreen = false }, 3000) } stopTimeout() { clearTimeout(this.timeOutID) } }
根据代码说明下实现方式:
① 3s 进入主页面,主要通过定时器 setTimeout() 实现,设置 3s 后隐藏全屏图片。
全屏图片父容器使用堆叠容器 Stack 包裹,通过 this.isShowFlashscreen 变量判断是否隐藏全屏图片,显示主页面。
② 主页面中,最外层通过 Scroll 容器,作为主页面的根容器。
③ 球员 banner 使用滑块视图容器 Swiper,内部使用 LazyForEach 懒加载方式加载球员图片,单屏横向显示三个球员,所以球员的图片高度为屏幕总宽度的 33.3%。
并将滑块组件的 displayMode 属性设置为 SwiperDisplayMode.AutoLinear,让 Swiper 滑动一页的宽度为子组件宽度中的最大值,这样每次滑动的宽度就是 33.3%,一个球员的图片。
④ 赛程列表,使用 List 组件进行加载,赛事 item 使用 LazyForEach 懒加载的方式提交列表加载效率。
通过 List 中的事件监听器 onReachStart(event: () => void) 和 onReachEnd(event: () => void) 监听列表达到起始位置或底末尾位置。
并在 onScrollBegin(event: (dx: number, dy: number) => { dxRemain: number, dyRemain: number }) 函数中监听列表的滑动量,如果滑动到 List 底部,再向上滑动界面时触发显示"积分排行"界面。
⑤ 积分排行界面内容,初始化时超屏显示,只有在滑动到 List 底部是,才被拉起显示。
积分排行界面设置在 Scroll 容器中,通过 this.scrollerForScroll.scrollEdge(Edge.Bottom) 拉起页面。
⑥ 点击"返回首页",通过设置 this.scrollerForScroll.scrollEdge(Edge.Start),返回到 Scroll 顶部。
代码中使用到的组件关键 API①Scroll
说明:若通过 onScrollBegin 事件和 scrollBy 方法实现容器嵌套滚动,需设置子滚动节点的 EdgeEffect 为 None。如 Scroll 嵌套 List 滚动时,List 组件的 edgeEffect 属性需设置为 EdgeEffect.None。②Swiper
③List
完整代码:https://gitee.com/xjszzz9/open-harmony-ark-ui-scroll-list-o
如果您能看到最后,还希望您能动动手指点个赞,一个人能走多远关键在于与谁同行,我用跨越山海的一路相伴,希望得到您的点赞。