鴻蒙上實現“世界盃”主介面

最近在看社群提供的 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, 2022

SDK: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@Componentstruct 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

如果您能看到最後,還希望您能動動手指點個贊,一個人能走多遠關鍵在於與誰同行,我用跨越山海的一路相伴,希望得到您的點贊。