
??想了解更多關于開源的內容,請訪問:??
??51CTO 開源基礎軟件社區??
??https://ost.51cto.com??
最近在看社區提供的app_samples?,其中有一個線性容器 ArrayList,看我后讓我想起Android中Scroll與ListView嵌套使用時需要解決的滑動沖突問題。
我想在OpenHarmony系統上是否也存在類似問題,Scroll與List嵌套后是否存在滑動問題?
Scroll內嵌套List先說個結論:
1、不會出現List中只顯示一個item問題;
2、滑動事件不會沖突,在List區域可以滑動列表,在非List區域可以滑動Scroll;
3、滾動時,若List不設置寬高,則默認全部加載,在對性能有要求的場景下建議指定List的寬高。
基礎信息
Scroll和List都屬于基礎容器。
Scroll:可滾動的容器組件,當子組件的布局尺寸超過父組件的尺寸時,內容可以滾動。??官方介紹??List:列表包含一系列相同寬度的列表項。適合連續、多行呈現同類數據,例如圖片和文本。官方介紹
需求
既然在OpenHarmony系統中Scroll與List不存在沖突問題,我們做一些其他的嘗試,讓Scroll與List的滾動結合實現聯動。
場景:實現世界杯主界面,包括球員banner、賽事、積分榜。
1、啟動頁,3s后進入主頁面。
2、頭部顯示球員banner,首次顯示3個球員,每隔3秒切換一個球員。
3、球賽列表,包括:對戰球隊、比分、比賽狀態(未開賽、已結束、進行中)、賽程。
4、球賽列表拉到最后一條,觸發全屏顯示積分榜。
5、點擊返回首頁,返回到頁面頂部,球賽列表返回首條顯示。
6、在一個頁面中實現。
草圖

效果
??演示視頻地址??




開發環境
- 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
實踐
聲明:示例中的數據的自己構建的,只為示例顯示使用,與實際比賽數據存在差異,請忽略。
1、創建項目
說明:在DevEco Studio IDE中構建OpenHarmony Stage模型項目,SDK選擇9(3.2.7.6)

2、關鍵代碼
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<Information> {
constructor(infos: Information[]) {
super(infos)
}
}
class BannerDataSource extends BaseDataSource<BannerDataResource> {
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, `listPosition=${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)
}
}
根據代碼說明下實現方式
1、3s進入主頁面,主要通過定時器setTimeout()實現,設置3s后隱藏全屏圖片,全屏圖片父容器使用堆疊容器Stack包裹,通過this.isShowFlashscreen變量判斷是否隱藏全屏圖片,顯示主頁面。
2、主頁面中,最外層通過Scroll容器,作為主頁面的根容器。
3、球員banner使用滑塊視圖容器Swiper,內部使用LazyForEach 懶加載方式加載球員圖片,單屏橫向顯示三個球員,所以球員的圖片高度為屏幕總寬度的33.3%,并將滑塊組件的displayMode屬性設置為SwiperDisplayMode.AutoLinear,讓Swiper滑動一頁的寬度為子組件寬度中的最大值,這樣每次滑動的寬度就是33.3%,一個球員的圖片。
4、賽程列表,使用List組件進行加載,賽事item使用LazyForEach懶加載的方式提交列表加載效率,通過List中的事件監聽器onReachStart(event: () => void)和onReachEnd(event: () => void) 監聽列表達到起始位置或底末尾位置,并在onScrollBegin(event: (dx: number, dy: number) => { dxRemain: number, dyRemain: number })函數中監聽列表的滑動量,如果滑動到List底部,再向上滑動界面時觸發顯示“積分排行”界面。
5、積分排行界面內容,初始化時超屏顯示,只有在滑動到List底部是,才被拉起顯示,積分排行界面設置在Scroll容器中,通過this.scrollerForScroll.scrollEdge(Edge.Bottom) 拉起頁面。
6、點擊"返回首頁",通過設置this.scrollerForScroll.scrollEdge(Edge.Start),返回到Scroll頂部。
代碼中使用到的組件關鍵API
Scroll
名稱 | 功能描述 |
onScrollBegin<sup>9+</sup>(event: (dx: number, dy: number) => { dxRemain: number, dyRemain: number }) | 滾動開始事件回調。 參數: - dx:即將發生的水平方向滾動量。 - dy:即將發生的豎直方向滾動量。 返回值: - dxRemain:水平方向滾動剩余量。 - dyRemain:豎直方向滾動剩余量。 |
說明:
若通過onScrollBegin事件和scrollBy方法實現容器嵌套滾動,需設置子滾動節點的EdgeEffect為None。如Scroll嵌套List滾動時,List組件的edgeEffect屬性需設置為EdgeEffect.None。
Swiper
名稱 | 參數類型 | 描述 |
index | number | 設置當前在容器中顯示的子組件的索引值。<br/>默認值:0 |
autoPlay | boolean | 子組件是否自動播放,自動播放狀態下,導航點不可操作。<br/>默認值:false |
interval | number | 使用自動播放時播放的時間間隔,單位為毫秒。<br/>默認值:3000 |
indicator | boolean | 是否啟用導航點指示器。<br/>默認值:true |
loop | boolean | 是否開啟循環。 設置為true時表示開啟循環,在LazyForEach懶循環加載模式下,加載的組件數量建議大于5個。<br/>默認值:true |
duration | number | 子組件切換的動畫時長,單位為毫秒。<br/>默認值:400 |
vertical | boolean | 是否為縱向滑動。<br/>默認值:false |
itemSpace | number | string | 設置子組件與子組件之間間隙。<br/>默認值:0 |
displayMode | SwiperDisplayMode | 主軸方向上元素排列的模式,優先以displayCount設置的個數顯示,displayCount未設置時本屬性生效。<br/>默認值:SwiperDisplayMode.Stretch |
cachedCount<sup>8+</sup> | number | 設置預加載子組件個數。<br/>默認值:1 |
disableSwipe<sup>8+</sup> | boolean | 禁用組件滑動切換功能。<br/>默認值:false |
curve<sup>8+</sup> | Curve | string | 設置Swiper的動畫曲線,默認為淡入淡出曲線,常用曲線參考Curve枚舉說明,也可以通過[]插值計算模塊提供的接口創建自定義的插值曲線對象。<br/>默認值:Curve.Ease |
indicatorStyle<sup>8+</sup> | {<br/>left?: Length,<br/>top?: Length,<br/>right?: Length,<br/>bottom?: Length,<br/>size?: Length,<br/>mask?: boolean,<br/>color?: ResourceColor,<br/>selectedColor?: ResourceColor<br/>} | 設置導航點樣式:<br/>- left: 設置導航點距離Swiper組件左邊的距離。<br/>- top: 設置導航點距離Swiper組件頂部的距離。<br/>- right: 設置導航點距離Swiper組件右邊的距離。<br/>- bottom: 設置導航點距離Swiper組件底部的距離。<br/>- size: 設置導航點的直徑。<br/>- mask: 設置是否顯示導航點蒙層樣式。<br/>- color: 設置導航點的顏色。<br/>- selectedColor: 設置選中的導航點的顏色。 |
displayCount<sup>8+</sup> | number|string | 設置一頁內元素顯示個數。<br/>默認值:1 |
effectMode<sup>8+</sup> | EdgeEffect | 滑動效果,目前支持的滑動效果參見EdgeEffect的枚舉說明。<br/>默認值:EdgeEffect.Spring |
List
名稱 | 功能描述 |
onReachStart(event: () => void) | 列表到達起始位置時觸發。 |
onReachEnd(event: () => void) | 列表到底末尾位置時觸發。 |
onScrollBegin<sup>9+</sup>(event: (dx: number, dy: number) => { dxRemain: number, dyRemain: number }) | 列表開始滑動時觸發,事件參數傳入即將發生的滑動量,事件處理函數中可根據應用場景計算實際需要的滑動量并作為事件處理函數的返回值返回,列表將按照返回值的實際滑動量進行滑動。<br/>- dx:即將發生的水平方向滑動量。<br/>- dy:即將發生的豎直方向滑動量。<br/>- dxRemain:水平方向實際滑動量。<br/>- dyRemain:豎直方向實際滑動量。 |
??想了解更多關于開源的內容,請訪問:??
??51CTO 開源基礎軟件社區??
??https://ost.51cto.com??