#盲盒+码#OpenHarmony ArkUI 卡塔尔世界杯-Scroll与List嵌套使用 原创 精华
【本文正在参加「盲盒」+码有奖征文活动】https://ost.51cto.com/posts/19288
作者:徐金生
最近在看社区提供的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 }) | 滚动开始事件回调。<br>参数:<br>- dx:即将发生的水平方向滚动量。<br>- dy:即将发生的竖直方向滚动量。<br>返回值:<br>- dxRemain:水平方向滚动剩余量。<br>- 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 | 是否开启循环。<br>设置为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:竖直方向实际滑动量。 |
完整代码
感谢
如果您能看到最后,还希望您能动动手指点个赞,一个人能走多远关键在于与谁同行,我用跨越山海的一路相伴,希望得到您的点赞。
滚动和滑动效果非常不错,贴一下视频链接:
https://ost.51cto.com/show/19976
草图的部分基本都实现了
监听列表的滑动量的操作学到了
不错,用自己的方式支持世界杯